Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
513a285fee | ||
|
|
b2bb3baa01 | ||
|
|
3f933dd166 | ||
|
|
1180b9afb0 | ||
|
|
b4ce6efd7b | ||
|
|
da0ada925f | ||
|
|
f9346931f8 | ||
|
|
1d18abf6c1 | ||
|
|
a644d30228 | ||
|
|
1c453e2981 | ||
|
|
170abfcc2f | ||
|
|
0539b15ddc | ||
|
|
bbadb1a917 | ||
|
|
480ca46a95 | ||
|
|
b7bdb1a502 | ||
|
|
3be528d03a | ||
|
|
ebc386cfa5 | ||
|
|
35f3a347ba | ||
|
|
cfcc9f82d8 | ||
|
|
7ab4254cd0 | ||
|
|
e3857e61c6 | ||
|
|
a02579d6dd | ||
|
|
79910dfec7 | ||
|
|
b40540b118 | ||
|
|
99711fc44e | ||
|
|
3eafad7261 | ||
|
|
80905d9d29 | ||
|
|
34db0da87c | ||
|
|
ff33554716 | ||
|
|
f399d17721 | ||
|
|
ce71f22316 | ||
|
|
1f3f76373d | ||
|
|
c5fe2f5e68 | ||
|
|
c050998f3d | ||
|
|
4802a8f6e6 | ||
|
|
03e516e568 | ||
|
|
ef37397969 | ||
|
|
c6d122008b | ||
|
|
3dce031f8e | ||
|
|
91d8776637 | ||
|
|
01f242b7c3 | ||
|
|
d5cf71c840 | ||
|
|
5ba70d9764 | ||
|
|
673476d773 | ||
|
|
5eb9fda015 | ||
|
|
81586caea0 | ||
|
|
a195126df1 | ||
|
|
b3783bab40 | ||
|
|
33d0f93e68 | ||
|
|
2050b05d6a | ||
|
|
38754b9d1a | ||
|
|
1867484032 | ||
|
|
b52ed19649 | ||
|
|
d53ababf7d | ||
|
|
ff43763721 | ||
|
|
c44d7633f2 | ||
|
|
08d641eb42 | ||
|
|
a243a044b9 | ||
|
|
c95a819eaf | ||
|
|
ce5ae675ea | ||
|
|
0828618c0d | ||
|
|
7267101021 | ||
|
|
0e868cef58 | ||
|
|
e410ccb2f4 | ||
|
|
c5b3f2ece6 | ||
|
|
35353c58cb | ||
|
|
e80d8e73ae | ||
|
|
494a35a0c3 | ||
|
|
818bbb4a30 | ||
|
|
4755c0eeb9 | ||
|
|
c8c89fdc95 | ||
|
|
52e0924f1c | ||
|
|
645c758b42 | ||
|
|
4dc7788981 | ||
|
|
9fa945a863 | ||
|
|
38b8695441 | ||
|
|
eadadb5d1d | ||
|
|
5f424e2e0b | ||
|
|
d807cd2de7 | ||
|
|
0b4e3a8da9 | ||
|
|
31be5e9a25 | ||
|
|
367538eeea | ||
|
|
442c1cb5f1 | ||
|
|
a333e4524f | ||
|
|
1e54f7d99b | ||
|
|
9f2aa8282d | ||
|
|
8d5dc440d0 | ||
|
|
8fffde0165 | ||
|
|
1a6e99971a | ||
|
|
4de88c3add | ||
|
|
76374893e3 | ||
|
|
edc17b304a | ||
|
|
ec7b4528f6 | ||
|
|
8d75c4afb1 | ||
|
|
b30fbf90b9 | ||
|
|
8fb95e1b06 | ||
|
|
f5e1a0569f | ||
|
|
929f87b411 | ||
|
|
59d97008f2 | ||
|
|
540b8ebb4d | ||
|
|
109d4afce2 | ||
|
|
aab8bce78e | ||
|
|
d2c33b4caf | ||
|
|
6443544a6b | ||
|
|
a56ac84186 | ||
|
|
443a9ea101 | ||
|
|
0faafe8bc4 | ||
|
|
9948701127 | ||
|
|
ffae92d233 | ||
|
|
74db087d7d | ||
|
|
e00239562c | ||
|
|
8df67bf76a | ||
|
|
80d4670204 | ||
|
|
ed2ab36ed4 | ||
|
|
0d6c5878fc | ||
|
|
dab76df131 | ||
|
|
d1c19d9d3e | ||
|
|
11b052e5bb | ||
|
|
7e1ba42873 | ||
|
|
0bef82ec32 | ||
|
|
f80bb6c42d | ||
|
|
01a74829fc | ||
|
|
7b77b7f5e9 | ||
|
|
ce8fcd2269 | ||
|
|
f6f64eca10 | ||
|
|
c103d7012b | ||
|
|
1f1a3c5de8 | ||
|
|
3bfd96defe | ||
|
|
cad590f993 |
@@ -1,8 +1,9 @@
|
|||||||
name: CI
|
name: Push to Master/Main CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
|
- main
|
||||||
- master
|
- master
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -24,6 +25,25 @@ jobs:
|
|||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: deno install
|
run: deno install
|
||||||
|
|
||||||
|
- name: Cache Deno dependencies
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/deno
|
||||||
|
./deno.lock
|
||||||
|
key: ${{ runner.os }}-deno-${{ hashFiles('**/deno.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-deno-
|
||||||
|
|
||||||
|
- name: Cache Dependencies
|
||||||
|
run: deno cache src/index.tsx
|
||||||
|
|
||||||
|
- name: Run linter
|
||||||
|
run: deno task lint
|
||||||
|
|
||||||
|
- name: Check formatter
|
||||||
|
run: deno task format --check
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: deno task test
|
run: deno task test
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
name: Pull Request
|
name: Pull Request CI
|
||||||
|
|
||||||
on: pull_request
|
on:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -14,9 +15,28 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
deno-version: v2.x
|
deno-version: v2.x
|
||||||
|
|
||||||
|
- name: Cache Deno dependencies
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/deno
|
||||||
|
./deno.lock
|
||||||
|
key: ${{ runner.os }}-deno-${{ hashFiles('**/deno.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-deno-
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: deno install
|
run: deno install
|
||||||
|
|
||||||
|
- name: Cache Dependencies
|
||||||
|
run: deno cache src/index.tsx
|
||||||
|
|
||||||
|
- name: Run linter
|
||||||
|
run: deno task lint
|
||||||
|
|
||||||
|
- name: Check formatter
|
||||||
|
run: deno task format --check
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: deno task test
|
run: deno task test
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
name: 'Release'
|
name: Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
types: [released]
|
types: [released, prereleased]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -38,6 +38,12 @@ jobs:
|
|||||||
name: build
|
name: build
|
||||||
path: dist/build.tar
|
path: dist/build.tar
|
||||||
|
|
||||||
|
- name: Attach build.tar to release
|
||||||
|
run: |
|
||||||
|
gh release upload ${{ github.event.release.tag_name }} dist/build.tar
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
name: Update Stable Branch from Master on Latest Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [released]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-stable-branch:
|
||||||
|
name: Update Stable Branch from Master
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Configure Git
|
||||||
|
run: |
|
||||||
|
git config user.name "GitHub Actions Bot"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
|
- name: Fetch latest master and stable branches
|
||||||
|
run: |
|
||||||
|
git fetch origin master:master
|
||||||
|
git fetch origin stable:stable || echo "Stable branch not found remotely, will create."
|
||||||
|
|
||||||
|
- name: Get latest master commit SHA
|
||||||
|
id: get_master_sha
|
||||||
|
run: echo "MASTER_SHA=$(git rev-parse master)" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Check out stable branch
|
||||||
|
run: |
|
||||||
|
if git show-ref --verify --quiet refs/heads/stable; then
|
||||||
|
git checkout stable
|
||||||
|
git pull origin stable # Sync with remote stable if it exists
|
||||||
|
else
|
||||||
|
echo "Creating local stable branch based on master HEAD."
|
||||||
|
git checkout -b stable ${{ env.MASTER_SHA }}
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Reset stable branch to latest master
|
||||||
|
run: git reset --hard ${{ env.MASTER_SHA }}
|
||||||
|
|
||||||
|
- name: Force push stable branch
|
||||||
|
run: git push origin stable --force
|
||||||
@@ -50,11 +50,20 @@ docker run -d -p 8080:8080 --restart always --name Meshtastic-Web ghcr.io/meshta
|
|||||||
podman run -d -p 8080:8080 --restart always --name Meshtastic-Web ghcr.io/meshtastic/web
|
podman run -d -p 8080:8080 --restart always --name Meshtastic-Web ghcr.io/meshtastic/web
|
||||||
```
|
```
|
||||||
|
|
||||||
## Nightly releases
|
## Release Schedule
|
||||||
|
|
||||||
Our nightly releases provide the latest development builds with cutting-edge
|
Our release process follows these guidelines:
|
||||||
features and fixes. These builds are automatically generated from the latest
|
|
||||||
main branch every night and are available for testing and early adoption.
|
- **Versioning:** We use Semantic Versioning (`Major.Minor.Patch`).
|
||||||
|
- **Stable Releases:** Published around the beginning of each month (e.g.,
|
||||||
|
`v2.3.4`).
|
||||||
|
- **Pre-releases:** A pre-release is typically issued mid-month for testing and
|
||||||
|
early adoption.
|
||||||
|
- **Nightly Builds:** An experimental Docker image containing the latest
|
||||||
|
cutting-edge features and fixes is automatically built nightly from the
|
||||||
|
`master` branch.
|
||||||
|
|
||||||
|
### Nightly Builds
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# With Docker
|
# With Docker
|
||||||
@@ -73,7 +82,7 @@ podman run -d -p 8080:8080 --restart always --name Meshtastic-Web ghcr.io/meshta
|
|||||||
> new features
|
> new features
|
||||||
> - No guarantee of backward compatibility between nightly builds
|
> - No guarantee of backward compatibility between nightly builds
|
||||||
|
|
||||||
### Version Information
|
#### Version Information
|
||||||
|
|
||||||
Each nightly build is tagged with:
|
Each nightly build is tagged with:
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,19 @@
|
|||||||
],
|
],
|
||||||
"strictPropertyInitialization": false
|
"strictPropertyInitialization": false
|
||||||
},
|
},
|
||||||
|
"fmt": {
|
||||||
|
"exclude": [
|
||||||
|
"*.test.ts",
|
||||||
|
"*.test.tsx"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"exclude": [
|
||||||
|
"*.test.ts",
|
||||||
|
"*.test.tsx"
|
||||||
|
],
|
||||||
|
"report": "pretty"
|
||||||
|
},
|
||||||
"unstable": [
|
"unstable": [
|
||||||
"sloppy-imports"
|
"sloppy-imports"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -40,70 +40,72 @@
|
|||||||
"@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth",
|
"@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth",
|
||||||
"@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial",
|
"@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial",
|
||||||
"@bufbuild/protobuf": "^2.2.5",
|
"@bufbuild/protobuf": "^2.2.5",
|
||||||
"@noble/curves": "^1.8.1",
|
"@noble/curves": "^1.9.0",
|
||||||
"@radix-ui/react-accordion": "^1.2.3",
|
"@radix-ui/react-accordion": "^1.2.8",
|
||||||
"@radix-ui/react-checkbox": "^1.1.4",
|
"@radix-ui/react-checkbox": "^1.2.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.6",
|
"@radix-ui/react-dialog": "^1.1.11",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
||||||
"@radix-ui/react-label": "^2.1.2",
|
"@radix-ui/react-label": "^2.1.4",
|
||||||
"@radix-ui/react-menubar": "^1.1.6",
|
"@radix-ui/react-menubar": "^1.1.12",
|
||||||
"@radix-ui/react-popover": "^1.1.6",
|
"@radix-ui/react-popover": "^1.1.11",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
"@radix-ui/react-scroll-area": "^1.2.6",
|
||||||
"@radix-ui/react-select": "^2.1.6",
|
"@radix-ui/react-select": "^2.2.2",
|
||||||
"@radix-ui/react-separator": "^1.1.2",
|
"@radix-ui/react-separator": "^1.1.4",
|
||||||
"@radix-ui/react-switch": "^1.1.3",
|
"@radix-ui/react-slider": "^1.3.2",
|
||||||
"@radix-ui/react-tabs": "^1.1.3",
|
"@radix-ui/react-switch": "^1.2.2",
|
||||||
"@radix-ui/react-toast": "^1.2.6",
|
"@radix-ui/react-tabs": "^1.1.9",
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
"@radix-ui/react-toast": "^1.2.11",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.4",
|
||||||
"@turf/turf": "^7.2.0",
|
"@turf/turf": "^7.2.0",
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"crypto-random-string": "^5.0.0",
|
"crypto-random-string": "^5.0.0",
|
||||||
|
"idb-keyval": "^6.2.1",
|
||||||
"immer": "^10.1.1",
|
"immer": "^10.1.1",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"lucide-react": "^0.486.0",
|
"lucide-react": "^0.507.0",
|
||||||
"maplibre-gl": "5.3.0",
|
"maplibre-gl": "5.4.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-error-boundary": "^5.0.0",
|
"react-error-boundary": "^6.0.0",
|
||||||
"react-hook-form": "^7.55.0",
|
"react-hook-form": "^7.56.2",
|
||||||
"react-map-gl": "8.0.2",
|
"react-map-gl": "8.0.4",
|
||||||
"react-qrcode-logo": "^3.0.0",
|
"react-qrcode-logo": "^3.0.0",
|
||||||
"rfc4648": "^1.5.4",
|
"rfc4648": "^1.5.4",
|
||||||
"vite-plugin-node-polyfills": "^0.23.0",
|
"vite-plugin-node-polyfills": "^0.23.0",
|
||||||
"zod": "^3.24.2",
|
"zod": "^3.24.3",
|
||||||
"zustand": "5.0.3"
|
"zustand": "5.0.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.0",
|
"@tailwindcss/postcss": "^4.1.5",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.2.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/chrome": "^0.0.313",
|
"@types/chrome": "^0.0.318",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/node": "^22.13.17",
|
"@types/node": "^22.15.3",
|
||||||
"@types/react": "^19.0.12",
|
"@types/react": "^19.1.2",
|
||||||
"@types/react-dom": "^19.0.4",
|
"@types/react-dom": "^19.1.3",
|
||||||
"@types/serviceworker": "^0.0.127",
|
"@types/serviceworker": "^0.0.133",
|
||||||
"@types/w3c-web-serial": "^1.0.8",
|
"@types/w3c-web-serial": "^1.0.8",
|
||||||
"@types/web-bluetooth": "^0.0.21",
|
"@types/web-bluetooth": "^0.0.21",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"gzipper": "^8.2.1",
|
"gzipper": "^8.2.1",
|
||||||
"happy-dom": "^17.4.4",
|
"happy-dom": "^17.4.6",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"simple-git-hooks": "^2.12.1",
|
"simple-git-hooks": "^2.13.0",
|
||||||
"tailwind-merge": "^3.1.0",
|
"tailwind-merge": "^3.2.0",
|
||||||
"tailwindcss": "^4.1.0",
|
"tailwindcss": "^4.1.5",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tar": "^7.4.3",
|
"tar": "^7.4.3",
|
||||||
"testing-library": "^0.0.2",
|
"testing-library": "^0.0.2",
|
||||||
"typescript": "^5.8.2",
|
"typescript": "^5.8.3",
|
||||||
"vite": "^6.2.4",
|
"vite": "^6.3.4",
|
||||||
"vitest": "^3.1.1",
|
"vitest": "^3.1.2",
|
||||||
"vite-plugin-pwa": "^1.0.0"
|
"vite-plugin-pwa": "^1.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="512" height="512" viewBox="0 0 512 512" xml:space="preserve">
|
||||||
|
<desc>Created with Fabric.js 4.6.0</desc>
|
||||||
|
<defs>
|
||||||
|
</defs>
|
||||||
|
<g transform="matrix(1 0 0 1 256 256)" id="xYQ9Gk9Jwpgj_HMOXB3F_" >
|
||||||
|
<path style="stroke: rgb(213,130,139); stroke-width: 0; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(103,234,148); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" transform=" translate(-256, -256)" d="M 0 0 L 512 0 L 512 512 L 0 512 z" stroke-linecap="round" />
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1.79 0 0 1.79 313.74 258.36)" id="1xBsk2n9FZp60Rz1O-ceJ" >
|
||||||
|
<path style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: round; stroke-miterlimit: 2; fill: rgb(44,45,60); fill-rule: evenodd; opacity: 1;" vector-effect="non-scaling-stroke" transform=" translate(-250.97, -362.41)" d="M 250.908 330.267 L 193.126 415.005 L 180.938 406.694 L 244.802 313.037 C 246.174 311.024 248.453 309.819 250.889 309.816 C 253.326 309.814 255.606 311.015 256.982 313.026 L 320.994 406.536 L 308.821 414.869 L 250.908 330.267 Z" stroke-linecap="round" />
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1.81 0 0 1.81 145 256.15)" id="KxN7E9YpbyPgz0S4z4Cl6" >
|
||||||
|
<path style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: round; stroke-miterlimit: 2; fill: rgb(44,45,60); fill-rule: evenodd; opacity: 1;" vector-effect="non-scaling-stroke" transform=" translate(-115.14, -528.06)" d="M 87.642 581.398 L 154.757 482.977 L 142.638 474.713 L 75.523 573.134 L 87.642 581.398 Z" stroke-linecap="round" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
@@ -1,5 +0,0 @@
|
|||||||
# Copyright Notice
|
|
||||||
Copyright © 2024 Meshtastic LLC. All Rights Reserved.
|
|
||||||
|
|
||||||
## In reference to the GNU GPLv3 License terms defined in Section 7e
|
|
||||||
Images (or assets) in this directory are protected under international copyright laws and treaties. Unauthorized reproduction, distribution, modification, or use of these images in any form, commercial or otherwise, outside of official Meshtastic creative works or its Backers and Partners is strictly prohibited without prior written consent from the copyright holder (Meshtastic LLC).
|
|
||||||
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
@@ -1,6 +1,5 @@
|
|||||||
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
|
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
|
||||||
import { PageRouter } from "@app/PageRouter.tsx";
|
import { PageRouter } from "@app/PageRouter.tsx";
|
||||||
import { DeviceSelector } from "@components/DeviceSelector.tsx";
|
|
||||||
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
|
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
|
||||||
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx";
|
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx";
|
||||||
import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx";
|
import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx";
|
||||||
@@ -14,7 +13,8 @@ import { ErrorBoundary } from "react-error-boundary";
|
|||||||
import { ErrorPage } from "@components/UI/ErrorPage.tsx";
|
import { ErrorPage } from "@components/UI/ErrorPage.tsx";
|
||||||
import { MapProvider } from "react-map-gl/maplibre";
|
import { MapProvider } from "react-map-gl/maplibre";
|
||||||
import { CommandPalette } from "@components/CommandPalette/index.tsx";
|
import { CommandPalette } from "@components/CommandPalette/index.tsx";
|
||||||
|
import { SidebarProvider } from "@core/stores/sidebarStore.tsx";
|
||||||
|
import { useTheme } from "@core/hooks/useTheme.ts";
|
||||||
|
|
||||||
export const App = (): JSX.Element => {
|
export const App = (): JSX.Element => {
|
||||||
const { getDevice } = useDeviceStore();
|
const { getDevice } = useDeviceStore();
|
||||||
@@ -23,6 +23,9 @@ export const App = (): JSX.Element => {
|
|||||||
|
|
||||||
const device = getDevice(selectedDevice);
|
const device = getDevice(selectedDevice);
|
||||||
|
|
||||||
|
// Sets up light/dark mode based on user preferences or system settings
|
||||||
|
useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary FallbackComponent={ErrorPage}>
|
<ErrorBoundary FallbackComponent={ErrorPage}>
|
||||||
<NewDeviceDialog
|
<NewDeviceDialog
|
||||||
@@ -33,12 +36,15 @@ export const App = (): JSX.Element => {
|
|||||||
/>
|
/>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<DeviceWrapper device={device}>
|
<DeviceWrapper device={device}>
|
||||||
<div className="flex h-screen flex-col overflow-hidden bg-background-primary text-text-primary">
|
<div
|
||||||
<div className="flex grow">
|
className="flex h-screen flex-col bg-background-primary text-text-primary"
|
||||||
<DeviceSelector />
|
style={{ scrollbarWidth: "thin" }}
|
||||||
<div className="flex grow flex-col">
|
>
|
||||||
{device ? (
|
<SidebarProvider>
|
||||||
<div className="flex h-screen w-full">
|
<div className="h-full flex flex-col">
|
||||||
|
{device
|
||||||
|
? (
|
||||||
|
<div className="h-full flex w-full">
|
||||||
<DialogManager />
|
<DialogManager />
|
||||||
<KeyBackupReminder />
|
<KeyBackupReminder />
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
@@ -46,14 +52,15 @@ export const App = (): JSX.Element => {
|
|||||||
<PageRouter />
|
<PageRouter />
|
||||||
</MapProvider>
|
</MapProvider>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)
|
||||||
|
: (
|
||||||
<>
|
<>
|
||||||
<Dashboard />
|
<Dashboard />
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SidebarProvider>
|
||||||
</div>
|
</div>
|
||||||
</DeviceWrapper>
|
</DeviceWrapper>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { vi } from 'vitest'
|
import { vi } from "vitest";
|
||||||
|
|
||||||
vi.mock('@components/UI/Button.tsx', () => ({
|
vi.mock("@components/UI/Button.tsx", () => ({
|
||||||
Button: ({ children, name, disabled, onClick }: {
|
Button: ({ children, name, disabled, onClick }: {
|
||||||
children: React.ReactNode,
|
children: React.ReactNode;
|
||||||
variant: string,
|
variant: string;
|
||||||
name: string,
|
name: string;
|
||||||
disabled?: boolean,
|
disabled?: boolean;
|
||||||
onClick: () => void
|
onClick: () => void;
|
||||||
}) =>
|
}) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
name={name}
|
name={name}
|
||||||
@@ -17,4 +17,5 @@ vi.mock('@components/UI/Button.tsx', () => ({
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
@@ -1,6 +1,19 @@
|
|||||||
import { vi } from 'vitest'
|
import { vi } from "vitest";
|
||||||
|
|
||||||
vi.mock('@components/UI/Checkbox.tsx', () => ({
|
vi.mock("@components/UI/Checkbox.tsx", () => ({
|
||||||
Checkbox: ({ id, checked, onChange }: { id: string, checked: boolean, onChange: () => void }) =>
|
Checkbox: (
|
||||||
<input data-testid="checkbox" type="checkbox" id={id} checked={checked} onChange={onChange} />
|
{ id, checked, onChange }: {
|
||||||
|
id: string;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: () => void;
|
||||||
|
},
|
||||||
|
) => (
|
||||||
|
<input
|
||||||
|
data-testid="checkbox"
|
||||||
|
type="checkbox"
|
||||||
|
id={id}
|
||||||
|
checked={checked}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
@@ -1,43 +1,45 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
|
|
||||||
export const Dialog = ({ children, open }: {
|
export const Dialog = ({ children, open }: {
|
||||||
children: React.ReactNode,
|
children: React.ReactNode;
|
||||||
open: boolean,
|
open: boolean;
|
||||||
onOpenChange?: (open: boolean) => void
|
onOpenChange?: (open: boolean) => void;
|
||||||
}) => open ? <div data-testid="dialog">{children}</div> : null;
|
}) => open ? <div data-testid="dialog">{children}</div> : null;
|
||||||
|
|
||||||
export const DialogContent = ({
|
export const DialogContent = ({
|
||||||
children,
|
children,
|
||||||
className
|
className,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode,
|
children: React.ReactNode;
|
||||||
className?: string
|
className?: string;
|
||||||
}) => <div data-testid="dialog-content" className={className}>{children}</div>;
|
}) => <div data-testid="dialog-content" className={className}>{children}</div>;
|
||||||
|
|
||||||
export const DialogHeader = ({
|
export const DialogHeader = ({
|
||||||
children
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode;
|
||||||
}) => <div data-testid="dialog-header">{children}</div>;
|
}) => <div data-testid="dialog-header">{children}</div>;
|
||||||
|
|
||||||
export const DialogTitle = ({
|
export const DialogTitle = ({
|
||||||
children
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode;
|
||||||
}) => <div data-testid="dialog-title">{children}</div>;
|
}) => <div data-testid="dialog-title">{children}</div>;
|
||||||
|
|
||||||
export const DialogDescription = ({
|
export const DialogDescription = ({
|
||||||
children,
|
children,
|
||||||
className
|
className,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode,
|
children: React.ReactNode;
|
||||||
className?: string
|
className?: string;
|
||||||
}) => <div data-testid="dialog-description" className={className}>{children}</div>;
|
}) => (
|
||||||
|
<div data-testid="dialog-description" className={className}>{children}</div>
|
||||||
|
);
|
||||||
|
|
||||||
export const DialogFooter = ({
|
export const DialogFooter = ({
|
||||||
children,
|
children,
|
||||||
className
|
className,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode,
|
children: React.ReactNode;
|
||||||
className?: string
|
className?: string;
|
||||||
}) => <div data-testid="dialog-footer" className={className}>{children}</div>;
|
}) => <div data-testid="dialog-footer" className={className}>{children}</div>;
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
import { vi } from 'vitest'
|
import { vi } from "vitest";
|
||||||
|
|
||||||
vi.mock('@components/UI/Label.tsx', () => ({
|
vi.mock("@components/UI/Label.tsx", () => ({
|
||||||
Label: ({ children, htmlFor, className }: { children: React.ReactNode, htmlFor: string, className?: string }) =>
|
Label: (
|
||||||
<label data-testid="label" htmlFor={htmlFor} className={className}>{children}</label>
|
{ children, htmlFor, className }: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
htmlFor: string;
|
||||||
|
className?: string;
|
||||||
|
},
|
||||||
|
) => (
|
||||||
|
<label data-testid="label" htmlFor={htmlFor} className={className}>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
|
|
||||||
vi.mock('@components/UI/Typography/Link.tsx', () => ({
|
vi.mock("@components/UI/Typography/Link.tsx", () => ({
|
||||||
Link: ({ children, href, className }: { children: React.ReactNode, href: string, className?: string }) =>
|
Link: (
|
||||||
<a data-testid="link" href={href} className={className}>{children}</a>
|
{ children, href, className }: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
href: string;
|
||||||
|
className?: string;
|
||||||
|
},
|
||||||
|
) => <a data-testid="link" href={href} className={className}>{children}</a>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
BatteryFullIcon,
|
||||||
|
BatteryLowIcon,
|
||||||
|
BatteryMediumIcon,
|
||||||
|
PlugZapIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
|
||||||
|
|
||||||
|
interface DeviceMetrics {
|
||||||
|
batteryLevel?: number | null;
|
||||||
|
voltage?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BatteryStatusProps {
|
||||||
|
deviceMetrics?: DeviceMetrics | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BatteryStateConfig {
|
||||||
|
condition: (level: number) => boolean;
|
||||||
|
Icon: React.ElementType;
|
||||||
|
className: string;
|
||||||
|
text: (level: number) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const batteryStates: BatteryStateConfig[] = [
|
||||||
|
{
|
||||||
|
condition: (level) => level > 100,
|
||||||
|
Icon: PlugZapIcon,
|
||||||
|
className: "text-gray-500",
|
||||||
|
text: () => "Plugged in",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
condition: (level) => level > 80,
|
||||||
|
Icon: BatteryFullIcon,
|
||||||
|
className: "text-green-500",
|
||||||
|
text: (level) => `${level}% charging`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
condition: (level) => level > 20,
|
||||||
|
Icon: BatteryMediumIcon,
|
||||||
|
className: "text-yellow-500",
|
||||||
|
text: (level) => `${level}% charging`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
condition: () => true,
|
||||||
|
Icon: BatteryLowIcon,
|
||||||
|
className: "text-red-500",
|
||||||
|
text: (level) => `${level}% charging`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const getBatteryState = (level: number) => {
|
||||||
|
return batteryStates.find((state) => state.condition(level));
|
||||||
|
};
|
||||||
|
|
||||||
|
const BatteryStatus: React.FC<BatteryStatusProps> = ({ deviceMetrics }) => {
|
||||||
|
if (
|
||||||
|
deviceMetrics?.batteryLevel === undefined ||
|
||||||
|
deviceMetrics?.batteryLevel === null
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { batteryLevel, voltage } = deviceMetrics;
|
||||||
|
const currentState = getBatteryState(batteryLevel) ??
|
||||||
|
batteryStates[batteryStates.length - 1];
|
||||||
|
|
||||||
|
const BatteryIcon = currentState.Icon;
|
||||||
|
const iconClassName = currentState.className;
|
||||||
|
const statusText = currentState.text(batteryLevel);
|
||||||
|
|
||||||
|
const voltageTitle = `${voltage?.toPrecision(3) ?? "Unknown"} volts`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 mt-0.5 text-gray-500"
|
||||||
|
title={voltageTitle}
|
||||||
|
>
|
||||||
|
<BatteryIcon size={22} className={iconClassName} />
|
||||||
|
<Subtle aria-label="Battery">
|
||||||
|
{statusText}
|
||||||
|
</Subtle>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BatteryStatus;
|
||||||
@@ -17,8 +17,10 @@ import {
|
|||||||
FactoryIcon,
|
FactoryIcon,
|
||||||
LayersIcon,
|
LayersIcon,
|
||||||
LinkIcon,
|
LinkIcon,
|
||||||
|
type LucideIcon,
|
||||||
MapIcon,
|
MapIcon,
|
||||||
MessageSquareIcon,
|
MessageSquareIcon,
|
||||||
|
Pin,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
PowerIcon,
|
PowerIcon,
|
||||||
QrCodeIcon,
|
QrCodeIcon,
|
||||||
@@ -27,8 +29,6 @@ import {
|
|||||||
SmartphoneIcon,
|
SmartphoneIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
Pin,
|
|
||||||
type LucideIcon,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Avatar } from "@components/UI/Avatar.tsx";
|
import { Avatar } from "@components/UI/Avatar.tsx";
|
||||||
@@ -57,11 +57,14 @@ export const CommandPalette = () => {
|
|||||||
const {
|
const {
|
||||||
commandPaletteOpen,
|
commandPaletteOpen,
|
||||||
setCommandPaletteOpen,
|
setCommandPaletteOpen,
|
||||||
|
setConnectDialogOpen,
|
||||||
setSelectedDevice,
|
setSelectedDevice,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
const { getDevices } = useDeviceStore();
|
const { getDevices } = useDeviceStore();
|
||||||
const { setDialogOpen, setActivePage, connection } = useDevice();
|
const { setDialogOpen, setActivePage, getNode, connection } = useDevice();
|
||||||
const { pinnedItems, togglePinnedItem } = usePinnedItems({ storageName: 'pinnedCommandMenuGroups' });
|
const { pinnedItems, togglePinnedItem } = usePinnedItems({
|
||||||
|
storageName: "pinnedCommandMenuGroups",
|
||||||
|
});
|
||||||
|
|
||||||
const groups: Group[] = [
|
const groups: Group[] = [
|
||||||
{
|
{
|
||||||
@@ -114,15 +117,12 @@ export const CommandPalette = () => {
|
|||||||
label: "Switch Node",
|
label: "Switch Node",
|
||||||
icon: ArrowLeftRightIcon,
|
icon: ArrowLeftRightIcon,
|
||||||
subItems: getDevices().map((device) => ({
|
subItems: getDevices().map((device) => ({
|
||||||
label:
|
label: getNode(device.hardware.myNodeNum)?.user?.longName ??
|
||||||
device.nodes.get(device.hardware.myNodeNum)?.user?.longName ??
|
|
||||||
device.hardware.myNodeNum.toString(),
|
device.hardware.myNodeNum.toString(),
|
||||||
icon: (
|
icon: (
|
||||||
<Avatar
|
<Avatar
|
||||||
text={
|
text={getNode(device.hardware.myNodeNum)?.user?.shortName ??
|
||||||
device.nodes.get(device.hardware.myNodeNum)?.user?.shortName ??
|
device.hardware.myNodeNum.toString()}
|
||||||
device.hardware.myNodeNum.toString()
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
action() {
|
action() {
|
||||||
@@ -134,7 +134,7 @@ export const CommandPalette = () => {
|
|||||||
label: "Connect New Node",
|
label: "Connect New Node",
|
||||||
icon: PlusIcon,
|
icon: PlusIcon,
|
||||||
action() {
|
action() {
|
||||||
setSelectedDevice(0);
|
setConnectDialogOpen(true);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -219,10 +219,10 @@ export const CommandPalette = () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "[WIP] Clear Messages",
|
label: "Clear All Stored Message",
|
||||||
icon: EraserIcon,
|
icon: EraserIcon,
|
||||||
action() {
|
action() {
|
||||||
alert("This feature is not implemented");
|
setDialogOpen("deleteMessages", true);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -248,7 +248,10 @@ export const CommandPalette = () => {
|
|||||||
}, [setCommandPaletteOpen]);
|
}, [setCommandPaletteOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandDialog open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen}>
|
<CommandDialog
|
||||||
|
open={commandPaletteOpen}
|
||||||
|
onOpenChange={setCommandPaletteOpen}
|
||||||
|
>
|
||||||
<CommandInput placeholder="Type a command or search..." />
|
<CommandInput placeholder="Type a command or search..." />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>No results found.</CommandEmpty>
|
<CommandEmpty>No results found.</CommandEmpty>
|
||||||
@@ -262,13 +265,11 @@ export const CommandPalette = () => {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => togglePinnedItem(group.label)}
|
onClick={() => togglePinnedItem(group.label)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"transition-all duration-300 scale-100 cursor-pointer m-0.5 p-2 focus:*:data-label:opacity-100"
|
"transition-all duration-300 scale-100 cursor-pointer p-2 focus:*:data-label:opacity-100",
|
||||||
)}
|
)}
|
||||||
aria-description={
|
aria-description={pinnedItems.includes(group.label)
|
||||||
pinnedItems.includes(group.label)
|
|
||||||
? "Unpin command group"
|
? "Unpin command group"
|
||||||
: "Pin command group"
|
: "Pin command group"}
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
data-label
|
data-label
|
||||||
@@ -280,7 +281,7 @@ export const CommandPalette = () => {
|
|||||||
"transition-opacity",
|
"transition-opacity",
|
||||||
pinnedItems.includes(group.label)
|
pinnedItems.includes(group.label)
|
||||||
? "opacity-100 text-red-500"
|
? "opacity-100 text-red-500"
|
||||||
: "opacity-40 hover:opacity-70"
|
: "opacity-40 hover:opacity-70",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
import { DeviceSelectorButton } from "@components/DeviceSelectorButton.tsx";
|
|
||||||
import ThemeSwitcher from "@components/ThemeSwitcher.tsx";
|
|
||||||
import { Separator } from "@components/UI/Seperator.tsx";
|
|
||||||
import { Code } from "@components/UI/Typography/Code.tsx";
|
|
||||||
import { useAppStore } from "@core/stores/appStore.ts";
|
|
||||||
import { useDeviceStore } from "@core/stores/deviceStore.ts";
|
|
||||||
import { HomeIcon, PlusIcon, SearchIcon } from "lucide-react";
|
|
||||||
import { Avatar } from "@components/UI/Avatar.tsx";
|
|
||||||
|
|
||||||
export const DeviceSelector = () => {
|
|
||||||
const { getDevices } = useDeviceStore();
|
|
||||||
const {
|
|
||||||
selectedDevice,
|
|
||||||
setSelectedDevice,
|
|
||||||
setCommandPaletteOpen,
|
|
||||||
setConnectDialogOpen,
|
|
||||||
} = useAppStore();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav className="flex flex-col justify-between border-r-[0.5px] border-slate-300 pt-2 dark:border-slate-700">
|
|
||||||
<div className="flex flex-col overflow-y-hidden">
|
|
||||||
<ul className="flex w-20 grow flex-col items-center space-y-4 bg-transparent py-4 px-5">
|
|
||||||
<DeviceSelectorButton
|
|
||||||
active={selectedDevice === 0}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedDevice(0);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<HomeIcon />
|
|
||||||
</DeviceSelectorButton>
|
|
||||||
{getDevices().map((device) => (
|
|
||||||
<DeviceSelectorButton
|
|
||||||
key={device.id}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedDevice(device.id);
|
|
||||||
}}
|
|
||||||
active={selectedDevice === device.id}
|
|
||||||
>
|
|
||||||
<Avatar
|
|
||||||
text={device.nodes
|
|
||||||
.get(device.hardware.myNodeNum)
|
|
||||||
?.user?.shortName.toString() ?? "UNK"}
|
|
||||||
/>
|
|
||||||
</DeviceSelectorButton>
|
|
||||||
))}
|
|
||||||
<Separator />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setConnectDialogOpen(true)}
|
|
||||||
className="transition-all duration-300"
|
|
||||||
>
|
|
||||||
<PlusIcon />
|
|
||||||
</button>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-20 flex-col items-center space-y-5 px-5 pb-5">
|
|
||||||
<ThemeSwitcher />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="transition-all hover:text-accent"
|
|
||||||
onClick={() => setCommandPaletteOpen(true)}
|
|
||||||
>
|
|
||||||
<SearchIcon />
|
|
||||||
</button>
|
|
||||||
{/* TODO: This is being commented out until its fixed */}
|
|
||||||
{
|
|
||||||
/* <button type="button" className="transition-all hover:text-accent">
|
|
||||||
<LanguagesIcon />
|
|
||||||
</button> */
|
|
||||||
}
|
|
||||||
<Separator />
|
|
||||||
<Code>{import.meta.env.VITE_COMMIT_HASH}</Code>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
export interface DeviceSelectorButtonProps {
|
|
||||||
active: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DeviceSelectorButton = ({
|
|
||||||
onClick,
|
|
||||||
children,
|
|
||||||
}: DeviceSelectorButtonProps) => (
|
|
||||||
<li
|
|
||||||
className="aspect-w-1 aspect-h-1 relative w-full"
|
|
||||||
onClick={onClick}
|
|
||||||
onKeyDown={onClick}
|
|
||||||
>
|
|
||||||
{
|
|
||||||
/* {active && (
|
|
||||||
<div className="absolute -left-2 h-10 w-1.5 rounded-full bg-accent" />
|
|
||||||
)} */
|
|
||||||
}
|
|
||||||
<div className="flex aspect-square cursor-pointer flex-col items-center justify-center">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
// Ensure the path is correct for import
|
||||||
|
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
||||||
|
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
|
||||||
|
|
||||||
|
vi.mock("@core/stores/messageStore", () => ({
|
||||||
|
useMessageStore: vi.fn(() => ({
|
||||||
|
deleteAllMessages: vi.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("DeleteMessagesDialog", () => {
|
||||||
|
const mockOnOpenChange = vi.fn();
|
||||||
|
const mockClearAllMessages = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockOnOpenChange.mockClear();
|
||||||
|
mockClearAllMessages.mockClear();
|
||||||
|
|
||||||
|
const mockedUseMessageStore = vi.mocked(useMessageStore);
|
||||||
|
mockedUseMessageStore.mockImplementation(() => ({
|
||||||
|
deleteAllMessages: mockClearAllMessages,
|
||||||
|
}));
|
||||||
|
mockedUseMessageStore.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onOpenChange with false when the close button (X) is clicked", () => {
|
||||||
|
render(
|
||||||
|
<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />,
|
||||||
|
);
|
||||||
|
const closeButton = screen.queryByTestId("dialog-close-button");
|
||||||
|
if (!closeButton) {
|
||||||
|
throw new Error(
|
||||||
|
"Dialog close button with data-testid='dialog-close-button' not found. Did you add it to the component?",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fireEvent.click(closeButton);
|
||||||
|
expect(mockOnOpenChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the dialog when open is true", () => {
|
||||||
|
render(
|
||||||
|
<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Clear All Messages")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/This action will clear all message history./))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Dismiss" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Clear Messages" }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render the dialog when open is false", () => {
|
||||||
|
render(
|
||||||
|
<DeleteMessagesDialog open={false} onOpenChange={mockOnOpenChange} />,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("Clear All Messages")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onOpenChange with false when the dismiss button is clicked", () => {
|
||||||
|
render(
|
||||||
|
<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
|
||||||
|
expect(mockOnOpenChange).toHaveBeenCalledTimes(1); // Add count check
|
||||||
|
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls deleteAllMessages and onOpenChange with false when the clear messages button is clicked", () => {
|
||||||
|
render(
|
||||||
|
<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Clear Messages" }));
|
||||||
|
expect(mockClearAllMessages).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockOnOpenChange).toHaveBeenCalledTimes(1); // Add count check
|
||||||
|
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Button } from "@components/UI/Button.tsx";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@components/UI/Dialog.tsx";
|
||||||
|
import { AlertTriangleIcon } from "lucide-react";
|
||||||
|
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
||||||
|
|
||||||
|
export interface DeleteMessagesDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeleteMessagesDialog = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: DeleteMessagesDialogProps) => {
|
||||||
|
const { deleteAllMessages } = useMessageStore();
|
||||||
|
const handleCloseDialog = () => {
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogClose data-testid="dialog-close-button" />
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertTriangleIcon className="h-5 w-5 text-warning" />
|
||||||
|
Clear All Messages
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This action will clear all message history. This cannot be undone.
|
||||||
|
Are you sure you want to continue?
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCloseDialog}
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
deleteAllMessages();
|
||||||
|
handleCloseDialog();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear Messages
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -10,10 +10,11 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@components/UI/Dialog.tsx";
|
} from "@components/UI/Dialog.tsx";
|
||||||
import { Input } from "@components/UI/Input.tsx";
|
|
||||||
import { Label } from "@components/UI/Label.tsx";
|
import { Label } from "@components/UI/Label.tsx";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
import { GenericInput } from "@components/Form/FormInput.tsx";
|
||||||
|
import { validateMaxByteLength } from "@core/utils/string.ts";
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
longName: string;
|
longName: string;
|
||||||
@@ -24,32 +25,50 @@ export interface DeviceNameDialogProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
const MAX_LONG_NAME_BYTE_LENGTH = 40;
|
||||||
|
const MAX_SHORT_NAME_BYTE_LENGTH = 4;
|
||||||
|
|
||||||
export const DeviceNameDialog = ({
|
export const DeviceNameDialog = ({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: DeviceNameDialogProps) => {
|
}: DeviceNameDialogProps) => {
|
||||||
const { hardware, nodes, connection } = useDevice();
|
const { hardware, getNode, connection } = useDevice();
|
||||||
|
const myNode = getNode(hardware.myNodeNum);
|
||||||
|
|
||||||
const myNode = nodes.get(hardware.myNodeNum);
|
const defaultValues = {
|
||||||
|
|
||||||
const { register, handleSubmit } = useForm<User>({
|
|
||||||
values: {
|
|
||||||
longName: myNode?.user?.longName ?? "Unknown",
|
longName: myNode?.user?.longName ?? "Unknown",
|
||||||
shortName: myNode?.user?.shortName ?? "Unknown",
|
shortName: myNode?.user?.shortName ?? "??",
|
||||||
},
|
};
|
||||||
|
|
||||||
|
const { getValues, setValue, reset, control, handleSubmit } = useForm<User>({
|
||||||
|
values: defaultValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { currentLength: currentLongNameLength } = validateMaxByteLength(
|
||||||
|
getValues("longName"),
|
||||||
|
MAX_LONG_NAME_BYTE_LENGTH,
|
||||||
|
);
|
||||||
|
const { currentLength: currentShortNameLength } = validateMaxByteLength(
|
||||||
|
getValues("shortName"),
|
||||||
|
MAX_SHORT_NAME_BYTE_LENGTH,
|
||||||
|
);
|
||||||
|
|
||||||
const onSubmit = handleSubmit((data) => {
|
const onSubmit = handleSubmit((data) => {
|
||||||
connection?.setOwner(
|
connection?.setOwner(
|
||||||
create(Protobuf.Mesh.UserSchema, {
|
create(Protobuf.Mesh.UserSchema, {
|
||||||
...myNode?.user,
|
...(myNode?.user ?? {}),
|
||||||
...data,
|
...data,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
reset({ longName: "", shortName: "" });
|
||||||
|
setValue("longName", "");
|
||||||
|
setValue("shortName", "");
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
@@ -60,21 +79,52 @@ export const DeviceNameDialog = ({
|
|||||||
The Device will restart once the config is saved.
|
The Device will restart once the config is saved.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="gap-4">
|
<form onSubmit={onSubmit} className="flex flex-col gap-4">
|
||||||
<form onSubmit={onSubmit}>
|
<div>
|
||||||
<Label>Long Name</Label>
|
<Label htmlFor="longName">Long Name</Label>
|
||||||
<Input className="dark:text-slte-900" {...register("longName")} />
|
<GenericInput
|
||||||
<Label>Short Name</Label>
|
control={control}
|
||||||
<Input
|
field={{
|
||||||
className="dark:text-slte-900"
|
name: "longName",
|
||||||
maxLength={4}
|
label: "Long Name",
|
||||||
{...register("shortName")}
|
type: "text",
|
||||||
|
properties: {
|
||||||
|
className: "text-slate-900 dark:text-slate-200",
|
||||||
|
fieldLength: {
|
||||||
|
currentValueLength: currentLongNameLength ?? 0,
|
||||||
|
max: MAX_LONG_NAME_BYTE_LENGTH,
|
||||||
|
showCharacterCount: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="shortName">Short Name</Label>
|
||||||
|
<GenericInput
|
||||||
|
control={control}
|
||||||
|
field={{
|
||||||
|
name: "shortName",
|
||||||
|
label: "Short Name",
|
||||||
|
type: "text",
|
||||||
|
properties: {
|
||||||
|
fieldLength: {
|
||||||
|
currentValueLength: currentShortNameLength ?? 0,
|
||||||
|
max: MAX_SHORT_NAME_BYTE_LENGTH,
|
||||||
|
showCharacterCount: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button onClick={() => onSubmit()}>Save</Button>
|
<Button type="button" variant="destructive" onClick={handleReset}>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">Save</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDeta
|
|||||||
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
|
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
|
||||||
import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx";
|
import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx";
|
||||||
import { RebootOTADialog } from "@components/Dialog/RebootOTADialog.tsx";
|
import { RebootOTADialog } from "@components/Dialog/RebootOTADialog.tsx";
|
||||||
|
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
|
||||||
|
|
||||||
export const DialogManager = () => {
|
export const DialogManager = () => {
|
||||||
const { channels, config, dialog, setDialogOpen } = useDevice();
|
const { channels, config, dialog, setDialogOpen } = useDevice();
|
||||||
@@ -84,6 +85,12 @@ export const DialogManager = () => {
|
|||||||
setDialogOpen("rebootOTA", open);
|
setDialogOpen("rebootOTA", open);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<DeleteMessagesDialog
|
||||||
|
open={dialog.deleteMessages}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setDialogOpen("deleteMessages", open);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -109,7 +109,6 @@ export const ImportDialog = ({
|
|||||||
<Input
|
<Input
|
||||||
value={importDialogInput}
|
value={importDialogInput}
|
||||||
suffix={validUrl ? "✅" : "❌"}
|
suffix={validUrl ? "✅" : "❌"}
|
||||||
className="dark:text-slate-900"
|
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setImportDialogInput(e.target.value);
|
setImportDialogInput(e.target.value);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ export const LocationResponseDialog = ({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: LocationResponseDialogProps) => {
|
}: LocationResponseDialogProps) => {
|
||||||
const { nodes } = useDevice();
|
const { getNode } = useDevice();
|
||||||
|
|
||||||
const from = nodes.get(location?.from ?? 0);
|
const from = getNode(location?.from ?? 0);
|
||||||
const longName = from?.user?.longName ??
|
const longName = from?.user?.longName ??
|
||||||
(from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown");
|
(from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown");
|
||||||
const shortName = from?.user?.shortName ??
|
const shortName = from?.user?.shortName ??
|
||||||
@@ -43,7 +43,8 @@ export const LocationResponseDialog = ({
|
|||||||
Coordinates:{" "}
|
Coordinates:{" "}
|
||||||
<a
|
<a
|
||||||
className="text-blue-500 dark:text-blue-400"
|
className="text-blue-500 dark:text-blue-400"
|
||||||
href={`https://www.openstreetmap.org/?mlat=${location?.data.latitudeI / 1e7
|
href={`https://www.openstreetmap.org/?mlat=${
|
||||||
|
location?.data.latitudeI / 1e7
|
||||||
}&mlon=${location?.data.longitudeI / 1e7}&layers=N`}
|
}&mlon=${location?.data.longitudeI / 1e7}&layers=N`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
|
|||||||
@@ -87,8 +87,8 @@ const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
|
|||||||
{browserFeatures.length > 0 && (
|
{browserFeatures.length > 0 && (
|
||||||
<>
|
<>
|
||||||
This connection type requires{" "}
|
This connection type requires{" "}
|
||||||
{formatFeatureList(browserFeatures)}. Please use a
|
{formatFeatureList(browserFeatures)}. Please use a supported
|
||||||
supported browser, like Chrome or Edge.
|
browser, like Chrome or Edge.
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{needsSecureContext && (
|
{needsSecureContext && (
|
||||||
@@ -135,7 +135,7 @@ export const NewDeviceDialog = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent aria-describedby={undefined}>
|
||||||
<DialogClose />
|
<DialogClose />
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Connect New Device</DialogTitle>
|
<DialogTitle>Connect New Device</DialogTitle>
|
||||||
@@ -151,10 +151,12 @@ export const NewDeviceDialog = ({
|
|||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<TabsContent key={tab.label} value={tab.label}>
|
<TabsContent key={tab.label} value={tab.label}>
|
||||||
<fieldset disabled={tab.isDisabled}>
|
<fieldset disabled={tab.isDisabled}>
|
||||||
{tab.isDisabled
|
{(tab.label !== "HTTP" && tab.isDisabled)
|
||||||
? <ErrorMessage missingFeatures={unsupported} />
|
? <ErrorMessage missingFeatures={unsupported} />
|
||||||
: null}
|
: null}
|
||||||
<tab.element closeDialog={() => onOpenChange(false)} />
|
<tab.element
|
||||||
|
closeDialog={() => onOpenChange(false)}
|
||||||
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
import { describe, it, vi, expect, beforeEach, Mock } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
|
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
|
||||||
import { useAppStore } from "@core/stores/appStore.ts";
|
import { useAppStore } from "@core/stores/appStore.ts";
|
||||||
|
import { Protobuf } from "@meshtastic/core";
|
||||||
|
|
||||||
vi.mock("@core/stores/deviceStore");
|
vi.mock("@core/stores/deviceStore", () => {
|
||||||
|
return {
|
||||||
|
useDevice: () => ({
|
||||||
|
setDialogOpen: vi.fn(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
vi.mock("@core/stores/appStore");
|
vi.mock("@core/stores/appStore");
|
||||||
|
|
||||||
|
const mockUseAppStore = vi.mocked(useAppStore);
|
||||||
|
|
||||||
describe("NodeDetailsDialog", () => {
|
describe("NodeDetailsDialog", () => {
|
||||||
const mockDevice = {
|
const mockNode = {
|
||||||
num: 1234,
|
num: 1234,
|
||||||
user: {
|
user: {
|
||||||
longName: "Test Node",
|
longName: "Test Node",
|
||||||
@@ -29,45 +37,96 @@ describe("NodeDetailsDialog", () => {
|
|||||||
voltage: 4.2,
|
voltage: 4.2,
|
||||||
uptimeSeconds: 3600,
|
uptimeSeconds: 3600,
|
||||||
},
|
},
|
||||||
};
|
} as unknown as Protobuf.Mesh.NodeInfo;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Reset mocks before each test
|
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
|
|
||||||
(useDevice as Mock).mockReturnValue({
|
mockUseAppStore.mockReturnValue({
|
||||||
nodes: new Map([[1234, mockDevice]]),
|
|
||||||
});
|
|
||||||
|
|
||||||
(useAppStore as unknown as Mock).mockReturnValue({
|
|
||||||
nodeNumDetails: 1234,
|
nodeNumDetails: 1234,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders node details correctly", () => {
|
it("renders node details correctly", () => {
|
||||||
render(<NodeDetailsDialog open={true} onOpenChange={() => { }} />);
|
render(<NodeDetailsDialog open node={mockNode} onOpenChange={() => {}} />);
|
||||||
|
|
||||||
expect(screen.getByText(/Node Details for Test Node/i)).toBeInTheDocument();
|
expect(screen.getByText(/Node Details for Test Node \(TN\)/i))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
|
||||||
expect(screen.getByText("Node Number: 1234")).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(/Air TX utilization: 50.12%/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Channel utilization: 75.46%/i)).toBeInTheDocument();
|
expect(screen.getByText(/Channel utilization: 75.46%/i))
|
||||||
|
.toBeInTheDocument();
|
||||||
expect(screen.getByText(/Battery level: 88.79%/i)).toBeInTheDocument();
|
expect(screen.getByText(/Battery level: 88.79%/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Voltage: 4.20V/i)).toBeInTheDocument();
|
expect(screen.getByText(/Voltage: 4.20V/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Uptime:/i)).toBeInTheDocument();
|
expect(screen.getByText(/Uptime:/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Coordinates:/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("45, -75")).toBeInTheDocument();
|
expect(screen.getByText(/All Raw Metrics:/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Altitude: 200m/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Role:/i)).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders null if device is not found", () => {
|
it("renders null if node is undefined", () => {
|
||||||
(useDevice as Mock).mockReturnValue({
|
const mockNode = undefined;
|
||||||
nodes: new Map(),
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<NodeDetailsDialog open={true} onOpenChange={() => { }} />);
|
const { container } = render(
|
||||||
|
<NodeDetailsDialog open node={mockNode} onOpenChange={() => {}} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
expect(screen.queryByText(/Node Details for/i)).not.toBeInTheDocument();
|
expect(screen.queryByText(/Node Details for/i)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders correctly when position is missing", () => {
|
||||||
|
const nodeWithoutPosition = { ...mockNode, position: undefined };
|
||||||
|
|
||||||
|
render(
|
||||||
|
<NodeDetailsDialog
|
||||||
|
open
|
||||||
|
node={nodeWithoutPosition}
|
||||||
|
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 };
|
||||||
|
|
||||||
|
render(
|
||||||
|
<NodeDetailsDialog
|
||||||
|
open
|
||||||
|
node={nodeWithoutMetrics}
|
||||||
|
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 };
|
||||||
|
|
||||||
|
render(
|
||||||
|
<NodeDetailsDialog open node={nodeNeverHeard} onOpenChange={() => {}} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Last Heard: Never/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { useAppStore } from "@core/stores/appStore.ts";
|
import { useAppStore } from "@core/stores/appStore.ts";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
|
import {
|
||||||
|
MessageType,
|
||||||
|
useMessageStore,
|
||||||
|
} from "@core/stores/messageStore/index.ts";
|
||||||
|
import { Protobuf } from "@meshtastic/core";
|
||||||
|
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||||
|
import { DeviceImage } from "@components/generic/DeviceImage.tsx";
|
||||||
|
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
|
||||||
|
import { Uptime } from "@components/generic/Uptime.tsx";
|
||||||
|
import { toast } from "@core/hooks/useToast.ts";
|
||||||
|
import { useFavoriteNode } from "../../../core/hooks/useFavoriteNode.ts";
|
||||||
|
import { useIgnoreNode } from "../../../core/hooks/useIgnoreNode.ts";
|
||||||
|
import { cn } from "@core/utils/cn.ts";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionContent,
|
AccordionContent,
|
||||||
@@ -14,124 +30,282 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@components/UI/Dialog.tsx";
|
} from "@components/UI/Dialog.tsx";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Button } from "@components/UI/Button.tsx";
|
||||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
import {
|
||||||
import { DeviceImage } from "@components/generic/DeviceImage.tsx";
|
BellIcon,
|
||||||
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
|
BellOffIcon,
|
||||||
import { Uptime } from "@components/generic/Uptime.tsx";
|
MapPinnedIcon,
|
||||||
|
MessageSquareIcon,
|
||||||
|
StarIcon,
|
||||||
|
TrashIcon,
|
||||||
|
WaypointsIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipArrow,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@components/UI/Tooltip.tsx";
|
||||||
|
import { Separator } from "@components/UI/Seperator.tsx";
|
||||||
|
|
||||||
export interface NodeDetailsDialogProps {
|
export interface NodeDetailsDialogProps {
|
||||||
|
node: Protobuf.Mesh.NodeInfo | undefined;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NodeDetailsDialog = ({
|
export const NodeDetailsDialog = ({
|
||||||
|
node,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: NodeDetailsDialogProps) => {
|
}: NodeDetailsDialogProps) => {
|
||||||
const { nodes } = useDevice();
|
const { setDialogOpen, connection, setActivePage } = useDevice();
|
||||||
const { nodeNumDetails } = useAppStore();
|
const { setNodeNumToBeRemoved } = useAppStore();
|
||||||
|
const { setChatType, setActiveChat } = useMessageStore();
|
||||||
|
|
||||||
const device = nodes.get(nodeNumDetails);
|
const { updateFavorite } = useFavoriteNode();
|
||||||
|
const [isFavoriteState, setIsFavoriteState] = useState<boolean>(false);
|
||||||
|
|
||||||
if (!device) return null;
|
const { updateIgnored } = useIgnoreNode();
|
||||||
|
const [isIgnoredState, setIsIgnoredState] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!node) return;
|
||||||
|
setIsFavoriteState(node?.isFavorite);
|
||||||
|
setIsIgnoredState(node?.isIgnored);
|
||||||
|
}, [node]);
|
||||||
|
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
function handleDirectMessage() {
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
setChatType(MessageType.Direct);
|
||||||
|
setActiveChat(node.num);
|
||||||
|
setActivePage("messages");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRequestPosition() {
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Requesting position, please wait...",
|
||||||
|
});
|
||||||
|
connection?.requestPosition(node.num).then(() =>
|
||||||
|
toast({
|
||||||
|
title: "Position request sent.",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTraceroute() {
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Sending Traceroute, please wait...",
|
||||||
|
});
|
||||||
|
connection?.traceRoute(node.num).then(() =>
|
||||||
|
toast({
|
||||||
|
title: "Traceroute sent.",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNodeRemove() {
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
setNodeNumToBeRemoved(node?.num);
|
||||||
|
setDialogOpen("nodeRemoval", true);
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleFavorite() {
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
updateFavorite({ nodeNum: node.num, isFavorite: !isFavoriteState });
|
||||||
|
setIsFavoriteState(!isFavoriteState);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleIgnored() {
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
updateIgnored({ nodeNum: node.num, isIgnored: !isIgnoredState });
|
||||||
|
setIsIgnoredState(!isIgnoredState);
|
||||||
|
}
|
||||||
|
|
||||||
const deviceMetricsMap = [
|
const deviceMetricsMap = [
|
||||||
{
|
{
|
||||||
key: "airUtilTx",
|
key: "airUtilTx",
|
||||||
label: "Air TX utilization",
|
label: "Air TX utilization",
|
||||||
value: device.deviceMetrics?.airUtilTx,
|
value: node.deviceMetrics?.airUtilTx,
|
||||||
format: (val: number) => `${val.toFixed(2)}%`,
|
format: (val: number) => `${val.toFixed(2)}%`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "channelUtilization",
|
key: "channelUtilization",
|
||||||
label: "Channel utilization",
|
label: "Channel utilization",
|
||||||
value: device.deviceMetrics?.channelUtilization,
|
value: node.deviceMetrics?.channelUtilization,
|
||||||
format: (val: number) => `${val.toFixed(2)}%`,
|
format: (val: number) => `${val.toFixed(2)}%`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "batteryLevel",
|
key: "batteryLevel",
|
||||||
label: "Battery level",
|
label: "Battery level",
|
||||||
value: device.deviceMetrics?.batteryLevel,
|
value: node.deviceMetrics?.batteryLevel,
|
||||||
format: (val: number) => `${val.toFixed(2)}%`,
|
format: (val: number) => `${val.toFixed(2)}%`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "voltage",
|
key: "voltage",
|
||||||
label: "Voltage",
|
label: "Voltage",
|
||||||
value: device.deviceMetrics?.voltage,
|
value: node.deviceMetrics?.voltage,
|
||||||
format: (val: number) => `${val.toFixed(2)}V`,
|
format: (val: number) => `${val.toFixed(2)}V`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent >
|
<DialogContent aria-describedby={undefined}>
|
||||||
<DialogClose />
|
<DialogClose />
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
Node Details for {device.user?.longName ?? "UNKNOWN"} (
|
Node Details for {node.user?.longName ?? "UNKNOWN"} (
|
||||||
{device.user?.shortName ?? "UNK"})
|
{node.user?.shortName ?? "UNK"})
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-row flex-wrap space-y-1">
|
||||||
<DeviceImage
|
<Button className="mr-1" onClick={handleDirectMessage}>
|
||||||
className="w-32 h-32 mx-auto rounded-lg border-4 border-slate-200 dark:border-slate-800"
|
<MessageSquareIcon className="mr-2" />
|
||||||
deviceType={
|
Message
|
||||||
Protobuf.Mesh.HardwareModel[device.user?.hwModel ?? 0]
|
</Button>
|
||||||
}
|
<Button className="mr-1" onClick={handleTraceroute}>
|
||||||
|
<WaypointsIcon className="mr-2" />
|
||||||
|
Trace Route
|
||||||
|
</Button>
|
||||||
|
<Button className="mr-1" onClick={handleToggleFavorite}>
|
||||||
|
<StarIcon
|
||||||
|
className={cn(
|
||||||
|
isFavoriteState ? " fill-yellow-400 stroke-yellow-400" : "",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="bg-slate-100 text-slate-900 dark:text-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
|
</Button>
|
||||||
<p className="text-lg font-semibold">Details:</p>
|
<div className="flex flex-1 justify-start"></div>
|
||||||
<p>
|
|
||||||
Hardware:{" "}
|
<TooltipProvider delayDuration={300}>
|
||||||
{Protobuf.Mesh.HardwareModel[device.user?.hwModel ?? 0]}
|
<Tooltip>
|
||||||
</p>
|
<TooltipTrigger asChild>
|
||||||
<p>Node Number: {device.num}</p>
|
<Button
|
||||||
<p>Node Hex: !{numberToHexUnpadded(device.num)}</p>
|
className={cn(
|
||||||
<p>
|
"flex justify-end mr-1 text-white",
|
||||||
Role:{" "}
|
isIgnoredState
|
||||||
{
|
? "bg-red-500 dark:bg-red-500 hover:bg-red-600 hover:dark:bg-red-600 text-white dark:text-white"
|
||||||
Protobuf.Config.Config_DeviceConfig_Role[
|
: "",
|
||||||
device.user?.role ?? 0
|
)}
|
||||||
]
|
onClick={handleToggleIgnored}
|
||||||
}
|
>
|
||||||
</p>
|
{isIgnoredState ? <BellIcon /> : <BellOffIcon />}
|
||||||
<p>
|
</Button>
|
||||||
Last Heard:{" "}
|
</TooltipTrigger>
|
||||||
{device.lastHeard === 0 ? "Never" : <TimeAgo timestamp={device.lastHeard * 1000} />}
|
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
|
||||||
</p>
|
{isIgnoredState ? "Unignore node" : "Ignore node"}
|
||||||
|
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<TooltipProvider delayDuration={300}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
className="flex justify-end"
|
||||||
|
onClick={handleNodeRemove}
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
|
||||||
|
Remove node
|
||||||
|
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{device.position && (
|
<Separator className="mt-5 mb-2" />
|
||||||
|
|
||||||
|
<div className="flex flex-col flex-wrap space-x-1 space-y-1">
|
||||||
|
<div className="flex flex-row space-x-2">
|
||||||
|
<div className="w-full bg-slate-100 text-slate-900 dark:text-slate-100 dark:bg-slate-800 p-3 rounded-lg">
|
||||||
|
<p className="text-lg font-semibold">Details:</p>
|
||||||
|
<p>Node Number: {node.num}</p>
|
||||||
|
<p>Node Hex: !{numberToHexUnpadded(node.num)}</p>
|
||||||
|
<p>
|
||||||
|
Role: {Protobuf.Config.Config_DeviceConfig_Role[
|
||||||
|
node.user?.role ?? 0
|
||||||
|
].replace(/_/g, " ")}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Last Heard: {node.lastHeard === 0
|
||||||
|
? "Never"
|
||||||
|
: <TimeAgo timestamp={node.lastHeard * 1000} />}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Hardware:{" "}
|
||||||
|
{(Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0] ??
|
||||||
|
"Unknown")
|
||||||
|
.replace(/_/g, " ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DeviceImage
|
||||||
|
className="h-45 w-45 p-2 rounded-lg border-4 border-slate-200 dark:border-slate-800"
|
||||||
|
deviceType={Protobuf.Mesh
|
||||||
|
.HardwareModel[node.user?.hwModel ?? 0]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
<div className="text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
|
<div className="text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
|
||||||
<p className="text-lg font-semibold">Position:</p>
|
<p className="text-lg font-semibold">Position:</p>
|
||||||
{device.position.latitudeI && device.position.longitudeI && (
|
|
||||||
|
{node.position
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
{node.position.latitudeI &&
|
||||||
|
node.position.longitudeI && (
|
||||||
<p>
|
<p>
|
||||||
Coordinates:{" "}
|
Coordinates:{" "}
|
||||||
<a
|
<a
|
||||||
className="text-blue-500 dark:text-blue-400"
|
className="text-blue-500 dark:text-blue-400"
|
||||||
href={`https://www.openstreetmap.org/?mlat=${device.position.latitudeI / 1e7
|
href={`https://www.openstreetmap.org/?mlat=${
|
||||||
}&mlon=${device.position.longitudeI / 1e7
|
node.position.latitudeI / 1e7
|
||||||
}&layers=N`}
|
}&mlon=${node.position.longitudeI / 1e7}&layers=N`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
{device.position.latitudeI / 1e7},{" "}
|
{node.position.latitudeI / 1e7},{" "}
|
||||||
{device.position.longitudeI / 1e7}
|
{node.position.longitudeI / 1e7}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{device.position.altitude && (
|
{node.position.altitude && (
|
||||||
<p>Altitude: {device.position.altitude}m</p>
|
<p>Altitude: {node.position.altitude}m</p>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: <p>Unknown</p>}
|
||||||
|
<Button onClick={handleRequestPosition} className="mt-2">
|
||||||
|
<MapPinnedIcon className="mr-2" />
|
||||||
|
Request Position
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{device.deviceMetrics && (
|
{node.deviceMetrics && (
|
||||||
<div className="text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
|
<div className="text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
|
||||||
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
|
<p className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||||
Device Metrics:
|
Device Metrics:
|
||||||
</p>
|
</p>
|
||||||
{deviceMetricsMap.map(
|
{deviceMetricsMap.map(
|
||||||
@@ -140,17 +314,16 @@ export const NodeDetailsDialog = ({
|
|||||||
<p key={metric.key}>
|
<p key={metric.key}>
|
||||||
{metric.label}: {metric.format(metric.value)}
|
{metric.label}: {metric.format(metric.value)}
|
||||||
</p>
|
</p>
|
||||||
)
|
),
|
||||||
)}
|
)}
|
||||||
{device.deviceMetrics.uptimeSeconds && (
|
{node.deviceMetrics.uptimeSeconds && (
|
||||||
<p>
|
<p>
|
||||||
Uptime:{" "}
|
Uptime:{" "}
|
||||||
<Uptime seconds={device.deviceMetrics.uptimeSeconds} />
|
<Uptime seconds={node.deviceMetrics.uptimeSeconds} />
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-slate-900 dark:text-slate-100 w-full max-w-[464px] bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
|
<div className="text-slate-900 dark:text-slate-100 w-full max-w-[464px] bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
|
||||||
@@ -163,7 +336,7 @@ export const NodeDetailsDialog = ({
|
|||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="overflow-x-scroll">
|
<AccordionContent className="overflow-x-scroll">
|
||||||
<pre className="text-xs w-full">
|
<pre className="text-xs w-full">
|
||||||
{JSON.stringify(device, null, 2)}
|
{JSON.stringify(node, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
import { toast } from "../../core/hooks/useToast.ts";
|
|
||||||
import { useAppStore } from "../../core/stores/appStore.ts";
|
|
||||||
import { useDevice } from "../../core/stores/deviceStore.ts";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "../UI/Dialog.tsx";
|
|
||||||
import type { Protobuf } from "@meshtastic/core";
|
|
||||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
|
||||||
import { TrashIcon } from "lucide-react";
|
|
||||||
|
|
||||||
import { Button } from "../UI/Button.tsx";
|
|
||||||
|
|
||||||
export interface NodeOptionsDialogProps {
|
|
||||||
node: Protobuf.Mesh.NodeInfo | undefined;
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NodeOptionsDialog = ({
|
|
||||||
node,
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
}: NodeOptionsDialogProps) => {
|
|
||||||
const { setDialogOpen, connection, setActivePage } = useDevice();
|
|
||||||
const {
|
|
||||||
setNodeNumToBeRemoved,
|
|
||||||
setNodeNumDetails,
|
|
||||||
setChatType,
|
|
||||||
setActiveChat,
|
|
||||||
} = useAppStore();
|
|
||||||
const longName = node?.user?.longName ??
|
|
||||||
(node ? `!${numberToHexUnpadded(node?.num)}` : "Unknown");
|
|
||||||
const shortName = node?.user?.shortName ??
|
|
||||||
(node ? `${numberToHexUnpadded(node?.num).substring(0, 4)}` : "UNK");
|
|
||||||
|
|
||||||
function handleDirectMessage() {
|
|
||||||
if (!node) return;
|
|
||||||
setChatType("direct");
|
|
||||||
setActiveChat(node.num);
|
|
||||||
setActivePage("messages");
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRequestPosition() {
|
|
||||||
if (!node) return;
|
|
||||||
toast({
|
|
||||||
title: "Requesting position, please wait...",
|
|
||||||
});
|
|
||||||
connection?.requestPosition(node.num).then(() =>
|
|
||||||
toast({
|
|
||||||
title: "Position request sent.",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
onOpenChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTraceroute() {
|
|
||||||
if (!node) return;
|
|
||||||
toast({
|
|
||||||
title: "Sending Traceroute, please wait...",
|
|
||||||
});
|
|
||||||
connection?.traceRoute(node.num).then(() =>
|
|
||||||
toast({
|
|
||||||
title: "Traceroute sent.",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
onOpenChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogClose />
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{`${longName} (${shortName})`}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="flex flex-col space-y-1">
|
|
||||||
<div>
|
|
||||||
<Button onClick={handleDirectMessage}>Direct Message</Button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Button onClick={handleRequestPosition}>Request Position</Button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Button onClick={handleTraceroute}>Trace Route</Button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
key="remove"
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => {
|
|
||||||
setNodeNumToBeRemoved(node.num);
|
|
||||||
setDialogOpen("nodeRemoval", true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TrashIcon />
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setNodeNumDetails(node.num);
|
|
||||||
setDialogOpen("nodeDetails", true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
More Details
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -10,12 +10,22 @@ import {
|
|||||||
} from "@components/UI/Dialog.tsx";
|
} from "@components/UI/Dialog.tsx";
|
||||||
|
|
||||||
export interface PkiRegenerateDialogProps {
|
export interface PkiRegenerateDialogProps {
|
||||||
|
text: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
button: string;
|
||||||
|
};
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: () => void;
|
onOpenChange: () => void;
|
||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PkiRegenerateDialog = ({
|
export const PkiRegenerateDialog = ({
|
||||||
|
text = {
|
||||||
|
title: "Regenerate Key Pair",
|
||||||
|
description: "Are you sure you want to regenerate key pair?",
|
||||||
|
button: "Regenerate",
|
||||||
|
},
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
@@ -25,14 +35,14 @@ export const PkiRegenerateDialog = ({
|
|||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogClose />
|
<DialogClose />
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Regenerate Key pair?</DialogTitle>
|
<DialogTitle>{text?.title}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Are you sure you want to regenerate key pair?
|
{text?.description}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="destructive" onClick={() => onSubmit()}>
|
<Button variant="destructive" onClick={() => onSubmit()}>
|
||||||
Regenerate
|
{text?.button}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -108,7 +108,8 @@ export const QRDialog = ({
|
|||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`border-slate-900 border-t border-l border-b rounded-l h-10 px-7 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${qrCodeAdd
|
className={`border-slate-900 border-t border-l border-b rounded-l h-10 px-7 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${
|
||||||
|
qrCodeAdd
|
||||||
? "focus:ring-green-800 bg-green-800 text-white"
|
? "focus:ring-green-800 bg-green-800 text-white"
|
||||||
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
|
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
|
||||||
}`}
|
}`}
|
||||||
@@ -118,7 +119,8 @@ export const QRDialog = ({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`border-slate-900 border-t border-r border-b rounded-r h-10 px-4 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${!qrCodeAdd
|
className={`border-slate-900 border-t border-r border-b rounded-r h-10 px-4 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${
|
||||||
|
!qrCodeAdd
|
||||||
? "focus:ring-green-800 bg-green-800 text-white"
|
? "focus:ring-green-800 bg-green-800 text-white"
|
||||||
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
|
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
|
||||||
}`}
|
}`}
|
||||||
@@ -133,8 +135,8 @@ export const QRDialog = ({
|
|||||||
<Input
|
<Input
|
||||||
value={qrCodeUrl}
|
value={qrCodeUrl}
|
||||||
disabled
|
disabled
|
||||||
className="dark:text-slate-900"
|
|
||||||
action={{
|
action={{
|
||||||
|
key: "copy-value",
|
||||||
icon: ClipboardIcon,
|
icon: ClipboardIcon,
|
||||||
onClick() {
|
onClick() {
|
||||||
void navigator.clipboard.writeText(qrCodeUrl);
|
void navigator.clipboard.writeText(qrCodeUrl);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { RebootOTADialog } from './RebootOTADialog.tsx';
|
import { RebootOTADialog } from "./RebootOTADialog.tsx";
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
const rebootOtaMock = vi.fn();
|
const rebootOtaMock = vi.fn();
|
||||||
@@ -8,41 +8,46 @@ let mockConnection: { rebootOta: (delay: number) => void } | undefined = {
|
|||||||
rebootOta: rebootOtaMock,
|
rebootOta: rebootOtaMock,
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock('@core/stores/deviceStore.ts', () => ({
|
vi.mock("@core/stores/deviceStore.ts", () => ({
|
||||||
useDevice: () => ({
|
useDevice: () => ({
|
||||||
connection: mockConnection,
|
connection: mockConnection,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@components/UI/Button.tsx', async () => {
|
vi.mock("@components/UI/Button.tsx", async () => {
|
||||||
const actual = await vi.importActual('@components/UI/Button.tsx');
|
const actual = await vi.importActual("@components/UI/Button.tsx");
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
Button: (props: any) => <button {...props} />,
|
Button: (props) => <button {...props} />,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('@components/UI/Input.tsx', async () => {
|
vi.mock("@components/UI/Input.tsx", async () => {
|
||||||
const actual = await vi.importActual('@components/UI/Input.tsx');
|
const actual = await vi.importActual("@components/UI/Input.tsx");
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
Input: (props: any) => <input {...props} />,
|
Input: (props) => <input {...props} />,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('@components/UI/Dialog.tsx', () => {
|
vi.mock("@components/UI/Dialog.tsx", () => {
|
||||||
return {
|
return {
|
||||||
Dialog: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
Dialog: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
DialogContent: ({ children }: { children: ReactNode }) => (
|
||||||
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
<div>{children}</div>
|
||||||
|
),
|
||||||
|
DialogHeader: ({ children }: { children: ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
),
|
||||||
DialogTitle: ({ children }: { children: ReactNode }) => <h1>{children}</h1>,
|
DialogTitle: ({ children }: { children: ReactNode }) => <h1>{children}</h1>,
|
||||||
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
|
DialogDescription: ({ children }: { children: ReactNode }) => (
|
||||||
|
<p>{children}</p>
|
||||||
|
),
|
||||||
DialogClose: () => null,
|
DialogClose: () => null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("RebootOTADialog", () => {
|
||||||
describe('RebootOTADialog', () => {
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
rebootOtaMock.mockClear();
|
rebootOtaMock.mockClear();
|
||||||
@@ -52,19 +57,19 @@ describe('RebootOTADialog', () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders dialog with default input value', () => {
|
it("renders dialog with default input value", () => {
|
||||||
render(<RebootOTADialog open={true} onOpenChange={() => { }} />);
|
render(<RebootOTADialog open onOpenChange={() => {}} />);
|
||||||
expect(screen.getByPlaceholderText(/enter delay/i)).toHaveValue(5);
|
expect(screen.getByPlaceholderText(/enter delay/i)).toHaveValue(5);
|
||||||
expect(screen.getByText(/schedule reboot/i)).toBeInTheDocument();
|
expect(screen.getByText(/schedule reboot/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/reboot to ota mode now/i)).toBeInTheDocument();
|
expect(screen.getByText(/reboot to ota mode now/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('schedules a reboot with delay and calls rebootOta', async () => {
|
it("schedules a reboot with delay and calls rebootOta", async () => {
|
||||||
const onOpenChangeMock = vi.fn();
|
const onOpenChangeMock = vi.fn();
|
||||||
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />);
|
render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />);
|
||||||
|
|
||||||
fireEvent.change(screen.getByPlaceholderText(/enter delay/i), {
|
fireEvent.change(screen.getByPlaceholderText(/enter delay/i), {
|
||||||
target: { value: '3' },
|
target: { value: "3" },
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByText(/schedule reboot/i));
|
fireEvent.click(screen.getByText(/schedule reboot/i));
|
||||||
@@ -79,9 +84,9 @@ describe('RebootOTADialog', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('triggers an instant reboot', async () => {
|
it("triggers an instant reboot", async () => {
|
||||||
const onOpenChangeMock = vi.fn();
|
const onOpenChangeMock = vi.fn();
|
||||||
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />);
|
render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByText(/reboot to ota mode now/i));
|
fireEvent.click(screen.getByText(/reboot to ota mode now/i));
|
||||||
|
|
||||||
@@ -91,13 +96,13 @@ describe('RebootOTADialog', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not call reboot if connection is undefined', async () => {
|
it("does not call reboot if connection is undefined", async () => {
|
||||||
const onOpenChangeMock = vi.fn();
|
const onOpenChangeMock = vi.fn();
|
||||||
|
|
||||||
// simulate no connection
|
// simulate no connection
|
||||||
mockConnection = undefined;
|
mockConnection = undefined;
|
||||||
|
|
||||||
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />);
|
render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByText(/schedule reboot/i));
|
fireEvent.click(screen.getByText(/schedule reboot/i));
|
||||||
vi.advanceTimersByTime(5000);
|
vi.advanceTimersByTime(5000);
|
||||||
@@ -110,5 +115,4 @@ describe('RebootOTADialog', () => {
|
|||||||
// reset connection for other tests
|
// reset connection for other tests
|
||||||
mockConnection = { rebootOta: rebootOtaMock };
|
mockConnection = { rebootOta: rebootOtaMock };
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ export interface RebootOTADialogProps {
|
|||||||
|
|
||||||
const DEFAULT_REBOOT_DELAY = 5; // seconds
|
const DEFAULT_REBOOT_DELAY = 5; // seconds
|
||||||
|
|
||||||
export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) => {
|
export const RebootOTADialog = (
|
||||||
|
{ open, onOpenChange }: RebootOTADialogProps,
|
||||||
|
) => {
|
||||||
const { connection } = useDevice();
|
const { connection } = useDevice();
|
||||||
const [time, setTime] = useState<number>(DEFAULT_REBOOT_DELAY);
|
const [time, setTime] = useState<number>(DEFAULT_REBOOT_DELAY);
|
||||||
const [isScheduled, setIsScheduled] = useState(false);
|
const [isScheduled, setIsScheduled] = useState(false);
|
||||||
@@ -28,8 +30,8 @@ export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) =>
|
|||||||
const handleSetTime = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleSetTime = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!e.target.validity.valid) {
|
if (!e.target.validity.valid) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return
|
return;
|
||||||
};
|
}
|
||||||
|
|
||||||
const val = e.target.value;
|
const val = e.target.value;
|
||||||
setInputValue(val);
|
setInputValue(val);
|
||||||
@@ -73,7 +75,8 @@ export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) =>
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Reboot to OTA Mode</DialogTitle>
|
<DialogTitle>Reboot to OTA Mode</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Reboot the connected node after a delay into OTA (Over-the-Air) mode.
|
Reboot the connected node after a delay into OTA (Over-the-Air)
|
||||||
|
mode.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -89,7 +92,7 @@ export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) =>
|
|||||||
/>
|
/>
|
||||||
<Button onClick={() => handleRebootWithTimeout()} className="w-9/12">
|
<Button onClick={() => handleRebootWithTimeout()} className="w-9/12">
|
||||||
<ClockIcon className="mr-2" size={18} />
|
<ClockIcon className="mr-2" size={18} />
|
||||||
{isScheduled ? 'Reboot has been scheduled' : 'Schedule Reboot'}
|
{isScheduled ? "Reboot has been scheduled" : "Schedule Reboot"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -101,4 +104,3 @@ export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) =>
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,55 +1,49 @@
|
|||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
|
import { DeviceContext, useDeviceStore } from "@core/stores/deviceStore.ts";
|
||||||
import { RefreshKeysDialog } from "./RefreshKeysDialog";
|
import { RefreshKeysDialog } from "./RefreshKeysDialog.tsx";
|
||||||
|
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
||||||
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
|
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
|
||||||
|
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
vi.mock("./useRefreshKeysDialog.ts", () => ({
|
vi.mock("@core/stores/messageStore");
|
||||||
useRefreshKeysDialog: vi.fn(),
|
vi.mock("./useRefreshKeysDialog");
|
||||||
}));
|
|
||||||
|
|
||||||
describe("RefreshKeysDialog Component", () => {
|
const mockUseMessageStore = vi.mocked(useMessageStore);
|
||||||
let handleCloseDialogMock: Mock;
|
const mockUseRefreshKeysDialog = vi.mocked(useRefreshKeysDialog);
|
||||||
let handleNodeRemoveMock: Mock;
|
|
||||||
let onOpenChangeMock: Mock;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
const getInitialState = () =>
|
||||||
handleCloseDialogMock = vi.fn();
|
useDeviceStore.getInitialState?.() ??
|
||||||
handleNodeRemoveMock = vi.fn();
|
{ devices: new Map(), remoteDevices: new Map() };
|
||||||
onOpenChangeMock = vi.fn();
|
|
||||||
|
|
||||||
(useRefreshKeysDialog as Mock).mockReturnValue({
|
beforeEach(() => {
|
||||||
handleCloseDialog: handleCloseDialogMock,
|
useDeviceStore.setState(getInitialState(), true);
|
||||||
handleNodeRemove: handleNodeRemoveMock,
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not render dialog if no error exists for active chat", () => {
|
||||||
|
const deviceId = 1;
|
||||||
|
const activeChatNum = 54321;
|
||||||
|
|
||||||
it("renders the dialog with correct content", () => {
|
useDeviceStore.getState().addDevice(deviceId);
|
||||||
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
|
|
||||||
expect(screen.getByText("Keys Mismatch")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Request New Keys")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Dismiss")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls handleNodeRemove when 'Request New Keys' button is clicked", () => {
|
const currentDeviceState = useDeviceStore.getState().getDevice(deviceId);
|
||||||
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
|
if (!currentDeviceState) throw new Error("Device not found");
|
||||||
fireEvent.click(screen.getByText("Request New Keys"));
|
|
||||||
expect(handleNodeRemoveMock).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls handleCloseDialog when 'Dismiss' button is clicked", () => {
|
mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum });
|
||||||
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
|
mockUseRefreshKeysDialog.mockReturnValue({
|
||||||
fireEvent.click(screen.getByText("Dismiss"));
|
handleCloseDialog: vi.fn(),
|
||||||
expect(handleCloseDialogMock).toHaveBeenCalled();
|
handleNodeRemove: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls onOpenChange when dialog close button is clicked", () => {
|
const { container } = render(
|
||||||
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
|
<DeviceContext.Provider value={currentDeviceState}>
|
||||||
fireEvent.click(screen.getByRole("button", { name: /close/i }));
|
<RefreshKeysDialog open onOpenChange={vi.fn()} />
|
||||||
expect(handleCloseDialogMock).toHaveBeenCalled();
|
</DeviceContext.Provider>,
|
||||||
});
|
);
|
||||||
|
|
||||||
it("does not render when open is false", () => {
|
expect(container.firstChild).toBeNull();
|
||||||
render(<RefreshKeysDialog open={false} onOpenChange={onOpenChangeMock} />);
|
|
||||||
expect(screen.queryByText("Keys Mismatch")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,27 +8,55 @@ import {
|
|||||||
import { Button } from "@components/UI/Button.tsx";
|
import { Button } from "@components/UI/Button.tsx";
|
||||||
import { LockKeyholeOpenIcon } from "lucide-react";
|
import { LockKeyholeOpenIcon } from "lucide-react";
|
||||||
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
|
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
|
||||||
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
|
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
||||||
|
|
||||||
export interface RefreshKeysDialogProps {
|
export interface RefreshKeysDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps) => {
|
export const RefreshKeysDialog = (
|
||||||
|
{ open, onOpenChange }: RefreshKeysDialogProps,
|
||||||
|
) => {
|
||||||
|
const { activeChat } = useMessageStore();
|
||||||
|
const { nodeErrors, getNode } = useDevice();
|
||||||
const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog();
|
const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog();
|
||||||
|
|
||||||
|
const nodeErrorNum = nodeErrors.get(activeChat);
|
||||||
|
|
||||||
|
if (!nodeErrorNum) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeWithError = getNode(nodeErrorNum.node);
|
||||||
|
|
||||||
|
const text = {
|
||||||
|
title: `Keys Mismatch - ${nodeWithError?.user?.longName ?? ""}`,
|
||||||
|
description: `Your node is unable to send a direct message to node: ${
|
||||||
|
nodeWithError?.user?.longName ?? ""
|
||||||
|
} (${
|
||||||
|
nodeWithError?.user?.shortName ?? ""
|
||||||
|
}). This is due to the remote node's current public key does not match the previously stored key for this node.`,
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-8 flex flex-col gap-2">
|
<DialogContent
|
||||||
|
className="max-w-8 flex flex-col gap-2"
|
||||||
|
aria-describedby={undefined}
|
||||||
|
>
|
||||||
<DialogClose onClick={handleCloseDialog} />
|
<DialogClose onClick={handleCloseDialog} />
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Keys Mismatch</DialogTitle>
|
<DialogTitle>{text.title}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
Your node is unable to send a direct message to this node. This is due to the remote node's current public key not matching the previously stored key for this node.
|
{text.description}
|
||||||
<ul className="mt-2">
|
<ul className="mt-2">
|
||||||
<li className="flex place-items-center gap-2 items-start">
|
<li className="flex place-items-center gap-2 items-start">
|
||||||
<div className="p-2 bg-slate-500 rounded-lg mt-1">
|
<div className="p-2 bg-slate-500 rounded-lg mt-1">
|
||||||
<LockKeyholeOpenIcon size={30} className="text-white justify-center" />
|
<LockKeyholeOpenIcon
|
||||||
|
size={30}
|
||||||
|
className="text-white justify-center"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div>
|
<div>
|
||||||
@@ -40,22 +68,19 @@ export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps
|
|||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
onClick={handleNodeRemove}
|
onClick={handleNodeRemove}
|
||||||
className=""
|
|
||||||
>
|
>
|
||||||
Request New Keys
|
Request New Keys
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleCloseDialog}
|
onClick={handleCloseDialog}
|
||||||
className=""
|
|
||||||
>
|
>
|
||||||
Dismiss
|
Dismiss
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
{/* </DialogDescription> */}
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog >
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { renderHook, act } from "@testing-library/react";
|
import { act, renderHook } from "@testing-library/react";
|
||||||
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
|
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
|
||||||
import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
|
import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
|
import { useMessageStore } from "@core/stores/messageStore/index.ts";
|
||||||
|
|
||||||
vi.mock("@core/stores/appStore.ts", () => ({
|
vi.mock("@core/stores/messageStore", () => ({
|
||||||
useAppStore: vi.fn(() => ({ activeChat: "chat-123" })),
|
useMessageStore: vi.fn(() => ({ activeChat: "chat-123" })),
|
||||||
}));
|
}));
|
||||||
|
vi.mock("@core/stores/deviceStore", () => ({
|
||||||
vi.mock("@core/stores/deviceStore.ts", () => ({
|
|
||||||
useDevice: vi.fn(() => ({
|
useDevice: vi.fn(() => ({
|
||||||
removeNode: vi.fn(),
|
removeNode: vi.fn(),
|
||||||
setDialogOpen: vi.fn(),
|
setDialogOpen: vi.fn(),
|
||||||
@@ -23,46 +23,54 @@ describe("useRefreshKeysDialog Hook", () => {
|
|||||||
let clearNodeErrorMock: Mock;
|
let clearNodeErrorMock: Mock;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
removeNodeMock = vi.fn();
|
removeNodeMock = vi.fn();
|
||||||
setDialogOpenMock = vi.fn();
|
setDialogOpenMock = vi.fn();
|
||||||
getNodeErrorMock = vi.fn();
|
getNodeErrorMock = vi.fn().mockReturnValue(undefined);
|
||||||
clearNodeErrorMock = vi.fn();
|
clearNodeErrorMock = vi.fn();
|
||||||
|
|
||||||
(useDevice as Mock).mockReturnValue({
|
vi.mocked(useDevice).mockReturnValue({
|
||||||
removeNode: removeNodeMock,
|
removeNode: removeNodeMock,
|
||||||
setDialogOpen: setDialogOpenMock,
|
setDialogOpen: setDialogOpenMock,
|
||||||
getNodeError: getNodeErrorMock,
|
getNodeError: getNodeErrorMock,
|
||||||
clearNodeError: clearNodeErrorMock,
|
clearNodeError: clearNodeErrorMock,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mocked(useMessageStore).mockReturnValue({
|
||||||
|
activeChat: "chat-123",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handleNodeRemove should remove the node and update dialog if there is an error", () => {
|
it("handleNodeRemove should remove the node and update dialog if there is an error", () => {
|
||||||
getNodeErrorMock.mockReturnValue({ node: "node-abc" });
|
getNodeErrorMock.mockReturnValue({ node: "node-abc" });
|
||||||
|
|
||||||
const { result } = renderHook(() => useRefreshKeysDialog());
|
const { result } = renderHook(() => useRefreshKeysDialog());
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.handleNodeRemove();
|
result.current.handleNodeRemove();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(getNodeErrorMock).toHaveBeenCalledTimes(1);
|
||||||
expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123");
|
expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123");
|
||||||
|
expect(clearNodeErrorMock).toHaveBeenCalledTimes(1);
|
||||||
expect(clearNodeErrorMock).toHaveBeenCalledWith("chat-123");
|
expect(clearNodeErrorMock).toHaveBeenCalledWith("chat-123");
|
||||||
|
expect(removeNodeMock).toHaveBeenCalledTimes(1);
|
||||||
expect(removeNodeMock).toHaveBeenCalledWith("node-abc");
|
expect(removeNodeMock).toHaveBeenCalledWith("node-abc");
|
||||||
|
expect(setDialogOpenMock).toHaveBeenCalledTimes(1);
|
||||||
expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false);
|
expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handleNodeRemove should do nothing if there is no error", () => {
|
it("handleNodeRemove should do nothing if there is no error", () => {
|
||||||
getNodeErrorMock.mockReturnValue(undefined);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useRefreshKeysDialog());
|
const { result } = renderHook(() => useRefreshKeysDialog());
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.handleNodeRemove();
|
result.current.handleNodeRemove();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(getNodeErrorMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123");
|
||||||
|
expect(clearNodeErrorMock).not.toHaveBeenCalled();
|
||||||
expect(removeNodeMock).not.toHaveBeenCalled();
|
expect(removeNodeMock).not.toHaveBeenCalled();
|
||||||
expect(setDialogOpenMock).not.toHaveBeenCalled();
|
expect(setDialogOpenMock).not.toHaveBeenCalled();
|
||||||
expect(clearNodeErrorMock).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handleCloseDialog should close the dialog", () => {
|
it("handleCloseDialog should close the dialog", () => {
|
||||||
@@ -72,6 +80,7 @@ describe("useRefreshKeysDialog Hook", () => {
|
|||||||
result.current.handleCloseDialog();
|
result.current.handleCloseDialog();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(setDialogOpenMock).toHaveBeenCalledTimes(1);
|
||||||
expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false);
|
expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useAppStore } from "@core/stores/appStore.ts";
|
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
|
import { useMessageStore } from "@core/stores/messageStore/index.ts";
|
||||||
|
|
||||||
export function useRefreshKeysDialog() {
|
export function useRefreshKeysDialog() {
|
||||||
const { removeNode, setDialogOpen, clearNodeError, getNodeError } = useDevice();
|
const { removeNode, setDialogOpen, clearNodeError, getNodeError } =
|
||||||
const { activeChat } = useAppStore();
|
useDevice();
|
||||||
|
const { activeChat } = useMessageStore();
|
||||||
|
|
||||||
|
const handleCloseDialog = useCallback(() => {
|
||||||
|
setDialogOpen("refreshKeys", false);
|
||||||
|
}, [setDialogOpen]);
|
||||||
|
|
||||||
const handleNodeRemove = useCallback(() => {
|
const handleNodeRemove = useCallback(() => {
|
||||||
const nodeWithError = getNodeError(activeChat);
|
const nodeWithError = getNodeError(activeChat);
|
||||||
@@ -12,17 +17,12 @@ export function useRefreshKeysDialog() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
clearNodeError(activeChat);
|
clearNodeError(activeChat);
|
||||||
handleCloseDialog();;
|
handleCloseDialog();
|
||||||
return removeNode(nodeWithError?.node);
|
return removeNode(nodeWithError?.node);
|
||||||
}, [activeChat, clearNodeError, setDialogOpen, removeNode]);
|
}, [activeChat, clearNodeError, getNodeError, removeNode, handleCloseDialog]);
|
||||||
|
|
||||||
const handleCloseDialog = useCallback(() => {
|
|
||||||
setDialogOpen('refreshKeys', false);
|
|
||||||
}, [setDialogOpen])
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleCloseDialog,
|
handleCloseDialog,
|
||||||
handleNodeRemove
|
handleNodeRemove,
|
||||||
};
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,7 @@ export const RemoveNodeDialog = ({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: RemoveNodeDialogProps) => {
|
}: RemoveNodeDialogProps) => {
|
||||||
const { connection, nodes, removeNode } = useDevice();
|
const { connection, getNode, removeNode } = useDevice();
|
||||||
const { nodeNumToBeRemoved } = useAppStore();
|
const { nodeNumToBeRemoved } = useAppStore();
|
||||||
|
|
||||||
const onSubmit = () => {
|
const onSubmit = () => {
|
||||||
@@ -42,7 +42,7 @@ export const RemoveNodeDialog = ({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="gap-4">
|
<div className="gap-4">
|
||||||
<form onSubmit={onSubmit}>
|
<form onSubmit={onSubmit}>
|
||||||
<Label>{nodes.get(nodeNumToBeRemoved)?.user?.longName}</Label>
|
<Label>{getNode(nodeNumToBeRemoved)?.user?.longName}</Label>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ export const ShutdownDialog = ({
|
|||||||
type="number"
|
type="number"
|
||||||
value={time}
|
value={time}
|
||||||
onChange={(e) => setTime(Number.parseInt(e.target.value))}
|
onChange={(e) => setTime(Number.parseInt(e.target.value))}
|
||||||
className="dark:text-slate-900"
|
|
||||||
suffix="Minutes"
|
suffix="Minutes"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -23,17 +23,17 @@ export const TracerouteResponseDialog = ({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: TracerouteResponseDialogProps) => {
|
}: TracerouteResponseDialogProps) => {
|
||||||
const { nodes } = useDevice();
|
const { getNode } = useDevice();
|
||||||
const route: number[] = traceroute?.data.route ?? [];
|
const route: number[] = traceroute?.data.route ?? [];
|
||||||
const routeBack: number[] = traceroute?.data.routeBack ?? [];
|
const routeBack: number[] = traceroute?.data.routeBack ?? [];
|
||||||
const snrTowards = traceroute?.data.snrTowards ?? [];
|
const snrTowards = (traceroute?.data.snrTowards ?? []).map((snr) => snr / 4);
|
||||||
const snrBack = traceroute?.data.snrBack ?? [];
|
const snrBack = (traceroute?.data.snrBack ?? []).map((snr) => snr / 4);
|
||||||
const from = nodes.get(traceroute?.from ?? 0);
|
const from = getNode(traceroute?.from ?? 0);
|
||||||
const longName = from?.user?.longName ??
|
const longName = from?.user?.longName ??
|
||||||
(from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown");
|
(from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown");
|
||||||
const shortName = from?.user?.shortName ??
|
const shortName = from?.user?.shortName ??
|
||||||
(from ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : "UNK");
|
(from ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : "UNK");
|
||||||
const to = nodes.get(traceroute?.to ?? 0);
|
const to = getNode(traceroute?.to ?? 0);
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// deno-lint-ignore-file
|
// deno-lint-ignore-file
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
|
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
|
||||||
import { eventBus } from "@core/utils/eventBus.ts";
|
import { eventBus } from "@core/utils/eventBus.ts";
|
||||||
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
|
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
|
||||||
@@ -10,41 +10,58 @@ describe("UnsafeRolesDialog", () => {
|
|||||||
setDialogOpen: vi.fn(),
|
setDialogOpen: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderWithDeviceContext = (ui: any) => {
|
const renderWithDeviceContext = (ui: React.ReactNode) => {
|
||||||
return render(
|
return render(
|
||||||
<DeviceWrapper device={mockDevice}>
|
<DeviceWrapper device={mockDevice}>
|
||||||
{ui}
|
{ui}
|
||||||
</DeviceWrapper>
|
</DeviceWrapper>,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
it("renders the dialog when open is true", () => {
|
it("renders the dialog when open is true", () => {
|
||||||
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
|
renderWithDeviceContext(
|
||||||
|
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
|
||||||
|
);
|
||||||
|
|
||||||
const dialog = screen.getByRole('dialog');
|
const dialog = screen.getByRole("dialog");
|
||||||
expect(dialog).toBeInTheDocument();
|
expect(dialog).toBeInTheDocument();
|
||||||
|
|
||||||
expect(screen.getByText(/I have read the/i)).toBeInTheDocument();
|
expect(screen.getByText(/I have read the/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/understand the implications/i)).toBeInTheDocument();
|
expect(screen.getByText(/understand the implications/i))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
|
||||||
const links = screen.getAllByRole('link');
|
const links = screen.getAllByRole("link");
|
||||||
expect(links).toHaveLength(2);
|
expect(links).toHaveLength(2);
|
||||||
expect(links[0]).toHaveTextContent('Device Role Documentation');
|
expect(links[0]).toHaveTextContent("Device Role Documentation");
|
||||||
expect(links[1]).toHaveTextContent('Choosing The Right Device Role');
|
expect(links[1]).toHaveTextContent("Choosing The Right Device Role");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("displays the correct links", () => {
|
it("displays the correct links", () => {
|
||||||
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
|
renderWithDeviceContext(
|
||||||
|
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
|
||||||
|
);
|
||||||
|
|
||||||
const docLink = screen.getByRole("link", { name: /Device Role Documentation/i });
|
const docLink = screen.getByRole("link", {
|
||||||
const blogLink = screen.getByRole("link", { name: /Choosing The Right Device Role/i });
|
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(docLink).toHaveAttribute(
|
||||||
expect(blogLink).toHaveAttribute("href", "https://meshtastic.org/blog/choosing-the-right-device-role/");
|
"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", () => {
|
it("does not allow confirmation until checkbox is checked", () => {
|
||||||
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
|
renderWithDeviceContext(
|
||||||
|
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
|
||||||
|
);
|
||||||
|
|
||||||
const confirmButton = screen.getByRole("button", { name: /confirm/i });
|
const confirmButton = screen.getByRole("button", { name: /confirm/i });
|
||||||
|
|
||||||
@@ -58,27 +75,37 @@ describe("UnsafeRolesDialog", () => {
|
|||||||
|
|
||||||
it("emits the correct event when closing via close button", () => {
|
it("emits the correct event when closing via close button", () => {
|
||||||
const eventSpy = vi.spyOn(eventBus, "emit");
|
const eventSpy = vi.spyOn(eventBus, "emit");
|
||||||
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
|
renderWithDeviceContext(
|
||||||
|
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
|
||||||
|
);
|
||||||
|
|
||||||
const dismissButton = screen.getByRole("button", { name: /close/i });
|
const dismissButton = screen.getByRole("button", { name: /close/i });
|
||||||
fireEvent.click(dismissButton);
|
fireEvent.click(dismissButton);
|
||||||
|
|
||||||
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", { action: "dismiss" });
|
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", {
|
||||||
|
action: "dismiss",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits the correct event when dismissing", () => {
|
it("emits the correct event when dismissing", () => {
|
||||||
const eventSpy = vi.spyOn(eventBus, "emit");
|
const eventSpy = vi.spyOn(eventBus, "emit");
|
||||||
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
|
renderWithDeviceContext(
|
||||||
|
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
|
||||||
|
);
|
||||||
|
|
||||||
const dismissButton = screen.getByRole("button", { name: /dismiss/i });
|
const dismissButton = screen.getByRole("button", { name: /dismiss/i });
|
||||||
fireEvent.click(dismissButton);
|
fireEvent.click(dismissButton);
|
||||||
|
|
||||||
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", { action: "dismiss" });
|
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", {
|
||||||
|
action: "dismiss",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits the correct event when confirming", () => {
|
it("emits the correct event when confirming", () => {
|
||||||
const eventSpy = vi.spyOn(eventBus, "emit");
|
const eventSpy = vi.spyOn(eventBus, "emit");
|
||||||
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
|
renderWithDeviceContext(
|
||||||
|
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
|
||||||
|
);
|
||||||
|
|
||||||
const checkbox = screen.getByRole("checkbox");
|
const checkbox = screen.getByRole("checkbox");
|
||||||
const confirmButton = screen.getByRole("button", { name: /confirm/i });
|
const confirmButton = screen.getByRole("button", { name: /confirm/i });
|
||||||
@@ -86,6 +113,8 @@ describe("UnsafeRolesDialog", () => {
|
|||||||
fireEvent.click(checkbox);
|
fireEvent.click(checkbox);
|
||||||
fireEvent.click(confirmButton);
|
fireEvent.click(confirmButton);
|
||||||
|
|
||||||
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", { action: "confirm" });
|
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", {
|
||||||
|
action: "confirm",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,29 +19,40 @@ export interface RouterRoleDialogProps {
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UnsafeRolesDialog = ({ open, onOpenChange }: RouterRoleDialogProps) => {
|
export const UnsafeRolesDialog = (
|
||||||
|
{ open, onOpenChange }: RouterRoleDialogProps,
|
||||||
|
) => {
|
||||||
const [confirmState, setConfirmState] = useState(false);
|
const [confirmState, setConfirmState] = useState(false);
|
||||||
const { setDialogOpen } = useDevice();
|
const { setDialogOpen } = useDevice();
|
||||||
|
|
||||||
const deviceRoleLink = "https://meshtastic.org/docs/configuration/radio/device/";
|
const deviceRoleLink =
|
||||||
const choosingTheRightDeviceRoleLink = "https://meshtastic.org/blog/choosing-the-right-device-role/";
|
"https://meshtastic.org/docs/configuration/radio/device/";
|
||||||
|
const choosingTheRightDeviceRoleLink =
|
||||||
|
"https://meshtastic.org/blog/choosing-the-right-device-role/";
|
||||||
|
|
||||||
const handleCloseDialog = (action: 'confirm' | 'dismiss') => {
|
const handleCloseDialog = (action: "confirm" | "dismiss") => {
|
||||||
setDialogOpen('unsafeRoles', false);
|
setDialogOpen("unsafeRoles", false);
|
||||||
setConfirmState(false);
|
setConfirmState(false);
|
||||||
eventBus.emit('dialog:unsafeRoles', { action });
|
eventBus.emit("dialog:unsafeRoles", { action });
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-8 flex flex-col">
|
<DialogContent className="max-w-8 flex flex-col">
|
||||||
<DialogClose onClick={() => handleCloseDialog('dismiss')} />
|
<DialogClose onClick={() => handleCloseDialog("dismiss")} />
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Are you sure?</DialogTitle>
|
<DialogTitle>Are you sure?</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogDescription className="text-md">
|
<DialogDescription className="text-md">
|
||||||
I have read the <Link href={deviceRoleLink} className="">Device Role Documentation</Link>{" "}
|
I have read the{" "}
|
||||||
and the blog post about <Link href={choosingTheRightDeviceRoleLink}>Choosing The Right Device Role</Link> and understand the implications of changing the role.
|
<Link href={deviceRoleLink} className="">
|
||||||
|
Device Role Documentation
|
||||||
|
</Link>{" "}
|
||||||
|
and the blog post about{" "}
|
||||||
|
<Link href={choosingTheRightDeviceRoleLink}>
|
||||||
|
Choosing The Right Device Role
|
||||||
|
</Link>{" "}
|
||||||
|
and understand the implications of changing the role.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -56,16 +67,20 @@ export const UnsafeRolesDialog = ({ open, onOpenChange }: RouterRoleDialogProps)
|
|||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
name="dismiss"
|
name="dismiss"
|
||||||
onClick={() => handleCloseDialog('dismiss')}> Dismiss
|
onClick={() => handleCloseDialog("dismiss")}
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
name="confirm"
|
name="confirm"
|
||||||
disabled={!confirmState}
|
disabled={!confirmState}
|
||||||
onClick={() => handleCloseDialog('confirm')}> Confirm
|
onClick={() => handleCloseDialog("confirm")}
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog >
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, Mock, vi } from "vitest";
|
||||||
import { renderHook } from '@testing-library/react';
|
import { renderHook } from "@testing-library/react";
|
||||||
import { useUnsafeRolesDialog, UNSAFE_ROLES } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog";
|
import {
|
||||||
import { eventBus } from "@core/utils/eventBus";
|
UNSAFE_ROLES,
|
||||||
|
useUnsafeRolesDialog,
|
||||||
|
} from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
|
||||||
|
import { eventBus } from "@core/utils/eventBus.ts";
|
||||||
|
|
||||||
vi.mock('@core/utils/eventBus', () => ({
|
vi.mock("@core/utils/eventBus", () => ({
|
||||||
eventBus: {
|
eventBus: {
|
||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
off: vi.fn(),
|
off: vi.fn(),
|
||||||
@@ -15,13 +18,13 @@ const mockDevice = {
|
|||||||
setDialogOpen: vi.fn(),
|
setDialogOpen: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock('@core/stores/deviceStore', () => ({
|
vi.mock("@core/stores/deviceStore", () => ({
|
||||||
useDevice: () => ({
|
useDevice: () => ({
|
||||||
setDialogOpen: mockDevice.setDialogOpen,
|
setDialogOpen: mockDevice.setDialogOpen,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('useUnsafeRolesDialog', () => {
|
describe("useUnsafeRolesDialog", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
});
|
});
|
||||||
@@ -34,80 +37,111 @@ describe('useUnsafeRolesDialog', () => {
|
|||||||
return renderHook(() => useUnsafeRolesDialog());
|
return renderHook(() => useUnsafeRolesDialog());
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('handleCloseDialog', () => {
|
describe("handleCloseDialog", () => {
|
||||||
it('should call setDialogOpen with correct parameters when dialog is closed', () => {
|
it("should call setDialogOpen with correct parameters when dialog is closed", () => {
|
||||||
const { result } = renderUnsafeRolesHook();
|
const { result } = renderUnsafeRolesHook();
|
||||||
|
|
||||||
result.current.handleCloseDialog();
|
result.current.handleCloseDialog();
|
||||||
|
|
||||||
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith('unsafeRoles', false);
|
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith(
|
||||||
|
"unsafeRoles",
|
||||||
|
false,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('validateRoleSelection', () => {
|
describe("validateRoleSelection", () => {
|
||||||
it('should resolve with true for safe roles without opening dialog', async () => {
|
it("should resolve with true for safe roles without opening dialog", async () => {
|
||||||
const { result } = renderUnsafeRolesHook();
|
const { result } = renderUnsafeRolesHook();
|
||||||
const safeRole = 'SAFE_ROLE';
|
const safeRole = "SAFE_ROLE";
|
||||||
|
|
||||||
const validationResult = await result.current.validateRoleSelection(safeRole);
|
const validationResult = await result.current.validateRoleSelection(
|
||||||
|
safeRole,
|
||||||
|
);
|
||||||
|
|
||||||
expect(validationResult).toBe(true);
|
expect(validationResult).toBe(true);
|
||||||
expect(mockDevice.setDialogOpen).not.toHaveBeenCalled();
|
expect(mockDevice.setDialogOpen).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should open dialog for unsafe roles and resolve with true when confirmed', async () => {
|
it("should open dialog for unsafe roles and resolve with true when confirmed", async () => {
|
||||||
const { result } = renderUnsafeRolesHook();
|
const { result } = renderUnsafeRolesHook();
|
||||||
|
|
||||||
const validationPromise = result.current.validateRoleSelection(UNSAFE_ROLES[0]);
|
const validationPromise = result.current.validateRoleSelection(
|
||||||
|
UNSAFE_ROLES[0],
|
||||||
|
);
|
||||||
|
|
||||||
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith('unsafeRoles', true);
|
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith(
|
||||||
expect(eventBus.on).toHaveBeenCalledWith('dialog:unsafeRoles', expect.any(Function));
|
"unsafeRoles",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(eventBus.on).toHaveBeenCalledWith(
|
||||||
|
"dialog:unsafeRoles",
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
|
||||||
const onHandler = (eventBus.on as Mock).mock.calls[0][1];
|
const onHandler = (eventBus.on as Mock).mock.calls[0][1];
|
||||||
onHandler({ action: 'confirm' });
|
onHandler({ action: "confirm" });
|
||||||
const validationResult = await validationPromise;
|
const validationResult = await validationPromise;
|
||||||
|
|
||||||
expect(validationResult).toBe(true);
|
expect(validationResult).toBe(true);
|
||||||
expect(eventBus.off).toHaveBeenCalledWith('dialog:unsafeRoles', onHandler);
|
expect(eventBus.off).toHaveBeenCalledWith(
|
||||||
|
"dialog:unsafeRoles",
|
||||||
|
onHandler,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should resolve with false when user dismisses the dialog', async () => {
|
it("should resolve with false when user dismisses the dialog", async () => {
|
||||||
const { result } = renderUnsafeRolesHook();
|
const { result } = renderUnsafeRolesHook();
|
||||||
const validationPromise = result.current.validateRoleSelection(UNSAFE_ROLES[0]);
|
const validationPromise = result.current.validateRoleSelection(
|
||||||
|
UNSAFE_ROLES[0],
|
||||||
|
);
|
||||||
const onHandler = (eventBus.on as Mock).mock.calls[0][1];
|
const onHandler = (eventBus.on as Mock).mock.calls[0][1];
|
||||||
onHandler({ action: 'dismiss' });
|
onHandler({ action: "dismiss" });
|
||||||
|
|
||||||
const validationResult = await validationPromise;
|
const validationResult = await validationPromise;
|
||||||
expect(validationResult).toBe(false);
|
expect(validationResult).toBe(false);
|
||||||
expect(eventBus.off).toHaveBeenCalledWith('dialog:unsafeRoles', onHandler);
|
expect(eventBus.off).toHaveBeenCalledWith(
|
||||||
|
"dialog:unsafeRoles",
|
||||||
|
onHandler,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should clean up event listener after response', async () => {
|
it("should clean up event listener after response", async () => {
|
||||||
const { result } = renderUnsafeRolesHook();
|
const { result } = renderUnsafeRolesHook();
|
||||||
|
|
||||||
const validationPromise = result.current.validateRoleSelection(UNSAFE_ROLES[1]);
|
const validationPromise = result.current.validateRoleSelection(
|
||||||
|
UNSAFE_ROLES[1],
|
||||||
|
);
|
||||||
const onHandler = (eventBus.on as Mock).mock.calls[0][1];
|
const onHandler = (eventBus.on as Mock).mock.calls[0][1];
|
||||||
|
|
||||||
onHandler({ action: 'confirm' });
|
onHandler({ action: "confirm" });
|
||||||
await validationPromise;
|
await validationPromise;
|
||||||
|
|
||||||
expect(eventBus.off).toHaveBeenCalledWith('dialog:unsafeRoles', onHandler);
|
expect(eventBus.off).toHaveBeenCalledWith(
|
||||||
|
"dialog:unsafeRoles",
|
||||||
|
onHandler,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should work with all unsafe roles', async () => {
|
it("should work with all unsafe roles", async () => {
|
||||||
const { result } = renderUnsafeRolesHook();
|
const { result } = renderUnsafeRolesHook();
|
||||||
|
|
||||||
for (const unsafeRole of UNSAFE_ROLES) {
|
for (const unsafeRole of UNSAFE_ROLES) {
|
||||||
mockDevice.setDialogOpen.mockClear();
|
mockDevice.setDialogOpen.mockClear();
|
||||||
(eventBus.on as Mock).mockClear();
|
(eventBus.on as Mock).mockClear();
|
||||||
|
|
||||||
const validationPromise = result.current.validateRoleSelection(unsafeRole);
|
const validationPromise = result.current.validateRoleSelection(
|
||||||
|
unsafeRole,
|
||||||
|
);
|
||||||
|
|
||||||
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith('unsafeRoles', true);
|
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith(
|
||||||
|
"unsafeRoles",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
const onHandler = (eventBus.on as Mock).mock.calls[0][1];
|
const onHandler = (eventBus.on as Mock).mock.calls[0][1];
|
||||||
onHandler({ action: 'confirm' });
|
onHandler({ action: "confirm" });
|
||||||
|
|
||||||
const validationResult = await validationPromise;
|
const validationResult = await validationPromise;
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ export const useUnsafeRolesDialog = () => {
|
|||||||
setDialogOpen("unsafeRoles", true);
|
setDialogOpen("unsafeRoles", true);
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const handleResponse = ({ action }: { action: "confirm" | "dismiss" }) => {
|
const handleResponse = (
|
||||||
|
{ action }: { action: "confirm" | "dismiss" },
|
||||||
|
) => {
|
||||||
eventBus.off("dialog:unsafeRoles", handleResponse);
|
eventBus.off("dialog:unsafeRoles", handleResponse);
|
||||||
resolve(action === "confirm");
|
resolve(action === "confirm");
|
||||||
};
|
};
|
||||||
@@ -29,7 +31,7 @@ export const useUnsafeRolesDialog = () => {
|
|||||||
eventBus.on("dialog:unsafeRoles", handleResponse);
|
eventBus.on("dialog:unsafeRoles", handleResponse);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[setDialogOpen]
|
[setDialogOpen],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
} from "react-hook-form";
|
} from "react-hook-form";
|
||||||
import { Heading } from "@components/UI/Typography/Heading.tsx";
|
import { Heading } from "@components/UI/Typography/Heading.tsx";
|
||||||
|
|
||||||
|
|
||||||
interface DisabledBy<T> {
|
interface DisabledBy<T> {
|
||||||
fieldName: Path<T>;
|
fieldName: Path<T>;
|
||||||
selector?: number;
|
selector?: number;
|
||||||
@@ -124,7 +123,9 @@ export function DynamicForm<T extends FieldValues>({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{hasSubmitButton && <Button type="submit" variant="outline">Submit</Button>}
|
{hasSubmitButton && (
|
||||||
|
<Button type="submit" variant="outline">Submit</Button>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,9 @@ import type {
|
|||||||
GenericFormElementProps,
|
GenericFormElementProps,
|
||||||
} from "@components/Form/DynamicForm.tsx";
|
} from "@components/Form/DynamicForm.tsx";
|
||||||
import { Input } from "@components/UI/Input.tsx";
|
import { Input } from "@components/UI/Input.tsx";
|
||||||
import type { LucideIcon } from "lucide-react";
|
|
||||||
import { Eye, EyeOff } from "lucide-react";
|
|
||||||
import type { ChangeEventHandler } from "react";
|
import type { ChangeEventHandler } from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useController, type FieldValues } from "react-hook-form";
|
import { type FieldValues, useController } from "react-hook-form";
|
||||||
|
|
||||||
export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
|
export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||||
type: "text" | "number" | "password";
|
type: "text" | "number" | "password";
|
||||||
@@ -17,16 +15,15 @@ export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
|
|||||||
prefix?: string;
|
prefix?: string;
|
||||||
suffix?: string;
|
suffix?: string;
|
||||||
step?: number;
|
step?: number;
|
||||||
|
className?: string;
|
||||||
fieldLength?: {
|
fieldLength?: {
|
||||||
min?: number;
|
min?: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
currentValueLength?: number;
|
currentValueLength?: number;
|
||||||
showCharacterCount?: boolean;
|
showCharacterCount?: boolean;
|
||||||
},
|
|
||||||
action?: {
|
|
||||||
icon: LucideIcon;
|
|
||||||
onClick: () => void;
|
|
||||||
};
|
};
|
||||||
|
showPasswordToggle?: boolean;
|
||||||
|
showCopyButton?: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,55 +33,55 @@ export function GenericInput<T extends FieldValues>({
|
|||||||
field,
|
field,
|
||||||
}: GenericFormElementProps<T, InputFieldProps<T>>) {
|
}: GenericFormElementProps<T, InputFieldProps<T>>) {
|
||||||
const { fieldLength, ...restProperties } = field.properties || {};
|
const { fieldLength, ...restProperties } = field.properties || {};
|
||||||
|
const [currentLength, setCurrentLength] = useState<number>(
|
||||||
const [passwordShown, setPasswordShown] = useState(false);
|
fieldLength?.currentValueLength || 0,
|
||||||
const [currentLength, setCurrentLength] = useState<number>(fieldLength?.currentValueLength || 0);
|
);
|
||||||
|
|
||||||
const { field: controllerField } = useController({
|
const { field: controllerField } = useController({
|
||||||
name: field.name,
|
name: field.name,
|
||||||
control,
|
control,
|
||||||
});
|
});
|
||||||
|
|
||||||
const togglePasswordVisiblity = () => {
|
|
||||||
setPasswordShown(!passwordShown);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const newValue = e.target.value;
|
const newValue = e.target.value;
|
||||||
|
|
||||||
if (field.properties?.fieldLength?.max && newValue.length > field.properties?.fieldLength?.max) {
|
if (
|
||||||
|
field.properties?.fieldLength?.max &&
|
||||||
|
newValue.length > field.properties?.fieldLength?.max
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCurrentLength(newValue.length);
|
setCurrentLength(newValue.length);
|
||||||
|
|
||||||
if (field.inputChange) field.inputChange(e);
|
if (field.inputChange) field.inputChange(e);
|
||||||
|
|
||||||
controllerField.onChange(field.type === "number" ? Number.parseFloat(newValue).toString() : newValue);
|
controllerField.onChange(
|
||||||
|
field.type === "number"
|
||||||
|
? Number.parseFloat(newValue).toString()
|
||||||
|
: newValue,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<Input
|
<Input
|
||||||
type={field.type === "password" && passwordShown ? "text" : field.type}
|
type={field.type}
|
||||||
action={
|
|
||||||
field.type === "password"
|
|
||||||
? {
|
|
||||||
icon: passwordShown ? EyeOff : Eye,
|
|
||||||
onClick: togglePasswordVisiblity,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
step={field.properties?.step}
|
step={field.properties?.step}
|
||||||
value={field.type === "number" ? String(controllerField.value) : controllerField.value}
|
value={field.type === "number"
|
||||||
|
? String(controllerField.value)
|
||||||
|
: controllerField.value}
|
||||||
id={field.name}
|
id={field.name}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
|
showCopyButton={field.properties?.showCopyButton}
|
||||||
|
showPasswordToggle={field.properties?.showPasswordToggle ||
|
||||||
|
field.type === "password"}
|
||||||
|
className={field.properties?.className}
|
||||||
{...restProperties}
|
{...restProperties}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{fieldLength?.showCharacterCount && fieldLength?.max && (
|
{fieldLength?.showCharacterCount && fieldLength?.max && (
|
||||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3 text-sm text-slate-500 dark:text-slate-400">
|
<div className="absolute inset-y-0 right-0 flex items-center pr-3 text-sm text-slate-900 dark:text-slate-200">
|
||||||
{currentLength ?? fieldLength?.currentValueLength}/{fieldLength?.max}
|
{currentLength ?? fieldLength?.currentValueLength}/{fieldLength?.max}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ import type {
|
|||||||
} from "@components/Form/DynamicForm.tsx";
|
} from "@components/Form/DynamicForm.tsx";
|
||||||
import type { ButtonVariant } from "../UI/Button.tsx";
|
import type { ButtonVariant } from "../UI/Button.tsx";
|
||||||
import { Generator } from "@components/UI/Generator.tsx";
|
import { Generator } from "@components/UI/Generator.tsx";
|
||||||
import { Eye, EyeOff } from "lucide-react";
|
|
||||||
import type { ChangeEventHandler } from "react";
|
import type { ChangeEventHandler } from "react";
|
||||||
import { useState } from "react";
|
|
||||||
import { Controller, type FieldValues } from "react-hook-form";
|
import { Controller, type FieldValues } from "react-hook-form";
|
||||||
|
import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts";
|
||||||
|
|
||||||
export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
|
export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
|
||||||
type: "passwordGenerator";
|
type: "passwordGenerator";
|
||||||
@@ -15,7 +14,7 @@ export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
|
|||||||
hide?: boolean;
|
hide?: boolean;
|
||||||
bits?: { text: string; value: string; key: string }[];
|
bits?: { text: string; value: string; key: string }[];
|
||||||
devicePSKBitCount: number;
|
devicePSKBitCount: number;
|
||||||
inputChange: ChangeEventHandler;
|
inputChange: ChangeEventHandler<HTMLInputElement> | undefined;
|
||||||
selectChange: (event: string) => void;
|
selectChange: (event: string) => void;
|
||||||
actionButtons: {
|
actionButtons: {
|
||||||
text: string;
|
text: string;
|
||||||
@@ -23,6 +22,8 @@ export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
|
|||||||
variant: ButtonVariant;
|
variant: ButtonVariant;
|
||||||
className?: string;
|
className?: string;
|
||||||
}[];
|
}[];
|
||||||
|
showPasswordToggle?: boolean;
|
||||||
|
showCopyButton?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PasswordGenerator<T extends FieldValues>({
|
export function PasswordGenerator<T extends FieldValues>({
|
||||||
@@ -30,10 +31,7 @@ export function PasswordGenerator<T extends FieldValues>({
|
|||||||
field,
|
field,
|
||||||
disabled,
|
disabled,
|
||||||
}: GenericFormElementProps<T, PasswordGeneratorProps<T>>) {
|
}: GenericFormElementProps<T, PasswordGeneratorProps<T>>) {
|
||||||
const [passwordShown, setPasswordShown] = useState(false);
|
const { isVisible } = usePasswordVisibilityToggle();
|
||||||
const togglePasswordVisiblity = () => {
|
|
||||||
setPasswordShown(!passwordShown);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Controller
|
<Controller
|
||||||
@@ -41,14 +39,8 @@ export function PasswordGenerator<T extends FieldValues>({
|
|||||||
control={control}
|
control={control}
|
||||||
render={({ field: { value, ...rest } }) => (
|
render={({ field: { value, ...rest } }) => (
|
||||||
<Generator
|
<Generator
|
||||||
type={field.hide && !passwordShown ? "password" : "text"}
|
type={field.hide && !isVisible ? "password" : "text"}
|
||||||
id={field.id}
|
id={field.id}
|
||||||
action={field.hide
|
|
||||||
? {
|
|
||||||
icon: passwordShown ? EyeOff : Eye,
|
|
||||||
onClick: togglePasswordVisiblity,
|
|
||||||
}
|
|
||||||
: undefined}
|
|
||||||
devicePSKBitCount={field.devicePSKBitCount}
|
devicePSKBitCount={field.devicePSKBitCount}
|
||||||
bits={field.bits}
|
bits={field.bits}
|
||||||
inputChange={field.inputChange}
|
inputChange={field.inputChange}
|
||||||
@@ -56,6 +48,8 @@ export function PasswordGenerator<T extends FieldValues>({
|
|||||||
value={value}
|
value={value}
|
||||||
variant={field.validationText ? "invalid" : "default"}
|
variant={field.validationText ? "invalid" : "default"}
|
||||||
actionButtons={field.actionButtons}
|
actionButtons={field.actionButtons}
|
||||||
|
showPasswordToggle={field.showPasswordToggle}
|
||||||
|
showCopyButton={field.showCopyButton}
|
||||||
{...field.properties}
|
{...field.properties}
|
||||||
{...rest}
|
{...rest}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@components/UI/Select.tsx";
|
} from "@components/UI/Select.tsx";
|
||||||
import { useController, type FieldValues } from "react-hook-form";
|
import { type FieldValues, useController } from "react-hook-form";
|
||||||
|
|
||||||
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||||
type: "select";
|
type: "select";
|
||||||
@@ -46,7 +46,8 @@ export function SelectInput<T extends FieldValues>({
|
|||||||
control,
|
control,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { enumValue, formatEnumName, ...remainingProperties } = field.properties;
|
const { enumValue, formatEnumName, ...remainingProperties } =
|
||||||
|
field.properties;
|
||||||
const valueToKeyMap: Record<string, string> = {};
|
const valueToKeyMap: Record<string, string> = {};
|
||||||
const optionsEnumValues: [string, number][] = [];
|
const optionsEnumValues: [string, number][] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -20,20 +20,21 @@ export const FieldWrapper = ({
|
|||||||
}: FieldWrapperProps) => (
|
}: FieldWrapperProps) => (
|
||||||
<div className="pt-6 sm:pt-5">
|
<div className="pt-6 sm:pt-5">
|
||||||
<fieldset aria-labelledby="label-notifications">
|
<fieldset aria-labelledby="label-notifications">
|
||||||
<div className="sm:grid sm:grid-cols-3 sm:items-baseline sm:gap-4">
|
{/* first column = labels/heading, second column = fields, third column = gutter */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-[0.6fr_2fr_.1fr] sm:items-baseline gap-4">
|
||||||
<Label htmlFor={fieldName}>{label}</Label>
|
<Label htmlFor={fieldName}>{label}</Label>
|
||||||
<div className="sm:col-span-2">
|
<div className="max-w-3xl">
|
||||||
<div className="max-w-lg">
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
<p className="text-sm text-slate-500">{description}</p>
|
{description}
|
||||||
|
</p>
|
||||||
<p hidden={valid ?? true} className="text-sm text-red-500">
|
<p hidden={valid ?? true} className="text-sm text-red-500">
|
||||||
{validationText}
|
{validationText}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4 space-y-4">
|
<div className="mt-4 space-y-4 sm:col-span-2">
|
||||||
<div className="flex items-center">{children}</div>
|
<div className="flex items-center">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ChannelValidation } from "@app/validation/channel.tsx";
|
import type { ChannelValidation } from "@app/validation/channel.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
import { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||||
import { useToast } from "@core/hooks/useToast.ts";
|
import { useToast } from "@core/hooks/useToast.ts";
|
||||||
@@ -97,9 +97,14 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
|
|||||||
settings: {
|
settings: {
|
||||||
...channel?.settings,
|
...channel?.settings,
|
||||||
psk: pass,
|
psk: pass,
|
||||||
moduleSettings: {...channel?.settings?.moduleSettings,
|
moduleSettings: {
|
||||||
positionPrecision: channel?.settings?.moduleSettings?.positionPrecision === undefined ? 10 : channel?.settings?.moduleSettings?.positionPrecision,
|
...channel?.settings?.moduleSettings,
|
||||||
}
|
positionPrecision:
|
||||||
|
channel?.settings?.moduleSettings?.positionPrecision ===
|
||||||
|
undefined
|
||||||
|
? 10
|
||||||
|
: channel?.settings?.moduleSettings?.positionPrecision,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -124,7 +129,7 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
|
|||||||
{
|
{
|
||||||
type: "passwordGenerator",
|
type: "passwordGenerator",
|
||||||
name: "settings.psk",
|
name: "settings.psk",
|
||||||
id: 'channel-psk',
|
id: "channel-psk",
|
||||||
label: "Pre-Shared Key",
|
label: "Pre-Shared Key",
|
||||||
description:
|
description:
|
||||||
"Supported PSK lengths: 256-bit, 128-bit, 8-bit, Empty (0-bit)",
|
"Supported PSK lengths: 256-bit, 128-bit, 8-bit, Empty (0-bit)",
|
||||||
@@ -142,6 +147,8 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
|
|||||||
hide: true,
|
hide: true,
|
||||||
properties: {
|
properties: {
|
||||||
value: pass,
|
value: pass,
|
||||||
|
showPasswordToggle: true,
|
||||||
|
showCopyButton: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -206,6 +213,12 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<PkiRegenerateDialog
|
<PkiRegenerateDialog
|
||||||
|
text={{
|
||||||
|
button: "Regenerate",
|
||||||
|
title: "Regenerate Pre-Shared Key?",
|
||||||
|
description:
|
||||||
|
"Are you sure you want to regenerate the pre-shared key?",
|
||||||
|
}}
|
||||||
open={preSharedDialogOpen}
|
open={preSharedDialogOpen}
|
||||||
onOpenChange={() => setPreSharedDialogOpen(false)}
|
onOpenChange={() => setPreSharedDialogOpen(false)}
|
||||||
onSubmit={() => preSharedKeyRegenerate()}
|
onSubmit={() => preSharedKeyRegenerate()}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useAppStore } from "../../../core/stores/appStore.ts";
|
import { useAppStore } from "../../../core/stores/appStore.ts";
|
||||||
import type { BluetoothValidation } from "@app/validation/config/bluetooth.tsx";
|
import type { BluetoothValidation } from "@app/validation/config/bluetooth.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
import { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { Device } from '@components/PageComponents/Config/Device/index.tsx';
|
import { Device } from "@components/PageComponents/Config/Device/index.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
|
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
|
|
||||||
vi.mock('@core/stores/deviceStore', () => ({
|
vi.mock("@core/stores/deviceStore.ts", () => ({
|
||||||
useDevice: vi.fn()
|
useDevice: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog', () => ({
|
vi.mock("@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts", () => ({
|
||||||
useUnsafeRolesDialog: vi.fn()
|
useUnsafeRolesDialog: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the DynamicForm component since we're testing the Device component,
|
// Mock the DynamicForm component since we're testing the Device component,
|
||||||
// not the DynamicForm implementation
|
// not the DynamicForm implementation
|
||||||
vi.mock('@components/Form/DynamicForm', () => ({
|
vi.mock("@components/Form/DynamicForm", () => ({
|
||||||
DynamicForm: vi.fn(({ onSubmit }) => {
|
DynamicForm: vi.fn(({ onSubmit }) => {
|
||||||
// Render a simplified version of the form for testing
|
// Render a simplified version of the form for testing
|
||||||
return (
|
return (
|
||||||
@@ -28,13 +28,16 @@ vi.mock('@components/Form/DynamicForm', () => ({
|
|||||||
onSubmit(mockData);
|
onSubmit(mockData);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{Object.entries(Protobuf.Config.Config_DeviceConfig_Role).map(([key, value]) => (
|
{Object.entries(Protobuf.Config.Config_DeviceConfig_Role).map((
|
||||||
|
[key, value],
|
||||||
|
) => (
|
||||||
<option key={key} value={value}>
|
<option key={key} value={value}>
|
||||||
{key}
|
{key}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<button type="submit"
|
<button
|
||||||
|
type="submit"
|
||||||
data-testid="submit-button"
|
data-testid="submit-button"
|
||||||
onClick={() => onSubmit({ role: "CLIENT" })}
|
onClick={() => onSubmit({ role: "CLIENT" })}
|
||||||
>
|
>
|
||||||
@@ -42,10 +45,10 @@ vi.mock('@components/Form/DynamicForm', () => ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('Device component', () => {
|
describe("Device component", () => {
|
||||||
const setWorkingConfigMock = vi.fn();
|
const setWorkingConfigMock = vi.fn();
|
||||||
const validateRoleSelectionMock = vi.fn();
|
const validateRoleSelectionMock = vi.fn();
|
||||||
const mockDeviceConfig = {
|
const mockDeviceConfig = {
|
||||||
@@ -63,17 +66,17 @@ describe('Device component', () => {
|
|||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
|
|
||||||
// Mock the useDevice hook
|
// Mock the useDevice hook
|
||||||
(useDevice as any).mockReturnValue({
|
useDevice.mockReturnValue({
|
||||||
config: {
|
config: {
|
||||||
device: mockDeviceConfig
|
device: mockDeviceConfig,
|
||||||
},
|
},
|
||||||
setWorkingConfig: setWorkingConfigMock
|
setWorkingConfig: setWorkingConfigMock,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock the useUnsafeRolesDialog hook
|
// Mock the useUnsafeRolesDialog hook
|
||||||
validateRoleSelectionMock.mockResolvedValue(true);
|
validateRoleSelectionMock.mockResolvedValue(true);
|
||||||
(useUnsafeRolesDialog as any).mockReturnValue({
|
useUnsafeRolesDialog.mockReturnValue({
|
||||||
validateRoleSelection: validateRoleSelectionMock
|
validateRoleSelection: validateRoleSelectionMock,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,48 +84,47 @@ describe('Device component', () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render the Device form', () => {
|
it("should render the Device form", () => {
|
||||||
render(<Device />);
|
render(<Device />);
|
||||||
expect(screen.getByTestId('dynamic-form')).toBeInTheDocument();
|
expect(screen.getByTestId("dynamic-form")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use the validateRoleSelection from the unsafe roles hook', () => {
|
it("should use the validateRoleSelection from the unsafe roles hook", () => {
|
||||||
render(<Device />);
|
render(<Device />);
|
||||||
expect(useUnsafeRolesDialog).toHaveBeenCalled();
|
expect(useUnsafeRolesDialog).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call setWorkingConfig when form is submitted', async () => {
|
it("should call setWorkingConfig when form is submitted", async () => {
|
||||||
render(<Device />);
|
render(<Device />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('submit-button'));
|
fireEvent.click(screen.getByTestId("submit-button"));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(setWorkingConfigMock).toHaveBeenCalledWith(
|
expect(setWorkingConfigMock).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
case: "device",
|
case: "device",
|
||||||
value: expect.objectContaining({ role: "CLIENT" })
|
value: expect.objectContaining({ role: "CLIENT" }),
|
||||||
}
|
},
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should create config with proper structure", async () => {
|
||||||
it('should create config with proper structure', async () => {
|
|
||||||
render(<Device />);
|
render(<Device />);
|
||||||
|
|
||||||
// Simulate form submission
|
// Simulate form submission
|
||||||
fireEvent.click(screen.getByTestId('submit-button'));
|
fireEvent.click(screen.getByTestId("submit-button"));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(setWorkingConfigMock).toHaveBeenCalledWith(
|
expect(setWorkingConfigMock).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
case: "device",
|
case: "device",
|
||||||
value: expect.any(Object)
|
value: expect.any(Object),
|
||||||
}
|
},
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const Device = () => {
|
|||||||
case: "device",
|
case: "device",
|
||||||
value: data,
|
value: data,
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
@@ -83,16 +83,16 @@ export const Device = () => {
|
|||||||
description: "Disable triple click",
|
description: "Disable triple click",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: "text",
|
||||||
name: 'tzdef',
|
name: "tzdef",
|
||||||
label: 'POSIX Timezone',
|
label: "POSIX Timezone",
|
||||||
description: 'The POSIX timezone string for the device',
|
description: "The POSIX timezone string for the device",
|
||||||
properties: {
|
properties: {
|
||||||
fieldLength: {
|
fieldLength: {
|
||||||
max: 64,
|
max: 64,
|
||||||
currentValueLength: config.device?.tzdef?.length,
|
currentValueLength: config.device?.tzdef?.length,
|
||||||
showCharacterCount: true,
|
showCharacterCount: true,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ export const Display = () => {
|
|||||||
label: "Compass North Top",
|
label: "Compass North Top",
|
||||||
description: "Fix north to the top of compass",
|
description: "Fix north to the top of compass",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "toggle",
|
||||||
|
name: "use12hClock",
|
||||||
|
label: "12-Hour Clock",
|
||||||
|
description: "Use 12-hour clock format",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "flipScreen",
|
name: "flipScreen",
|
||||||
|
|||||||
@@ -1,131 +1,24 @@
|
|||||||
// import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
// import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
// import { Network } from '@components/PageComponents/Config/Network/index.tsx';
|
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', () => ({
|
|
||||||
// DynamicForm: vi.fn(({ onSubmit }) => {
|
|
||||||
// return (
|
|
||||||
// <div data-testid="dynamic-form">
|
|
||||||
// <select
|
|
||||||
// data-testid="role-select"
|
|
||||||
// onChange={(e) => {
|
|
||||||
// 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('Network component', () => {
|
|
||||||
// const setWorkingConfigMock = vi.fn();
|
|
||||||
// const mockDeviceConfig = {
|
|
||||||
// role: "CLIENT",
|
|
||||||
// buttonGpio: 0,
|
|
||||||
// buzzerGpio: 0,
|
|
||||||
// rebroadcastMode: "ALL",
|
|
||||||
// nodeInfoBroadcastSecs: 300,
|
|
||||||
// doubleTapAsButtonPress: false,
|
|
||||||
// disableTripleClick: false,
|
|
||||||
// ledHeartbeatDisabled: false,
|
|
||||||
// };
|
|
||||||
|
|
||||||
// beforeEach(() => {
|
|
||||||
// vi.resetAllMocks();
|
|
||||||
|
|
||||||
// (useDevice as any).mockReturnValue({
|
|
||||||
// config: {
|
|
||||||
// device: mockDeviceConfig
|
|
||||||
// },
|
|
||||||
// setWorkingConfig: setWorkingConfigMock
|
|
||||||
// });
|
|
||||||
|
|
||||||
// });
|
|
||||||
|
|
||||||
// afterEach(() => {
|
|
||||||
// vi.clearAllMocks();
|
|
||||||
// });
|
|
||||||
|
|
||||||
// it('should render the Network form', () => {
|
|
||||||
// render(<Network />);
|
|
||||||
// expect(screen.getByTestId('dynamic-form')).toBeInTheDocument();
|
|
||||||
// });
|
|
||||||
|
|
||||||
// it('should call setWorkingConfig when form is submitted', async () => {
|
|
||||||
// render(<Network />);
|
|
||||||
|
|
||||||
// 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(<Network />);
|
|
||||||
|
|
||||||
// // Simulate form submission
|
|
||||||
// fireEvent.click(screen.getByTestId('submit-button'));
|
|
||||||
|
|
||||||
// await waitFor(() => {
|
|
||||||
// expect(setWorkingConfigMock).toHaveBeenCalledWith(
|
|
||||||
// expect.objectContaining({
|
|
||||||
// payloadVariant: {
|
|
||||||
// case: "network",
|
|
||||||
// value: expect.any(Object)
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
// );
|
|
||||||
// });
|
|
||||||
// });
|
|
||||||
// });
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
||||||
import { Network } from '@components/PageComponents/Config/Network/index.tsx';
|
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
|
|
||||||
vi.mock('@core/stores/deviceStore', () => ({
|
vi.mock("@core/stores/deviceStore", () => ({
|
||||||
useDevice: vi.fn()
|
useDevice: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@components/Form/DynamicForm', async () => {
|
vi.mock("@components/Form/DynamicForm", async () => {
|
||||||
const React = await import('react');
|
const React = await import("react");
|
||||||
const { useState } = React;
|
const { useState } = React;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
DynamicForm: ({ onSubmit, defaultValues }: any) => {
|
DynamicForm: ({ onSubmit, defaultValues }) => {
|
||||||
const [wifiEnabled, setWifiEnabled] = useState(defaultValues.wifiEnabled ?? false);
|
const [wifiEnabled, setWifiEnabled] = useState(
|
||||||
const [ssid, setSsid] = useState(defaultValues.wifiSsid ?? '');
|
defaultValues.wifiEnabled ?? false,
|
||||||
const [psk, setPsk] = useState(defaultValues.wifiPsk ?? '');
|
);
|
||||||
|
const [ssid, setSsid] = useState(defaultValues.wifiSsid ?? "");
|
||||||
|
const [psk, setPsk] = useState(defaultValues.wifiPsk ?? "");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
@@ -166,15 +59,14 @@ vi.mock('@components/Form/DynamicForm', async () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
;
|
|
||||||
|
|
||||||
describe('Network component', () => {
|
describe("Network component", () => {
|
||||||
const setWorkingConfigMock = vi.fn();
|
const setWorkingConfigMock = vi.fn();
|
||||||
const mockNetworkConfig = {
|
const mockNetworkConfig = {
|
||||||
wifiEnabled: false,
|
wifiEnabled: false,
|
||||||
wifiSsid: '',
|
wifiSsid: "",
|
||||||
wifiPsk: '',
|
wifiPsk: "",
|
||||||
ntpServer: '',
|
ntpServer: "",
|
||||||
ethEnabled: false,
|
ethEnabled: false,
|
||||||
addressMode: Protobuf.Config.Config_NetworkConfig_AddressMode.DHCP,
|
addressMode: Protobuf.Config.Config_NetworkConfig_AddressMode.DHCP,
|
||||||
ipv4Config: {
|
ipv4Config: {
|
||||||
@@ -185,17 +77,17 @@ describe('Network component', () => {
|
|||||||
},
|
},
|
||||||
enabledProtocols:
|
enabledProtocols:
|
||||||
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
|
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
|
||||||
rsyslogServer: '',
|
rsyslogServer: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
|
|
||||||
(useDevice as any).mockReturnValue({
|
useDevice.mockReturnValue({
|
||||||
config: {
|
config: {
|
||||||
network: mockNetworkConfig
|
network: mockNetworkConfig,
|
||||||
},
|
},
|
||||||
setWorkingConfig: setWorkingConfigMock
|
setWorkingConfig: setWorkingConfigMock,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -203,21 +95,21 @@ describe('Network component', () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render the Network form', () => {
|
it("should render the Network form", () => {
|
||||||
render(<Network />);
|
render(<Network />);
|
||||||
expect(screen.getByTestId('dynamic-form')).toBeInTheDocument();
|
expect(screen.getByTestId("dynamic-form")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should disable SSID and PSK fields when wifi is off', () => {
|
it("should disable SSID and PSK fields when wifi is off", () => {
|
||||||
render(<Network />);
|
render(<Network />);
|
||||||
expect(screen.getByLabelText("SSID")).toBeDisabled();
|
expect(screen.getByLabelText("SSID")).toBeDisabled();
|
||||||
expect(screen.getByLabelText("PSK")).toBeDisabled();
|
expect(screen.getByLabelText("PSK")).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should enable SSID and PSK when wifi is toggled on', async () => {
|
it("should enable SSID and PSK when wifi is toggled on", async () => {
|
||||||
render(<Network />);
|
render(<Network />);
|
||||||
const toggle = screen.getByLabelText("WiFi Enabled");
|
const toggle = screen.getByLabelText("WiFi Enabled");
|
||||||
screen.debug()
|
screen.debug();
|
||||||
|
|
||||||
fireEvent.click(toggle); // turns wifiEnabled = true
|
fireEvent.click(toggle); // turns wifiEnabled = true
|
||||||
|
|
||||||
@@ -227,7 +119,7 @@ describe('Network component', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call setWorkingConfig with the right structure on submit', async () => {
|
it("should call setWorkingConfig with the right structure on submit", async () => {
|
||||||
render(<Network />);
|
render(<Network />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("submit-button"));
|
fireEvent.click(screen.getByTestId("submit-button"));
|
||||||
@@ -239,28 +131,28 @@ describe('Network component', () => {
|
|||||||
case: "network",
|
case: "network",
|
||||||
value: expect.objectContaining({
|
value: expect.objectContaining({
|
||||||
wifiEnabled: false,
|
wifiEnabled: false,
|
||||||
wifiSsid: '',
|
wifiSsid: "",
|
||||||
wifiPsk: '',
|
wifiPsk: "",
|
||||||
ntpServer: '',
|
ntpServer: "",
|
||||||
ethEnabled: false,
|
ethEnabled: false,
|
||||||
rsyslogServer: '',
|
rsyslogServer: "",
|
||||||
})
|
}),
|
||||||
}
|
},
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should submit valid data after enabling wifi and entering SSID and PSK', async () => {
|
it("should submit valid data after enabling wifi and entering SSID and PSK", async () => {
|
||||||
render(<Network />);
|
render(<Network />);
|
||||||
fireEvent.click(screen.getByLabelText("WiFi Enabled"));
|
fireEvent.click(screen.getByLabelText("WiFi Enabled"));
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("SSID"), {
|
fireEvent.change(screen.getByLabelText("SSID"), {
|
||||||
target: { value: "MySSID" }
|
target: { value: "MySSID" },
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("PSK"), {
|
fireEvent.change(screen.getByLabelText("PSK"), {
|
||||||
target: { value: "MySecretPSK" }
|
target: { value: "MySecretPSK" },
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("submit-button"));
|
fireEvent.click(screen.getByTestId("submit-button"));
|
||||||
@@ -273,10 +165,10 @@ describe('Network component', () => {
|
|||||||
value: expect.objectContaining({
|
value: expect.objectContaining({
|
||||||
wifiEnabled: true,
|
wifiEnabled: true,
|
||||||
wifiSsid: "MySSID",
|
wifiSsid: "MySSID",
|
||||||
wifiPsk: "MySecretPSK"
|
wifiPsk: "MySecretPSK",
|
||||||
})
|
}),
|
||||||
}
|
},
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { NetworkValidationSchema, type NetworkValidation } from "@app/validation/config/network.ts";
|
import {
|
||||||
|
type NetworkValidation,
|
||||||
|
NetworkValidationSchema,
|
||||||
|
} from "@app/validation/config/network.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
import { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
@@ -55,8 +58,8 @@ export const Network = () => {
|
|||||||
),
|
),
|
||||||
dns: convertIntToIpAddress(config.network?.ipv4Config?.dns ?? 0),
|
dns: convertIntToIpAddress(config.network?.ipv4Config?.dns ?? 0),
|
||||||
},
|
},
|
||||||
enabledProtocols: config.network?.enabledProtocols ?? Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST
|
enabledProtocols: config.network?.enabledProtocols ??
|
||||||
|
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
|
||||||
}}
|
}}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
@@ -183,10 +186,9 @@ export const Network = () => {
|
|||||||
name: "enabledProtocols",
|
name: "enabledProtocols",
|
||||||
label: "Mesh via UDP",
|
label: "Mesh via UDP",
|
||||||
properties: {
|
properties: {
|
||||||
enumValue:
|
enumValue: Protobuf.Config.Config_NetworkConfig_ProtocolFlags,
|
||||||
Protobuf.Config.Config_NetworkConfig_ProtocolFlags,
|
|
||||||
formatEnumName: true,
|
formatEnumName: true,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
|
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||||
import { useAppStore } from "@core/stores/appStore.ts";
|
import { useAppStore } from "@core/stores/appStore.ts";
|
||||||
import {
|
import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts";
|
||||||
getX25519PrivateKey,
|
|
||||||
getX25519PublicKey,
|
|
||||||
} from "@core/utils/x25519.ts";
|
|
||||||
import type { SecurityValidation } from "@app/validation/config/security.ts";
|
import type { SecurityValidation } from "@app/validation/config/security.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
import { create } from "@bufbuild/protobuf";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { fromByteArray, toByteArray } from "base64-js";
|
import { fromByteArray, toByteArray } from "base64-js";
|
||||||
import { Eye, EyeOff } from "lucide-react";
|
|
||||||
import { useReducer } from "react";
|
import { useReducer } from "react";
|
||||||
import { securityReducer } from "@components/PageComponents/Config/Security/securityReducer.tsx";
|
import { securityReducer } from "@components/PageComponents/Config/Security/securityReducer.tsx";
|
||||||
|
|
||||||
@@ -58,7 +54,8 @@ export const Security = () => {
|
|||||||
if (input.length % 4 !== 0) {
|
if (input.length % 4 !== 0) {
|
||||||
addError(
|
addError(
|
||||||
fieldName,
|
fieldName,
|
||||||
`${fieldName === "privateKey" ? "Private" : "Admin"
|
`${
|
||||||
|
fieldName === "privateKey" ? "Private" : "Admin"
|
||||||
} Key is required to be a 256 bit pre-shared key (PSK)`,
|
} Key is required to be a 256 bit pre-shared key (PSK)`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -73,7 +70,8 @@ export const Security = () => {
|
|||||||
console.error(e);
|
console.error(e);
|
||||||
addError(
|
addError(
|
||||||
fieldName,
|
fieldName,
|
||||||
`Invalid ${fieldName === "privateKey" ? "Private" : "Admin"
|
`Invalid ${
|
||||||
|
fieldName === "privateKey" ? "Private" : "Admin"
|
||||||
} Key format`,
|
} Key format`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -195,11 +193,8 @@ export const Security = () => {
|
|||||||
],
|
],
|
||||||
properties: {
|
properties: {
|
||||||
value: state.privateKey,
|
value: state.privateKey,
|
||||||
action: {
|
showCopyButton: true,
|
||||||
icon: state.privateKeyVisible ? EyeOff : Eye,
|
showPasswordToggle: true,
|
||||||
onClick: () =>
|
|
||||||
dispatch({ type: "TOGGLE_PRIVATE_KEY_VISIBILITY" }),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -211,6 +206,7 @@ export const Security = () => {
|
|||||||
"Sent out to other nodes on the mesh to allow them to compute a shared secret key",
|
"Sent out to other nodes on the mesh to allow them to compute a shared secret key",
|
||||||
properties: {
|
properties: {
|
||||||
value: state.publicKey,
|
value: state.publicKey,
|
||||||
|
showCopyButton: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -244,7 +240,7 @@ export const Security = () => {
|
|||||||
? getErrorMessage("adminKey")
|
? getErrorMessage("adminKey")
|
||||||
: "",
|
: "",
|
||||||
inputChange: adminKeyInputChangeEvent,
|
inputChange: adminKeyInputChangeEvent,
|
||||||
selectChange: () => { },
|
selectChange: () => {},
|
||||||
bits: [{ text: "256 bit", value: "32", key: "bit256" }],
|
bits: [{ text: "256 bit", value: "32", key: "bit256" }],
|
||||||
devicePSKBitCount: state.privateKeyBitCount,
|
devicePSKBitCount: state.privateKeyBitCount,
|
||||||
hide: !state.adminKeyVisible,
|
hide: !state.adminKeyVisible,
|
||||||
@@ -271,11 +267,7 @@ export const Security = () => {
|
|||||||
],
|
],
|
||||||
properties: {
|
properties: {
|
||||||
value: state.adminKey,
|
value: state.adminKey,
|
||||||
action: {
|
showCopyButton: true,
|
||||||
icon: state.adminKeyVisible ? EyeOff : Eye,
|
|
||||||
onClick: () =>
|
|
||||||
dispatch({ type: "TOGGLE_ADMIN_KEY_VISIBILITY" }),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -302,6 +294,11 @@ export const Security = () => {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<PkiRegenerateDialog
|
<PkiRegenerateDialog
|
||||||
|
text={{
|
||||||
|
button: "Regenerate",
|
||||||
|
title: "Regenerate Key pair?",
|
||||||
|
description: "Are you sure you want to regenerate key pair?",
|
||||||
|
}}
|
||||||
open={state.privateKeyDialogOpen}
|
open={state.privateKeyDialogOpen}
|
||||||
onOpenChange={() =>
|
onOpenChange={() =>
|
||||||
dispatch({ type: "SHOW_PRIVATE_KEY_DIALOG", payload: false })}
|
dispatch({ type: "SHOW_PRIVATE_KEY_DIALOG", payload: false })}
|
||||||
|
|||||||
@@ -7,10 +7,15 @@ import { subscribeAll } from "@core/subscriptions.ts";
|
|||||||
import { randId } from "@core/utils/randId.ts";
|
import { randId } from "@core/utils/randId.ts";
|
||||||
import { BleConnection, ServiceUuid } from "@meshtastic/js";
|
import { BleConnection, ServiceUuid } from "@meshtastic/js";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
||||||
|
|
||||||
export const BLE = ({ closeDialog }: TabElementProps) => {
|
export const BLE = (
|
||||||
|
{ closeDialog }: TabElementProps,
|
||||||
|
) => {
|
||||||
|
const [connectionInProgress, setConnectionInProgress] = useState(false);
|
||||||
const [bleDevices, setBleDevices] = useState<BluetoothDevice[]>([]);
|
const [bleDevices, setBleDevices] = useState<BluetoothDevice[]>([]);
|
||||||
const { addDevice } = useDeviceStore();
|
const { addDevice } = useDeviceStore();
|
||||||
|
const messageStore = useMessageStore();
|
||||||
const { setSelectedDevice } = useAppStore();
|
const { setSelectedDevice } = useAppStore();
|
||||||
|
|
||||||
const updateBleDeviceList = useCallback(async (): Promise<void> => {
|
const updateBleDeviceList = useCallback(async (): Promise<void> => {
|
||||||
@@ -30,19 +35,23 @@ export const BLE = ({ closeDialog }: TabElementProps) => {
|
|||||||
device: bleDevice,
|
device: bleDevice,
|
||||||
});
|
});
|
||||||
device.addConnection(connection);
|
device.addConnection(connection);
|
||||||
subscribeAll(device, connection);
|
subscribeAll(device, connection, messageStore);
|
||||||
|
|
||||||
closeDialog();
|
closeDialog();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-2 p-4">
|
<fieldset
|
||||||
|
className="flex w-full flex-col gap-2 p-4"
|
||||||
|
disabled={connectionInProgress}
|
||||||
|
>
|
||||||
<div className="flex h-48 flex-col gap-2 overflow-y-auto">
|
<div className="flex h-48 flex-col gap-2 overflow-y-auto">
|
||||||
{bleDevices.map((device) => (
|
{bleDevices.map((device) => (
|
||||||
<Button
|
<Button
|
||||||
key={device.id}
|
key={device.id}
|
||||||
className="dark:bg-slate-900 dark:text-white"
|
variant="default"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
setConnectionInProgress(true);
|
||||||
onConnect(device);
|
onConnect(device);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -54,7 +63,7 @@ export const BLE = ({ closeDialog }: TabElementProps) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
className="dark:bg-slate-900 dark:text-white"
|
variant="default"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await navigator.bluetooth
|
await navigator.bluetooth
|
||||||
.requestDevice({
|
.requestDevice({
|
||||||
@@ -65,11 +74,16 @@ export const BLE = ({ closeDialog }: TabElementProps) => {
|
|||||||
if (exists === -1) {
|
if (exists === -1) {
|
||||||
setBleDevices(bleDevices.concat(device));
|
setBleDevices(bleDevices.concat(device));
|
||||||
}
|
}
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error("Error requesting device:", error);
|
||||||
|
setConnectionInProgress(false);
|
||||||
|
}).finally(() => {
|
||||||
|
setConnectionInProgress(false);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>New device</span>
|
<span>New device</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</fieldset>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||