Compare commits
225 Commits
deno-round
...
v2.6.4
| 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 | ||
|
|
633b99d6b2 | ||
|
|
87159b4eee | ||
|
|
6d39ecc7b9 | ||
|
|
7738661b7c | ||
|
|
6443544a6b | ||
|
|
4689ebe3ce | ||
|
|
6cd8ce5102 | ||
|
|
a56ac84186 | ||
|
|
443a9ea101 | ||
|
|
0faafe8bc4 | ||
|
|
9948701127 | ||
|
|
ffae92d233 | ||
|
|
fed65d9c8b | ||
|
|
8f225f4d28 | ||
|
|
11e820d1d0 | ||
|
|
95fc72173f | ||
|
|
03b5c639fb | ||
|
|
4d30558aca | ||
|
|
7f376186b4 | ||
|
|
0de24c41ed | ||
|
|
88c4f84edb | ||
|
|
74db087d7d | ||
|
|
e00239562c | ||
|
|
bf9557040f | ||
|
|
6d9a44a0e3 | ||
|
|
35aabdc900 | ||
|
|
163502156d | ||
|
|
8baa5d84b9 | ||
|
|
c55fdbd982 | ||
|
|
8da38ab2e4 | ||
|
|
dddb781627 | ||
|
|
77b3a7ac85 | ||
|
|
626970865f | ||
|
|
c0308532a1 | ||
|
|
8df67bf76a | ||
|
|
80d4670204 | ||
|
|
a378cce0be | ||
|
|
488fd61558 | ||
|
|
ed2ab36ed4 | ||
|
|
0d6c5878fc | ||
|
|
dcbfb08f26 | ||
|
|
dab76df131 | ||
|
|
a7a448cbcd | ||
|
|
1780c6fb2a | ||
|
|
2d54df7dba | ||
|
|
890674eea3 | ||
|
|
d1c19d9d3e | ||
|
|
11b052e5bb | ||
|
|
93a70dfd47 | ||
|
|
6ac8646323 | ||
|
|
a215da1ebe | ||
|
|
22dbfbcc09 | ||
|
|
6341d564d3 | ||
|
|
28cc7b9800 | ||
|
|
5a142e671d | ||
|
|
ba3d45584d | ||
|
|
f54c0dd836 | ||
|
|
a6427a9ed1 | ||
|
|
11058dbf3b | ||
|
|
d062c2f1ab | ||
|
|
1f109d161f | ||
|
|
f2d6daa9fc | ||
|
|
9634e1ce39 | ||
|
|
64055a5aeb | ||
|
|
ad366e6bab | ||
|
|
9399104914 | ||
|
|
f82bc660b0 | ||
|
|
ed13af2382 | ||
|
|
e4c2952e49 | ||
|
|
0830eb9971 | ||
|
|
be9b61ec0c | ||
|
|
be0fe08f2f | ||
|
|
3f8d3389d5 | ||
|
|
7e1ba42873 | ||
|
|
20af1b4d34 | ||
|
|
207061e9d8 | ||
|
|
6633fc9c55 | ||
|
|
52b80613f8 | ||
|
|
0bef82ec32 | ||
|
|
f80bb6c42d | ||
|
|
db2cb8cb42 | ||
|
|
c320d7d173 | ||
|
|
db50bb5c1b | ||
|
|
01a74829fc | ||
|
|
7b77b7f5e9 | ||
|
|
ce8fcd2269 | ||
|
|
f6f64eca10 | ||
|
|
3240ac57f7 | ||
|
|
2008b09ca3 | ||
|
|
491f72b426 | ||
|
|
a6f46bd38a | ||
|
|
c103d7012b | ||
|
|
2cebb8eee2 | ||
|
|
33ad9f989c | ||
|
|
c590ab2ff5 | ||
|
|
9da949d27a | ||
|
|
f1a58f0434 | ||
|
|
0296b241e4 | ||
|
|
344ad48858 | ||
|
|
97f2abb582 | ||
|
|
eca5d780c1 | ||
|
|
1f1a3c5de8 | ||
|
|
844a6316f6 | ||
|
|
d39c5ed079 | ||
|
|
09bb0bc43a | ||
|
|
266e27bfe9 | ||
|
|
5b11131e08 | ||
|
|
3bfd96defe | ||
|
|
cad590f993 | ||
|
|
d70b14b12b | ||
|
|
c115ac0749 | ||
|
|
d54a612e0b | ||
|
|
d379769672 | ||
|
|
b670ffe407 | ||
|
|
4ffbe03b22 | ||
|
|
6a438470cf | ||
|
|
4d0d1da691 | ||
|
|
39f26f475b | ||
|
|
35fed173af | ||
|
|
a8b0515949 | ||
|
|
bd9d599934 | ||
|
|
b40079cdc9 |
@@ -1,2 +0,0 @@
|
||||
dist/build.tar
|
||||
dist/output
|
||||
22
.github/workflows/ci.yml
vendored
@@ -1,8 +1,9 @@
|
||||
name: CI
|
||||
name: Push to Master/Main CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
|
||||
permissions:
|
||||
@@ -24,6 +25,25 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
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
|
||||
run: deno task test
|
||||
|
||||
|
||||
2
.github/workflows/nightly.yml
vendored
@@ -46,7 +46,7 @@ jobs:
|
||||
uses: redhat-actions/buildah-build@v2
|
||||
with:
|
||||
containerfiles: |
|
||||
./Containerfile
|
||||
./infra/Containerfile
|
||||
image: ${{github.event.repository.full_name}}
|
||||
tags: nightly ${{ github.sha }}
|
||||
oci: true
|
||||
|
||||
26
.github/workflows/pr.yml
vendored
@@ -1,6 +1,7 @@
|
||||
name: Pull Request
|
||||
name: Pull Request CI
|
||||
|
||||
on: pull_request
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -14,9 +15,28 @@ jobs:
|
||||
with:
|
||||
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
|
||||
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
|
||||
run: deno task test
|
||||
|
||||
|
||||
12
.github/workflows/release.yml
vendored
@@ -1,8 +1,8 @@
|
||||
name: 'Release'
|
||||
name: Release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
types: [released, prereleased]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -38,6 +38,12 @@ jobs:
|
||||
name: build
|
||||
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
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
@@ -46,7 +52,7 @@ jobs:
|
||||
uses: redhat-actions/buildah-build@v2
|
||||
with:
|
||||
containerfiles: |
|
||||
./Containerfile
|
||||
./infra/Containerfile
|
||||
image: ${{github.event.repository.full_name}}
|
||||
tags: latest ${{ github.sha }}
|
||||
oci: true
|
||||
|
||||
50
.github/workflows/update-stable-from-master.yml
vendored
Normal file
@@ -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
|
||||
3
.gitignore
vendored
@@ -2,5 +2,6 @@ dist
|
||||
node_modules
|
||||
stats.html
|
||||
.vercel
|
||||
.vite/deps
|
||||
.vite
|
||||
dev-dist
|
||||
__screenshots__*
|
||||
@@ -1,10 +0,0 @@
|
||||
FROM nginx:1.27.2-alpine
|
||||
|
||||
RUN rm -r /usr/share/nginx/html \
|
||||
&& mkdir /usr/share/nginx/html
|
||||
|
||||
WORKDIR /usr/share/nginx/html
|
||||
|
||||
ADD dist .
|
||||
|
||||
CMD nginx -g "daemon off;"
|
||||
77
README.md
@@ -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
|
||||
```
|
||||
|
||||
## Nightly releases
|
||||
## Release Schedule
|
||||
|
||||
Our nightly releases provide the latest development builds with cutting-edge
|
||||
features and fixes. These builds are automatically generated from the latest
|
||||
main branch every night and are available for testing and early adoption.
|
||||
Our release process follows these guidelines:
|
||||
|
||||
- **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
|
||||
# With Docker
|
||||
@@ -73,7 +82,7 @@ podman run -d -p 8080:8080 --restart always --name Meshtastic-Web ghcr.io/meshta
|
||||
> new features
|
||||
> - No guarantee of backward compatibility between nightly builds
|
||||
|
||||
### Version Information
|
||||
#### Version Information
|
||||
|
||||
Each nightly build is tagged with:
|
||||
|
||||
@@ -139,46 +148,28 @@ reasons:
|
||||
- **Web Standard APIs**: Uses browser-compatible APIs, making code more portable
|
||||
between server and client environments.
|
||||
|
||||
### Debugging
|
||||
### Contributing
|
||||
|
||||
#### Debugging with React Scan
|
||||
We welcome contributions! Here’s how the deployment flow works for pull
|
||||
requests:
|
||||
|
||||
Meshtastic Web Client has included the library
|
||||
[React Scan](https://github.com/aidenybai/react-scan) to help you identify and
|
||||
resolve render performance issues during development.
|
||||
- **Preview Deployments:**\
|
||||
Every pull request automatically generates a preview deployment on Vercel.
|
||||
This allows you and reviewers to easily preview changes before merging.
|
||||
|
||||
React's comparison-by-reference approach to props makes it easy to inadvertently
|
||||
cause unnecessary re-renders, especially with:
|
||||
- **Staging Environment (`client-test`):**\
|
||||
Once your PR is merged, your changes will be available on our staging site:
|
||||
[client-test.meshtastic.org](https://client-test.meshtastic.org/).\
|
||||
This environment supports rapid feature iteration and testing without
|
||||
impacting the production site.
|
||||
|
||||
- Inline function callbacks (`onClick={() => handleClick()}`)
|
||||
- Object literals (`style={{ color: "purple" }}`)
|
||||
- Array literals (`items={[1, 2, 3]}`)
|
||||
- **Production Releases:**\
|
||||
At regular intervals, stable and fully tested releases are promoted to our
|
||||
production site: [client.meshtastic.org](https://client.meshtastic.org/).\
|
||||
This is the primary interface used by the public to connect with their
|
||||
Meshtastic nodes.
|
||||
|
||||
These are recreated on every render, causing child components to re-render even
|
||||
when nothing has actually changed.
|
||||
|
||||
Unlike React DevTools, React Scan specifically focuses on performance
|
||||
optimization by:
|
||||
|
||||
- Clearly distinguishing between necessary and unnecessary renders
|
||||
- Providing render counts for components
|
||||
- Highlighting slow-rendering components
|
||||
- Offering a dedicated performance debugging experience
|
||||
|
||||
#### Usage
|
||||
|
||||
When experiencing slow renders, run:
|
||||
|
||||
```bash
|
||||
deno task dev:scan
|
||||
```
|
||||
|
||||
This will allow you to discover the following about your components and pages:
|
||||
|
||||
- Components with excessive re-renders
|
||||
- Performance bottlenecks in the render tree
|
||||
- Expensive hook operations
|
||||
- Props that change reference on every render
|
||||
|
||||
Use these insights to apply targeted optimizations like `React.memo()`,
|
||||
`useCallback()`, or `useMemo()` where they'll have the most impact.
|
||||
Please review our
|
||||
[Contribution Guidelines](https://github.com/meshtastic/web/blob/master/CONTRIBUTING.md)
|
||||
before submitting a pull request. We appreciate your help in making the project
|
||||
better!
|
||||
|
||||
13
deno.json
@@ -28,6 +28,19 @@
|
||||
],
|
||||
"strictPropertyInitialization": false
|
||||
},
|
||||
"fmt": {
|
||||
"exclude": [
|
||||
"*.test.ts",
|
||||
"*.test.tsx"
|
||||
]
|
||||
},
|
||||
"lint": {
|
||||
"exclude": [
|
||||
"*.test.ts",
|
||||
"*.test.tsx"
|
||||
],
|
||||
"report": "pretty"
|
||||
},
|
||||
"unstable": [
|
||||
"sloppy-imports"
|
||||
]
|
||||
|
||||
2
infra/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
../dist/build.tar
|
||||
../dist/output
|
||||
15
infra/Containerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
RUN rm -r /usr/share/nginx/html \
|
||||
&& mkdir -p /usr/share/nginx/html \
|
||||
&& mkdir -p /etc/nginx/conf.d
|
||||
|
||||
WORKDIR /usr/share/nginx/html
|
||||
|
||||
ADD dist .
|
||||
|
||||
COPY ./infra/default.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
42
infra/default.conf
Normal file
@@ -0,0 +1,42 @@
|
||||
server {
|
||||
listen 8080;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
internal;
|
||||
}
|
||||
|
||||
location ~ /\.ht {
|
||||
deny all;
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_disable "msie6";
|
||||
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_buffers 16 8k;
|
||||
gzip_http_version 1.1;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/xml
|
||||
text/javascript
|
||||
application/javascript
|
||||
application/x-javascript
|
||||
application/json
|
||||
application/xml
|
||||
application/xml+rss
|
||||
font/ttf
|
||||
font/otf
|
||||
image/svg+xml;
|
||||
}
|
||||
102
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "meshtastic-web",
|
||||
"version": "2.3.3-0",
|
||||
"version": "2.6.0-0",
|
||||
"type": "module",
|
||||
"description": "Meshtastic web client",
|
||||
"license": "GPL-3.0-only",
|
||||
@@ -12,9 +12,7 @@
|
||||
"format": "deno fmt src/",
|
||||
"dev": "deno task dev:ui",
|
||||
"dev:ui": "deno run -A npm:vite dev",
|
||||
"dev:scan": "VITE_DEBUG_SCAN=true deno task dev:ui",
|
||||
"test": "deno run -A npm:vitest",
|
||||
"test:ui": "deno task test --ui",
|
||||
"preview": "deno run -A npm:vite preview",
|
||||
"package": "gzipper c -i html,js,css,png,ico,svg,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ ."
|
||||
},
|
||||
@@ -36,72 +34,78 @@
|
||||
},
|
||||
"homepage": "https://meshtastic.org",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^2.2.3",
|
||||
"@meshtastic/core": "npm:@jsr/meshtastic__core@2.6.0-0",
|
||||
"@meshtastic/core": "npm:@jsr/meshtastic__core@2.6.2",
|
||||
"@meshtastic/js": "npm:@jsr/meshtastic__js@2.6.0-0",
|
||||
"@meshtastic/transport-http": "npm:@jsr/meshtastic__transport-http",
|
||||
"@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth",
|
||||
"@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial",
|
||||
"@noble/curves": "^1.8.1",
|
||||
"@radix-ui/react-accordion": "^1.2.3",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-menubar": "^1.1.6",
|
||||
"@radix-ui/react-popover": "^1.1.6",
|
||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
"@radix-ui/react-separator": "^1.1.2",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-toast": "^1.2.6",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@bufbuild/protobuf": "^2.2.5",
|
||||
"@noble/curves": "^1.9.0",
|
||||
"@radix-ui/react-accordion": "^1.2.8",
|
||||
"@radix-ui/react-checkbox": "^1.2.3",
|
||||
"@radix-ui/react-dialog": "^1.1.11",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
||||
"@radix-ui/react-label": "^2.1.4",
|
||||
"@radix-ui/react-menubar": "^1.1.12",
|
||||
"@radix-ui/react-popover": "^1.1.11",
|
||||
"@radix-ui/react-scroll-area": "^1.2.6",
|
||||
"@radix-ui/react-select": "^2.2.2",
|
||||
"@radix-ui/react-separator": "^1.1.4",
|
||||
"@radix-ui/react-slider": "^1.3.2",
|
||||
"@radix-ui/react-switch": "^1.2.2",
|
||||
"@radix-ui/react-tabs": "^1.1.9",
|
||||
"@radix-ui/react-toast": "^1.2.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.4",
|
||||
"@turf/turf": "^7.2.0",
|
||||
"base64-js": "^1.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"class-validator": "^0.14.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.4",
|
||||
"cmdk": "^1.1.1",
|
||||
"crypto-random-string": "^5.0.0",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"immer": "^10.1.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.477.0",
|
||||
"maplibre-gl": "5.1.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-error-boundary": "^5.0.0",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-map-gl": "8.0.1",
|
||||
"lucide-react": "^0.507.0",
|
||||
"maplibre-gl": "5.4.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-error-boundary": "^6.0.0",
|
||||
"react-hook-form": "^7.56.2",
|
||||
"react-map-gl": "8.0.4",
|
||||
"react-qrcode-logo": "^3.0.0",
|
||||
"react-scan": "^0.2.8",
|
||||
"rfc4648": "^1.5.4",
|
||||
"vite-plugin-node-polyfills": "^0.23.0",
|
||||
"zustand": "5.0.3"
|
||||
"zod": "^3.24.3",
|
||||
"zustand": "5.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.9",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@types/chrome": "^0.0.307",
|
||||
"@tailwindcss/postcss": "^4.1.5",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/chrome": "^0.0.318",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/node": "^22.13.7",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/serviceworker": "^0.0.123",
|
||||
"@types/node": "^22.15.3",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.3",
|
||||
"@types/serviceworker": "^0.0.133",
|
||||
"@types/w3c-web-serial": "^1.0.8",
|
||||
"@types/web-bluetooth": "^0.0.21",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"gzipper": "^8.2.0",
|
||||
"happy-dom": "^17.1.8",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"gzipper": "^8.2.1",
|
||||
"happy-dom": "^17.4.6",
|
||||
"postcss": "^8.5.3",
|
||||
"simple-git-hooks": "^2.11.1",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"tailwindcss": "^4.0.9",
|
||||
"simple-git-hooks": "^2.13.0",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tailwindcss": "^4.1.5",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tar": "^7.4.3",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^6.2.0",
|
||||
"vite-plugin-pwa": "^0.21.1",
|
||||
"vitest": "^3.0.7"
|
||||
"testing-library": "^0.0.2",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.4",
|
||||
"vitest": "^3.1.2",
|
||||
"vite-plugin-pwa": "^1.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
16
public/Logo.svg
Normal file
@@ -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 |
53
src/App.tsx
@@ -1,7 +1,5 @@
|
||||
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
|
||||
import { PageRouter } from "@app/PageRouter.tsx";
|
||||
import { CommandPalette } from "@components/CommandPalette.tsx";
|
||||
import { DeviceSelector } from "@components/DeviceSelector.tsx";
|
||||
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
|
||||
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx";
|
||||
import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx";
|
||||
@@ -14,7 +12,9 @@ import type { JSX } from "react";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { ErrorPage } from "@components/UI/ErrorPage.tsx";
|
||||
import { MapProvider } from "react-map-gl/maplibre";
|
||||
|
||||
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 => {
|
||||
const { getDevice } = useDeviceStore();
|
||||
@@ -23,6 +23,9 @@ export const App = (): JSX.Element => {
|
||||
|
||||
const device = getDevice(selectedDevice);
|
||||
|
||||
// Sets up light/dark mode based on user preferences or system settings
|
||||
useTheme();
|
||||
|
||||
return (
|
||||
<ErrorBoundary FallbackComponent={ErrorPage}>
|
||||
<NewDeviceDialog
|
||||
@@ -33,27 +36,31 @@ export const App = (): JSX.Element => {
|
||||
/>
|
||||
<Toaster />
|
||||
<DeviceWrapper device={device}>
|
||||
<div className="flex h-screen flex-col overflow-hidden bg-background-primary text-text-primary">
|
||||
<div className="flex grow">
|
||||
<DeviceSelector />
|
||||
<div className="flex grow flex-col">
|
||||
{device ? (
|
||||
<div className="flex h-screen w-full">
|
||||
<DialogManager />
|
||||
<KeyBackupReminder />
|
||||
<CommandPalette />
|
||||
<MapProvider>
|
||||
<PageRouter />
|
||||
</MapProvider>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Dashboard />
|
||||
<Footer />
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className="flex h-screen flex-col bg-background-primary text-text-primary"
|
||||
style={{ scrollbarWidth: "thin" }}
|
||||
>
|
||||
<SidebarProvider>
|
||||
<div className="h-full flex flex-col">
|
||||
{device
|
||||
? (
|
||||
<div className="h-full flex w-full">
|
||||
<DialogManager />
|
||||
<KeyBackupReminder />
|
||||
<CommandPalette />
|
||||
<MapProvider>
|
||||
<PageRouter />
|
||||
</MapProvider>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<Dashboard />
|
||||
<Footer />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
</DeviceWrapper>
|
||||
</ErrorBoundary>
|
||||
|
||||
43
src/__mocks__/README.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Mocks Directory
|
||||
|
||||
This directory contains mock implementations used by Vitest for testing.
|
||||
|
||||
## Structure
|
||||
|
||||
The directory structure mirrors the actual project structure to make mocking
|
||||
more intuitive:
|
||||
|
||||
```
|
||||
__mocks__/
|
||||
├── components/
|
||||
│ └── UI/
|
||||
│ ├── Dialog.tsx
|
||||
│ ├── Button.tsx
|
||||
│ ├── Checkbox.tsx
|
||||
│ └── ...
|
||||
├── core/
|
||||
│ └── ...
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Auto-mocking
|
||||
|
||||
Vitest will automatically use the mock files in this directory when the
|
||||
corresponding module is imported in tests. For example, when a test imports
|
||||
`@components/UI/Dialog.tsx`, Vitest will use
|
||||
`__mocks__/components/UI/Dialog.tsx` instead.
|
||||
|
||||
## Creating New Mocks
|
||||
|
||||
To create a new mock:
|
||||
|
||||
1. Create a file in the same relative path as the original module
|
||||
2. Export the mocked functionality with the same names as the original
|
||||
3. Add a `vi.mock()` statement to `vitest.setup.ts` if needed
|
||||
|
||||
## Mock Guidelines
|
||||
|
||||
- Keep mocks as simple as possible
|
||||
- Use `data-testid` attributes for easy querying in tests
|
||||
- Implement just enough functionality to test the component
|
||||
- Use TypeScript types to ensure compatibility with the original module
|
||||
21
src/__mocks__/components/UI/Button.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
vi.mock("@components/UI/Button.tsx", () => ({
|
||||
Button: ({ children, name, disabled, onClick }: {
|
||||
children: React.ReactNode;
|
||||
variant: string;
|
||||
name: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
name={name}
|
||||
data-testid={`button-${name}`}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
19
src/__mocks__/components/UI/Checkbox.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
vi.mock("@components/UI/Checkbox.tsx", () => ({
|
||||
Checkbox: (
|
||||
{ id, checked, onChange }: {
|
||||
id: string;
|
||||
checked: boolean;
|
||||
onChange: () => void;
|
||||
},
|
||||
) => (
|
||||
<input
|
||||
data-testid="checkbox"
|
||||
type="checkbox"
|
||||
id={id}
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
45
src/__mocks__/components/UI/Dialog/Dialog.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
|
||||
export const Dialog = ({ children, open }: {
|
||||
children: React.ReactNode;
|
||||
open: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) => open ? <div data-testid="dialog">{children}</div> : null;
|
||||
|
||||
export const DialogContent = ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => <div data-testid="dialog-content" className={className}>{children}</div>;
|
||||
|
||||
export const DialogHeader = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => <div data-testid="dialog-header">{children}</div>;
|
||||
|
||||
export const DialogTitle = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => <div data-testid="dialog-title">{children}</div>;
|
||||
|
||||
export const DialogDescription = ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => (
|
||||
<div data-testid="dialog-description" className={className}>{children}</div>
|
||||
);
|
||||
|
||||
export const DialogFooter = ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => <div data-testid="dialog-footer" className={className}>{children}</div>;
|
||||
15
src/__mocks__/components/UI/Label.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
vi.mock("@components/UI/Label.tsx", () => ({
|
||||
Label: (
|
||||
{ children, htmlFor, className }: {
|
||||
children: React.ReactNode;
|
||||
htmlFor: string;
|
||||
className?: string;
|
||||
},
|
||||
) => (
|
||||
<label data-testid="label" htmlFor={htmlFor} className={className}>
|
||||
{children}
|
||||
</label>
|
||||
),
|
||||
}));
|
||||
11
src/__mocks__/components/UI/Link.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
vi.mock("@components/UI/Typography/Link.tsx", () => ({
|
||||
Link: (
|
||||
{ children, href, className }: {
|
||||
children: React.ReactNode;
|
||||
href: string;
|
||||
className?: string;
|
||||
},
|
||||
) => <a data-testid="link" href={href} className={className}>{children}</a>,
|
||||
}));
|
||||
88
src/components/BatteryStatus.tsx
Normal file
@@ -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;
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Avatar } from "./UI/Avatar.tsx";
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
type LucideIcon,
|
||||
MapIcon,
|
||||
MessageSquareIcon,
|
||||
Pin,
|
||||
PlusIcon,
|
||||
PowerIcon,
|
||||
QrCodeIcon,
|
||||
@@ -29,9 +29,11 @@ import {
|
||||
SmartphoneIcon,
|
||||
TrashIcon,
|
||||
UsersIcon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { Avatar } from "@components/UI/Avatar.tsx";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { usePinnedItems } from "@core/hooks/usePinnedItems.ts";
|
||||
|
||||
export interface Group {
|
||||
label: string;
|
||||
@@ -45,7 +47,6 @@ export interface Command {
|
||||
subItems?: SubItem[];
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface SubItem {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
@@ -56,12 +57,14 @@ export const CommandPalette = () => {
|
||||
const {
|
||||
commandPaletteOpen,
|
||||
setCommandPaletteOpen,
|
||||
setConnectDialogOpen,
|
||||
setSelectedDevice,
|
||||
removeDevice,
|
||||
selectedDevice,
|
||||
} = useAppStore();
|
||||
const { getDevices } = useDeviceStore();
|
||||
const { setDialogOpen, setActivePage, connection } = useDevice();
|
||||
const { setDialogOpen, setActivePage, getNode, connection } = useDevice();
|
||||
const { pinnedItems, togglePinnedItem } = usePinnedItems({
|
||||
storageName: "pinnedCommandMenuGroups",
|
||||
});
|
||||
|
||||
const groups: Group[] = [
|
||||
{
|
||||
@@ -113,28 +116,25 @@ export const CommandPalette = () => {
|
||||
{
|
||||
label: "Switch Node",
|
||||
icon: ArrowLeftRightIcon,
|
||||
subItems: getDevices().map((device) => {
|
||||
return {
|
||||
label:
|
||||
device.nodes.get(device.hardware.myNodeNum)?.user?.longName ??
|
||||
device.hardware.myNodeNum.toString(),
|
||||
icon: (
|
||||
<Avatar
|
||||
text={device.nodes.get(device.hardware.myNodeNum)?.user
|
||||
?.shortName ?? device.hardware.myNodeNum.toString()}
|
||||
/>
|
||||
),
|
||||
action() {
|
||||
setSelectedDevice(device.id);
|
||||
},
|
||||
};
|
||||
}),
|
||||
subItems: getDevices().map((device) => ({
|
||||
label: getNode(device.hardware.myNodeNum)?.user?.longName ??
|
||||
device.hardware.myNodeNum.toString(),
|
||||
icon: (
|
||||
<Avatar
|
||||
text={getNode(device.hardware.myNodeNum)?.user?.shortName ??
|
||||
device.hardware.myNodeNum.toString()}
|
||||
/>
|
||||
),
|
||||
action() {
|
||||
setSelectedDevice(device.id);
|
||||
},
|
||||
})),
|
||||
},
|
||||
{
|
||||
label: "Connect New Node",
|
||||
icon: PlusIcon,
|
||||
action() {
|
||||
setSelectedDevice(0);
|
||||
setConnectDialogOpen(true);
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -163,15 +163,6 @@ export const CommandPalette = () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Disconnect",
|
||||
icon: XCircleIcon,
|
||||
action() {
|
||||
void connection?.disconnect();
|
||||
setSelectedDevice(0);
|
||||
removeDevice(selectedDevice ?? 0);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Schedule Shutdown",
|
||||
icon: PowerIcon,
|
||||
@@ -186,6 +177,13 @@ export const CommandPalette = () => {
|
||||
setDialogOpen("reboot", true);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Reboot To OTA Mode",
|
||||
icon: RefreshCwIcon,
|
||||
action() {
|
||||
setDialogOpen("rebootOTA", true);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Reset Nodes",
|
||||
icon: TrashIcon,
|
||||
@@ -221,16 +219,22 @@ export const CommandPalette = () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "[WIP] Clear Messages",
|
||||
label: "Clear All Stored Message",
|
||||
icon: EraserIcon,
|
||||
action() {
|
||||
alert("This feature is not implemented");
|
||||
setDialogOpen("deleteMessages", true);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const sortedGroups = [...groups].sort((a, b) => {
|
||||
const aPinned = pinnedItems.includes(a.label) ? 1 : 0;
|
||||
const bPinned = pinnedItems.includes(b.label) ? 1 : 0;
|
||||
return bPinned - aPinned;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||
@@ -251,8 +255,39 @@ export const CommandPalette = () => {
|
||||
<CommandInput placeholder="Type a command or search..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
{groups.map((group) => (
|
||||
<CommandGroup key={group.label} heading={group.label}>
|
||||
{sortedGroups.map((group) => (
|
||||
<CommandGroup
|
||||
key={group.label}
|
||||
heading={
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{group.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => togglePinnedItem(group.label)}
|
||||
className={cn(
|
||||
"transition-all duration-300 scale-100 cursor-pointer p-2 focus:*:data-label:opacity-100",
|
||||
)}
|
||||
aria-description={pinnedItems.includes(group.label)
|
||||
? "Unpin command group"
|
||||
: "Pin command group"}
|
||||
>
|
||||
<span
|
||||
data-label
|
||||
className="transition-all block absolute w-full mb-auto mt-auto ml-0 mr-0 text-xs left-0 -top-5 opacity-0 rounded-lg"
|
||||
/>
|
||||
<Pin
|
||||
size={16}
|
||||
className={cn(
|
||||
"transition-opacity",
|
||||
pinnedItems.includes(group.label)
|
||||
? "opacity-100 text-red-500"
|
||||
: "opacity-40 hover:opacity-70",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{group.commands.map((command) => (
|
||||
<div key={command.label}>
|
||||
<CommandItem
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -3,16 +3,18 @@ import { create } from "@bufbuild/protobuf";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import { Label } from "@components/UI/Label.tsx";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { GenericInput } from "@components/Form/FormInput.tsx";
|
||||
import { validateMaxByteLength } from "@core/utils/string.ts";
|
||||
|
||||
export interface User {
|
||||
longName: string;
|
||||
@@ -23,56 +25,106 @@ export interface DeviceNameDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
const MAX_LONG_NAME_BYTE_LENGTH = 40;
|
||||
const MAX_SHORT_NAME_BYTE_LENGTH = 4;
|
||||
|
||||
export const DeviceNameDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: DeviceNameDialogProps) => {
|
||||
const { hardware, nodes, connection } = useDevice();
|
||||
const { hardware, getNode, connection } = useDevice();
|
||||
const myNode = getNode(hardware.myNodeNum);
|
||||
|
||||
const myNode = nodes.get(hardware.myNodeNum);
|
||||
const defaultValues = {
|
||||
longName: myNode?.user?.longName ?? "Unknown",
|
||||
shortName: myNode?.user?.shortName ?? "??",
|
||||
};
|
||||
|
||||
const { register, handleSubmit } = useForm<User>({
|
||||
values: {
|
||||
longName: myNode?.user?.longName ?? "Unknown",
|
||||
shortName: myNode?.user?.shortName ?? "Unknown",
|
||||
},
|
||||
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) => {
|
||||
connection?.setOwner(
|
||||
create(Protobuf.Mesh.UserSchema, {
|
||||
...myNode?.user,
|
||||
...(myNode?.user ?? {}),
|
||||
...data,
|
||||
}),
|
||||
);
|
||||
onOpenChange(false);
|
||||
});
|
||||
|
||||
const handleReset = () => {
|
||||
reset({ longName: "", shortName: "" });
|
||||
setValue("longName", "");
|
||||
setValue("shortName", "");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Change Device Name</DialogTitle>
|
||||
<DialogDescription>
|
||||
The Device will restart once the config is saved.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="gap-4">
|
||||
<form onSubmit={onSubmit}>
|
||||
<Label>Long Name</Label>
|
||||
<Input className="dark:text-slte-900" {...register("longName")} />
|
||||
<Label>Short Name</Label>
|
||||
<Input
|
||||
className="dark:text-slte-900"
|
||||
maxLength={4}
|
||||
{...register("shortName")}
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-4">
|
||||
<div>
|
||||
<Label htmlFor="longName">Long Name</Label>
|
||||
<GenericInput
|
||||
control={control}
|
||||
field={{
|
||||
name: "longName",
|
||||
label: "Long Name",
|
||||
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>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => onSubmit()}>Save</Button>
|
||||
</DialogFooter>
|
||||
</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>
|
||||
<Button type="button" variant="destructive" onClick={handleReset}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button type="submit">Save</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { RemoveNodeDialog } from "@components/Dialog/RemoveNodeDialog.tsx";
|
||||
import { DeviceNameDialog } from "@components/Dialog/DeviceNameDialog.tsx";
|
||||
import { ImportDialog } from "@components/Dialog/ImportDialog.tsx";
|
||||
import { PkiBackupDialog } from "./PKIBackupDialog.tsx";
|
||||
import { PkiBackupDialog } from "@components/Dialog/PKIBackupDialog.tsx";
|
||||
import { QRDialog } from "@components/Dialog/QRDialog.tsx";
|
||||
import { RebootDialog } from "@components/Dialog/RebootDialog.tsx";
|
||||
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
|
||||
import { NodeDetailsDialog } from "./NodeDetailsDialog.tsx";
|
||||
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
|
||||
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
|
||||
import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx";
|
||||
import { RebootOTADialog } from "@components/Dialog/RebootOTADialog.tsx";
|
||||
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
|
||||
|
||||
export const DialogManager = () => {
|
||||
const { channels, config, dialog, setDialogOpen } = useDevice();
|
||||
@@ -64,6 +67,30 @@ export const DialogManager = () => {
|
||||
setDialogOpen("nodeDetails", open);
|
||||
}}
|
||||
/>
|
||||
<UnsafeRolesDialog
|
||||
open={dialog.unsafeRoles}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen("unsafeRoles", open);
|
||||
}}
|
||||
/>
|
||||
<RefreshKeysDialog
|
||||
open={dialog.refreshKeys}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen("refreshKeys", open);
|
||||
}}
|
||||
/>
|
||||
<RebootOTADialog
|
||||
open={dialog.rebootOTA}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen("rebootOTA", open);
|
||||
}}
|
||||
/>
|
||||
<DeleteMessagesDialog
|
||||
open={dialog.deleteMessages}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen("deleteMessages", open);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { create, fromBinary } from "@bufbuild/protobuf";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { Checkbox } from "@components/UI/Checkbox.tsx";
|
||||
import { Checkbox } from "../UI/Checkbox/index.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -96,6 +97,7 @@ export const ImportDialog = ({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import Channel Set</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -107,7 +109,6 @@ export const ImportDialog = ({
|
||||
<Input
|
||||
value={importDialogInput}
|
||||
suffix={validUrl ? "✅" : "❌"}
|
||||
className="dark:text-slate-900"
|
||||
onChange={(e) => {
|
||||
setImportDialogInput(e.target.value);
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useDevice } from "../../core/stores/deviceStore.ts";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
@@ -20,9 +21,9 @@ export const LocationResponseDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: 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 ??
|
||||
(from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown");
|
||||
const shortName = from?.user?.shortName ??
|
||||
@@ -31,6 +32,7 @@ export const LocationResponseDialog = ({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{`Location: ${longName} (${shortName})`}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx";
|
||||
import { Serial } from "@components/PageComponents/Connect/Serial.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
@@ -52,7 +53,7 @@ const links: { [key: string]: string } = {
|
||||
|
||||
const listFormatter = new Intl.ListFormat("en", {
|
||||
style: "long",
|
||||
type: "conjunction",
|
||||
type: "disjunction",
|
||||
});
|
||||
|
||||
const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
|
||||
@@ -78,16 +79,16 @@ const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Subtle className="flex flex-col items-start gap-2 text-slate-900 bg-red-200/80 p-4 rounded-md">
|
||||
<Subtle className="flex flex-col items-start gap-2 bg-red-500 p-4 rounded-md">
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<AlertCircle size={40} className="mr-2 shrink-0" />
|
||||
<AlertCircle size={40} className="mr-2 shrink-0 text-white" />
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm">
|
||||
<p className="text-sm text-white">
|
||||
{browserFeatures.length > 0 && (
|
||||
<>
|
||||
This application requires{" "}
|
||||
{formatFeatureList(browserFeatures)}. Please use a
|
||||
Chromium-based browser like Chrome or Edge.
|
||||
This connection type requires{" "}
|
||||
{formatFeatureList(browserFeatures)}. Please use a supported
|
||||
browser, like Chrome or Edge.
|
||||
</>
|
||||
)}
|
||||
{needsSecureContext && (
|
||||
@@ -134,7 +135,8 @@ export const NewDeviceDialog = ({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogContent aria-describedby={undefined}>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connect New Device</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -149,10 +151,12 @@ export const NewDeviceDialog = ({
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent key={tab.label} value={tab.label}>
|
||||
<fieldset disabled={tab.isDisabled}>
|
||||
{tab.isDisabled
|
||||
{(tab.label !== "HTTP" && tab.isDisabled)
|
||||
? <ErrorMessage missingFeatures={unsupported} />
|
||||
: null}
|
||||
<tab.element closeDialog={() => onOpenChange(false)} />
|
||||
<tab.element
|
||||
closeDialog={() => onOpenChange(false)}
|
||||
/>
|
||||
</fieldset>
|
||||
</TabsContent>
|
||||
))}
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
import { useAppStore } from "../../core/stores/appStore.ts";
|
||||
import { useDevice } from "../../core/stores/deviceStore.ts";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "../UI/Accordion.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../UI/Dialog.tsx";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { DeviceImage } from "../generic/DeviceImage.tsx";
|
||||
import { TimeAgo } from "../generic/TimeAgo.tsx";
|
||||
import { Uptime } from "../generic/Uptime.tsx";
|
||||
|
||||
export interface NodeDetailsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const NodeDetailsDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: NodeDetailsDialogProps) => {
|
||||
const { nodes } = useDevice();
|
||||
const { nodeNumDetails } = useAppStore();
|
||||
const device: Protobuf.Mesh.NodeInfo = nodes.get(nodeNumDetails);
|
||||
|
||||
return device
|
||||
? (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Node Details for {device.user?.longName ?? "UNKNOWN"} (
|
||||
{device.user?.shortName ?? "UNK"})
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<div className="w-full">
|
||||
<DeviceImage
|
||||
className="w-32 h-32 mx-auto rounded-lg border-4 border-slate-200 dark:border-slate-800"
|
||||
deviceType={Protobuf.Mesh
|
||||
.HardwareModel[device.user?.hwModel ?? 0]}
|
||||
/>
|
||||
<div className="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">
|
||||
Details:
|
||||
</p>
|
||||
<p>
|
||||
Hardware:{" "}
|
||||
{Protobuf.Mesh.HardwareModel[device.user?.hwModel ?? 0]}
|
||||
</p>
|
||||
<p>Node Number: {device.num}</p>
|
||||
<p>Node HEX: !{numberToHexUnpadded(device.num)}</p>
|
||||
<p>
|
||||
Role: {Protobuf.Config.Config_DeviceConfig_Role[
|
||||
device.user?.role ?? 0
|
||||
]}
|
||||
</p>
|
||||
<p>
|
||||
Last Heard: {device.lastHeard === 0
|
||||
? (
|
||||
"Never"
|
||||
)
|
||||
: <TimeAgo timestamp={device.lastHeard * 1000} />}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{device.position
|
||||
? (
|
||||
<div className="mt-5 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">
|
||||
Position:
|
||||
</p>
|
||||
{device.position.latitudeI && device.position.longitudeI
|
||||
? (
|
||||
<p>
|
||||
Coordinates:{" "}
|
||||
<a
|
||||
className="text-blue-500 dark:text-blue-400"
|
||||
href={`https://www.openstreetmap.org/?mlat=${
|
||||
device.position.latitudeI / 1e7
|
||||
}&mlon=${
|
||||
device.position.longitudeI / 1e7
|
||||
}&layers=N`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{device.position.latitudeI / 1e7},{" "}
|
||||
{device.position.longitudeI / 1e7}
|
||||
</a>
|
||||
</p>
|
||||
)
|
||||
: null}
|
||||
{device.position.altitude
|
||||
? <p>Altitude: {device.position.altitude}m</p>
|
||||
: null}
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
|
||||
{device.deviceMetrics
|
||||
? (
|
||||
<div className="mt-5 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">
|
||||
Device Metrics:
|
||||
</p>
|
||||
{device.deviceMetrics.airUtilTx
|
||||
? (
|
||||
<p>
|
||||
Air TX utilization:{" "}
|
||||
{device.deviceMetrics.airUtilTx.toFixed(2)}%
|
||||
</p>
|
||||
)
|
||||
: null}
|
||||
{device.deviceMetrics.channelUtilization
|
||||
? (
|
||||
<p>
|
||||
Channel utilization:{" "}
|
||||
{device.deviceMetrics.channelUtilization.toFixed(2)}%
|
||||
</p>
|
||||
)
|
||||
: null}
|
||||
{device.deviceMetrics.batteryLevel
|
||||
? (
|
||||
<p>
|
||||
Battery level:{" "}
|
||||
{device.deviceMetrics.batteryLevel.toFixed(2)}%
|
||||
</p>
|
||||
)
|
||||
: null}
|
||||
{device.deviceMetrics.voltage
|
||||
? (
|
||||
<p>
|
||||
Voltage: {device.deviceMetrics.voltage.toFixed(2)}V
|
||||
</p>
|
||||
)
|
||||
: null}
|
||||
{device.deviceMetrics.uptimeSeconds
|
||||
? (
|
||||
<p>
|
||||
Uptime:{" "}
|
||||
<Uptime
|
||||
seconds={device.deviceMetrics.uptimeSeconds}
|
||||
/>
|
||||
</p>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
|
||||
{device
|
||||
? (
|
||||
<div className="mt-5 w-full max-w-[464px] bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
|
||||
<Accordion
|
||||
className="AccordionRoot"
|
||||
type="single"
|
||||
collapsible
|
||||
>
|
||||
<AccordionItem className="AccordionItem" value="item-1">
|
||||
<AccordionTrigger>
|
||||
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
|
||||
All Raw Metrics:
|
||||
</p>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="overflow-x-scroll">
|
||||
<pre className="text-xs w-full">
|
||||
{JSON.stringify(device, null, 2)}
|
||||
</pre>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
: null;
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
|
||||
vi.mock("@core/stores/deviceStore", () => {
|
||||
return {
|
||||
useDevice: () => ({
|
||||
setDialogOpen: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("@core/stores/appStore");
|
||||
|
||||
const mockUseAppStore = vi.mocked(useAppStore);
|
||||
|
||||
describe("NodeDetailsDialog", () => {
|
||||
const mockNode = {
|
||||
num: 1234,
|
||||
user: {
|
||||
longName: "Test Node",
|
||||
shortName: "TN",
|
||||
hwModel: 1,
|
||||
role: 1,
|
||||
},
|
||||
lastHeard: 1697500000,
|
||||
position: {
|
||||
latitudeI: 450000000,
|
||||
longitudeI: -750000000,
|
||||
altitude: 200,
|
||||
},
|
||||
deviceMetrics: {
|
||||
airUtilTx: 50.123,
|
||||
channelUtilization: 75.456,
|
||||
batteryLevel: 88.789,
|
||||
voltage: 4.2,
|
||||
uptimeSeconds: 3600,
|
||||
},
|
||||
} as unknown as Protobuf.Mesh.NodeInfo;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
mockUseAppStore.mockReturnValue({
|
||||
nodeNumDetails: 1234,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders node details correctly", () => {
|
||||
render(<NodeDetailsDialog open node={mockNode} onOpenChange={() => {}} />);
|
||||
|
||||
expect(screen.getByText(/Node Details for Test Node \(TN\)/i))
|
||||
.toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("Node Number: 1234")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Node Hex: !/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Last Heard:/i)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/Coordinates:/i)).toBeInTheDocument();
|
||||
const link = screen.getByRole("link", { name: /^45, -75$/ });
|
||||
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute(
|
||||
"href",
|
||||
expect.stringContaining("openstreetmap.org"),
|
||||
);
|
||||
expect(screen.getByText(/Altitude: 200m/i)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/Air TX utilization: 50.12%/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Channel utilization: 75.46%/i))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByText(/Battery level: 88.79%/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Voltage: 4.20V/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Uptime:/i)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/All Raw Metrics:/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders null if node is undefined", () => {
|
||||
const mockNode = undefined;
|
||||
|
||||
const { container } = render(
|
||||
<NodeDetailsDialog open node={mockNode} onOpenChange={() => {}} />,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
expect(screen.queryByText(/Node Details for/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders correctly when position is missing", () => {
|
||||
const nodeWithoutPosition = { ...mockNode, position: undefined };
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
350
src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx
Normal file
@@ -0,0 +1,350 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useAppStore } from "@core/stores/appStore.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 {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@components/UI/Accordion.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import {
|
||||
BellIcon,
|
||||
BellOffIcon,
|
||||
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 {
|
||||
node: Protobuf.Mesh.NodeInfo | undefined;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const NodeDetailsDialog = ({
|
||||
node,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: NodeDetailsDialogProps) => {
|
||||
const { setDialogOpen, connection, setActivePage } = useDevice();
|
||||
const { setNodeNumToBeRemoved } = useAppStore();
|
||||
const { setChatType, setActiveChat } = useMessageStore();
|
||||
|
||||
const { updateFavorite } = useFavoriteNode();
|
||||
const [isFavoriteState, setIsFavoriteState] = useState<boolean>(false);
|
||||
|
||||
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 = [
|
||||
{
|
||||
key: "airUtilTx",
|
||||
label: "Air TX utilization",
|
||||
value: node.deviceMetrics?.airUtilTx,
|
||||
format: (val: number) => `${val.toFixed(2)}%`,
|
||||
},
|
||||
{
|
||||
key: "channelUtilization",
|
||||
label: "Channel utilization",
|
||||
value: node.deviceMetrics?.channelUtilization,
|
||||
format: (val: number) => `${val.toFixed(2)}%`,
|
||||
},
|
||||
{
|
||||
key: "batteryLevel",
|
||||
label: "Battery level",
|
||||
value: node.deviceMetrics?.batteryLevel,
|
||||
format: (val: number) => `${val.toFixed(2)}%`,
|
||||
},
|
||||
{
|
||||
key: "voltage",
|
||||
label: "Voltage",
|
||||
value: node.deviceMetrics?.voltage,
|
||||
format: (val: number) => `${val.toFixed(2)}V`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent aria-describedby={undefined}>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Node Details for {node.user?.longName ?? "UNKNOWN"} (
|
||||
{node.user?.shortName ?? "UNK"})
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<div className="w-full">
|
||||
<div className="flex flex-row flex-wrap space-y-1">
|
||||
<Button className="mr-1" onClick={handleDirectMessage}>
|
||||
<MessageSquareIcon className="mr-2" />
|
||||
Message
|
||||
</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" : "",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
<div className="flex flex-1 justify-start"></div>
|
||||
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className={cn(
|
||||
"flex justify-end mr-1 text-white",
|
||||
isIgnoredState
|
||||
? "bg-red-500 dark:bg-red-500 hover:bg-red-600 hover:dark:bg-red-600 text-white dark:text-white"
|
||||
: "",
|
||||
)}
|
||||
onClick={handleToggleIgnored}
|
||||
>
|
||||
{isIgnoredState ? <BellIcon /> : <BellOffIcon />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
|
||||
{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>
|
||||
|
||||
<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">
|
||||
<p className="text-lg font-semibold">Position:</p>
|
||||
|
||||
{node.position
|
||||
? (
|
||||
<>
|
||||
{node.position.latitudeI &&
|
||||
node.position.longitudeI && (
|
||||
<p>
|
||||
Coordinates:{" "}
|
||||
<a
|
||||
className="text-blue-500 dark:text-blue-400"
|
||||
href={`https://www.openstreetmap.org/?mlat=${
|
||||
node.position.latitudeI / 1e7
|
||||
}&mlon=${node.position.longitudeI / 1e7}&layers=N`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{node.position.latitudeI / 1e7},{" "}
|
||||
{node.position.longitudeI / 1e7}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{node.position.altitude && (
|
||||
<p>Altitude: {node.position.altitude}m</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
: <p>Unknown</p>}
|
||||
<Button onClick={handleRequestPosition} className="mt-2">
|
||||
<MapPinnedIcon className="mr-2" />
|
||||
Request Position
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{node.deviceMetrics && (
|
||||
<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-100">
|
||||
Device Metrics:
|
||||
</p>
|
||||
{deviceMetricsMap.map(
|
||||
(metric) =>
|
||||
metric.value !== undefined && (
|
||||
<p key={metric.key}>
|
||||
{metric.label}: {metric.format(metric.value)}
|
||||
</p>
|
||||
),
|
||||
)}
|
||||
{node.deviceMetrics.uptimeSeconds && (
|
||||
<p>
|
||||
Uptime:{" "}
|
||||
<Uptime seconds={node.deviceMetrics.uptimeSeconds} />
|
||||
</p>
|
||||
)}
|
||||
</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">
|
||||
<Accordion className="AccordionRoot" type="single" collapsible>
|
||||
<AccordionItem className="AccordionItem" value="item-1">
|
||||
<AccordionTrigger>
|
||||
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
|
||||
All Raw Metrics:
|
||||
</p>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="overflow-x-scroll">
|
||||
<pre className="text-xs w-full">
|
||||
{JSON.stringify(node, null, 2)}
|
||||
</pre>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,115 +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,
|
||||
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>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { useDevice } from "../../core/stores/deviceStore.ts";
|
||||
import { Button } from "../UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -102,6 +103,7 @@ export const PkiBackupDialog = ({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Backup Keys</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -9,12 +10,22 @@ import {
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
|
||||
export interface PkiRegenerateDialogProps {
|
||||
text: {
|
||||
title: string;
|
||||
description: string;
|
||||
button: string;
|
||||
};
|
||||
open: boolean;
|
||||
onOpenChange: () => void;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
export const PkiRegenerateDialog = ({
|
||||
text = {
|
||||
title: "Regenerate Key Pair",
|
||||
description: "Are you sure you want to regenerate key pair?",
|
||||
button: "Regenerate",
|
||||
},
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
@@ -22,15 +33,16 @@ export const PkiRegenerateDialog = ({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Regenerate Key pair?</DialogTitle>
|
||||
<DialogTitle>{text?.title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to regenerate key pair?
|
||||
{text?.description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="destructive" onClick={() => onSubmit()}>
|
||||
Regenerate
|
||||
{text?.button}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { create, toBinary } from "@bufbuild/protobuf";
|
||||
import { Checkbox } from "@components/UI/Checkbox.tsx";
|
||||
import { Checkbox } from "../UI/Checkbox/index.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -62,6 +63,7 @@ export const QRDialog = ({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Generate QR Code</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -133,8 +135,8 @@ export const QRDialog = ({
|
||||
<Input
|
||||
value={qrCodeUrl}
|
||||
disabled
|
||||
className="dark:text-slate-900"
|
||||
action={{
|
||||
key: "copy-value",
|
||||
icon: ClipboardIcon,
|
||||
onClick() {
|
||||
void navigator.clipboard.writeText(qrCodeUrl);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
@@ -27,6 +28,7 @@ export const RebootDialog = ({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Schedule Reboot</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
118
src/components/Dialog/RebootOTADialog.test.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { RebootOTADialog } from "./RebootOTADialog.tsx";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
const rebootOtaMock = vi.fn();
|
||||
let mockConnection: { rebootOta: (delay: number) => void } | undefined = {
|
||||
rebootOta: rebootOtaMock,
|
||||
};
|
||||
|
||||
vi.mock("@core/stores/deviceStore.ts", () => ({
|
||||
useDevice: () => ({
|
||||
connection: mockConnection,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@components/UI/Button.tsx", async () => {
|
||||
const actual = await vi.importActual("@components/UI/Button.tsx");
|
||||
return {
|
||||
...actual,
|
||||
Button: (props) => <button {...props} />,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@components/UI/Input.tsx", async () => {
|
||||
const actual = await vi.importActual("@components/UI/Input.tsx");
|
||||
return {
|
||||
...actual,
|
||||
Input: (props) => <input {...props} />,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@components/UI/Dialog.tsx", () => {
|
||||
return {
|
||||
Dialog: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogContent: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DialogHeader: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DialogTitle: ({ children }: { children: ReactNode }) => <h1>{children}</h1>,
|
||||
DialogDescription: ({ children }: { children: ReactNode }) => (
|
||||
<p>{children}</p>
|
||||
),
|
||||
DialogClose: () => null,
|
||||
};
|
||||
});
|
||||
|
||||
describe("RebootOTADialog", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
rebootOtaMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders dialog with default input value", () => {
|
||||
render(<RebootOTADialog open onOpenChange={() => {}} />);
|
||||
expect(screen.getByPlaceholderText(/enter delay/i)).toHaveValue(5);
|
||||
expect(screen.getByText(/schedule reboot/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/reboot to ota mode now/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("schedules a reboot with delay and calls rebootOta", async () => {
|
||||
const onOpenChangeMock = vi.fn();
|
||||
render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/enter delay/i), {
|
||||
target: { value: "3" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText(/schedule reboot/i));
|
||||
|
||||
expect(screen.getByText(/reboot has been scheduled/i)).toBeInTheDocument();
|
||||
|
||||
vi.advanceTimersByTime(3000);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(rebootOtaMock).toHaveBeenCalledWith(0);
|
||||
expect(onOpenChangeMock).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("triggers an instant reboot", async () => {
|
||||
const onOpenChangeMock = vi.fn();
|
||||
render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />);
|
||||
|
||||
fireEvent.click(screen.getByText(/reboot to ota mode now/i));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(rebootOtaMock).toHaveBeenCalledWith(5);
|
||||
expect(onOpenChangeMock).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not call reboot if connection is undefined", async () => {
|
||||
const onOpenChangeMock = vi.fn();
|
||||
|
||||
// simulate no connection
|
||||
mockConnection = undefined;
|
||||
|
||||
render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />);
|
||||
|
||||
fireEvent.click(screen.getByText(/schedule reboot/i));
|
||||
vi.advanceTimersByTime(5000);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(rebootOtaMock).not.toHaveBeenCalled();
|
||||
expect(onOpenChangeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// reset connection for other tests
|
||||
mockConnection = { rebootOta: rebootOtaMock };
|
||||
});
|
||||
});
|
||||
106
src/components/Dialog/RebootOTADialog.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useState } from "react";
|
||||
import { ClockIcon, RefreshCwIcon } from "lucide-react";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
|
||||
export interface RebootOTADialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_REBOOT_DELAY = 5; // seconds
|
||||
|
||||
export const RebootOTADialog = (
|
||||
{ open, onOpenChange }: RebootOTADialogProps,
|
||||
) => {
|
||||
const { connection } = useDevice();
|
||||
const [time, setTime] = useState<number>(DEFAULT_REBOOT_DELAY);
|
||||
const [isScheduled, setIsScheduled] = useState(false);
|
||||
const [inputValue, setInputValue] = useState(DEFAULT_REBOOT_DELAY.toString());
|
||||
|
||||
const handleSetTime = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!e.target.validity.valid) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const val = e.target.value;
|
||||
setInputValue(val);
|
||||
|
||||
const parsed = Number(val);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
setTime(parsed);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRebootWithTimeout = async () => {
|
||||
if (!connection) return;
|
||||
setIsScheduled(true);
|
||||
|
||||
const delay = time > 0 ? time : DEFAULT_REBOOT_DELAY;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
console.log("Rebooting...");
|
||||
resolve();
|
||||
}, delay * 1000);
|
||||
}).finally(() => {
|
||||
setIsScheduled(false);
|
||||
onOpenChange(false);
|
||||
setInputValue(DEFAULT_REBOOT_DELAY.toString());
|
||||
});
|
||||
connection.rebootOta(0);
|
||||
};
|
||||
|
||||
const handleInstantReboot = async () => {
|
||||
if (!connection) return;
|
||||
|
||||
await connection.rebootOta(DEFAULT_REBOOT_DELAY);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reboot to OTA Mode</DialogTitle>
|
||||
<DialogDescription>
|
||||
Reboot the connected node after a delay into OTA (Over-the-Air)
|
||||
mode.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex gap-2 p-2 items-center relative">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={86400}
|
||||
className="dark:text-slate-900 appearance-none"
|
||||
value={inputValue}
|
||||
onChange={handleSetTime}
|
||||
placeholder="Enter delay (sec)"
|
||||
/>
|
||||
<Button onClick={() => handleRebootWithTimeout()} className="w-9/12">
|
||||
<ClockIcon className="mr-2" size={18} />
|
||||
{isScheduled ? "Reboot has been scheduled" : "Schedule Reboot"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button variant="destructive" onClick={() => handleInstantReboot()}>
|
||||
<RefreshCwIcon className="mr-2" size={16} />
|
||||
Reboot to OTA Mode Now
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { DeviceContext, useDeviceStore } from "@core/stores/deviceStore.ts";
|
||||
import { RefreshKeysDialog } from "./RefreshKeysDialog.tsx";
|
||||
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
||||
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("@core/stores/messageStore");
|
||||
vi.mock("./useRefreshKeysDialog");
|
||||
|
||||
const mockUseMessageStore = vi.mocked(useMessageStore);
|
||||
const mockUseRefreshKeysDialog = vi.mocked(useRefreshKeysDialog);
|
||||
|
||||
const getInitialState = () =>
|
||||
useDeviceStore.getInitialState?.() ??
|
||||
{ devices: new Map(), remoteDevices: new Map() };
|
||||
|
||||
beforeEach(() => {
|
||||
useDeviceStore.setState(getInitialState(), true);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("does not render dialog if no error exists for active chat", () => {
|
||||
const deviceId = 1;
|
||||
const activeChatNum = 54321;
|
||||
|
||||
useDeviceStore.getState().addDevice(deviceId);
|
||||
|
||||
const currentDeviceState = useDeviceStore.getState().getDevice(deviceId);
|
||||
if (!currentDeviceState) throw new Error("Device not found");
|
||||
|
||||
mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum });
|
||||
mockUseRefreshKeysDialog.mockReturnValue({
|
||||
handleCloseDialog: vi.fn(),
|
||||
handleNodeRemove: vi.fn(),
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<DeviceContext.Provider value={currentDeviceState}>
|
||||
<RefreshKeysDialog open onOpenChange={vi.fn()} />
|
||||
</DeviceContext.Provider>,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { LockKeyholeOpenIcon } from "lucide-react";
|
||||
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
||||
|
||||
export interface RefreshKeysDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const RefreshKeysDialog = (
|
||||
{ open, onOpenChange }: RefreshKeysDialogProps,
|
||||
) => {
|
||||
const { activeChat } = useMessageStore();
|
||||
const { nodeErrors, getNode } = useDevice();
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-8 flex flex-col gap-2"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
<DialogClose onClick={handleCloseDialog} />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{text.title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{text.description}
|
||||
<ul className="mt-2">
|
||||
<li className="flex place-items-center gap-2 items-start">
|
||||
<div className="p-2 bg-slate-500 rounded-lg mt-1">
|
||||
<LockKeyholeOpenIcon
|
||||
size={30}
|
||||
className="text-white justify-center"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
<p className="font-bold mb-0.5">Accept New Keys</p>
|
||||
<p>
|
||||
This will remove the node from device and request new keys.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleNodeRemove}
|
||||
>
|
||||
Request New Keys
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCloseDialog}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
|
||||
import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { useMessageStore } from "@core/stores/messageStore/index.ts";
|
||||
|
||||
vi.mock("@core/stores/messageStore", () => ({
|
||||
useMessageStore: vi.fn(() => ({ activeChat: "chat-123" })),
|
||||
}));
|
||||
vi.mock("@core/stores/deviceStore", () => ({
|
||||
useDevice: vi.fn(() => ({
|
||||
removeNode: vi.fn(),
|
||||
setDialogOpen: vi.fn(),
|
||||
getNodeError: vi.fn(),
|
||||
clearNodeError: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("useRefreshKeysDialog Hook", () => {
|
||||
let removeNodeMock: Mock;
|
||||
let setDialogOpenMock: Mock;
|
||||
let getNodeErrorMock: Mock;
|
||||
let clearNodeErrorMock: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
removeNodeMock = vi.fn();
|
||||
setDialogOpenMock = vi.fn();
|
||||
getNodeErrorMock = vi.fn().mockReturnValue(undefined);
|
||||
clearNodeErrorMock = vi.fn();
|
||||
|
||||
vi.mocked(useDevice).mockReturnValue({
|
||||
removeNode: removeNodeMock,
|
||||
setDialogOpen: setDialogOpenMock,
|
||||
getNodeError: getNodeErrorMock,
|
||||
clearNodeError: clearNodeErrorMock,
|
||||
});
|
||||
|
||||
vi.mocked(useMessageStore).mockReturnValue({
|
||||
activeChat: "chat-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("handleNodeRemove should remove the node and update dialog if there is an error", () => {
|
||||
getNodeErrorMock.mockReturnValue({ node: "node-abc" });
|
||||
|
||||
const { result } = renderHook(() => useRefreshKeysDialog());
|
||||
act(() => {
|
||||
result.current.handleNodeRemove();
|
||||
});
|
||||
|
||||
expect(getNodeErrorMock).toHaveBeenCalledTimes(1);
|
||||
expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123");
|
||||
expect(clearNodeErrorMock).toHaveBeenCalledTimes(1);
|
||||
expect(clearNodeErrorMock).toHaveBeenCalledWith("chat-123");
|
||||
expect(removeNodeMock).toHaveBeenCalledTimes(1);
|
||||
expect(removeNodeMock).toHaveBeenCalledWith("node-abc");
|
||||
expect(setDialogOpenMock).toHaveBeenCalledTimes(1);
|
||||
expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false);
|
||||
});
|
||||
|
||||
it("handleNodeRemove should do nothing if there is no error", () => {
|
||||
const { result } = renderHook(() => useRefreshKeysDialog());
|
||||
act(() => {
|
||||
result.current.handleNodeRemove();
|
||||
});
|
||||
|
||||
expect(getNodeErrorMock).toHaveBeenCalledTimes(1);
|
||||
expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123");
|
||||
expect(clearNodeErrorMock).not.toHaveBeenCalled();
|
||||
expect(removeNodeMock).not.toHaveBeenCalled();
|
||||
expect(setDialogOpenMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handleCloseDialog should close the dialog", () => {
|
||||
const { result } = renderHook(() => useRefreshKeysDialog());
|
||||
|
||||
act(() => {
|
||||
result.current.handleCloseDialog();
|
||||
});
|
||||
|
||||
expect(setDialogOpenMock).toHaveBeenCalledTimes(1);
|
||||
expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useCallback } from "react";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { useMessageStore } from "@core/stores/messageStore/index.ts";
|
||||
|
||||
export function useRefreshKeysDialog() {
|
||||
const { removeNode, setDialogOpen, clearNodeError, getNodeError } =
|
||||
useDevice();
|
||||
const { activeChat } = useMessageStore();
|
||||
|
||||
const handleCloseDialog = useCallback(() => {
|
||||
setDialogOpen("refreshKeys", false);
|
||||
}, [setDialogOpen]);
|
||||
|
||||
const handleNodeRemove = useCallback(() => {
|
||||
const nodeWithError = getNodeError(activeChat);
|
||||
if (!nodeWithError) {
|
||||
return;
|
||||
}
|
||||
clearNodeError(activeChat);
|
||||
handleCloseDialog();
|
||||
return removeNode(nodeWithError?.node);
|
||||
}, [activeChat, clearNodeError, getNodeError, removeNode, handleCloseDialog]);
|
||||
|
||||
return {
|
||||
handleCloseDialog,
|
||||
handleNodeRemove,
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -20,7 +21,7 @@ export const RemoveNodeDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: RemoveNodeDialogProps) => {
|
||||
const { connection, nodes, removeNode } = useDevice();
|
||||
const { connection, getNode, removeNode } = useDevice();
|
||||
const { nodeNumToBeRemoved } = useAppStore();
|
||||
|
||||
const onSubmit = () => {
|
||||
@@ -32,6 +33,7 @@ export const RemoveNodeDialog = ({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove Node?</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -40,7 +42,7 @@ export const RemoveNodeDialog = ({
|
||||
</DialogHeader>
|
||||
<div className="gap-4">
|
||||
<form onSubmit={onSubmit}>
|
||||
<Label>{nodes.get(nodeNumToBeRemoved)?.user?.longName}</Label>
|
||||
<Label>{getNode(nodeNumToBeRemoved)?.user?.longName}</Label>
|
||||
</form>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
@@ -27,6 +28,7 @@ export const ShutdownDialog = ({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Schedule Shutdown</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -39,7 +41,6 @@ export const ShutdownDialog = ({
|
||||
type="number"
|
||||
value={time}
|
||||
onChange={(e) => setTime(Number.parseInt(e.target.value))}
|
||||
className="dark:text-slate-900"
|
||||
suffix="Minutes"
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useDevice } from "../../core/stores/deviceStore.ts";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
@@ -22,20 +23,21 @@ export const TracerouteResponseDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: TracerouteResponseDialogProps) => {
|
||||
const { nodes } = useDevice();
|
||||
const { getNode } = useDevice();
|
||||
const route: number[] = traceroute?.data.route ?? [];
|
||||
const routeBack: number[] = traceroute?.data.routeBack ?? [];
|
||||
const snrTowards = traceroute?.data.snrTowards ?? [];
|
||||
const snrBack = traceroute?.data.snrBack ?? [];
|
||||
const from = nodes.get(traceroute?.from ?? 0);
|
||||
const snrTowards = (traceroute?.data.snrTowards ?? []).map((snr) => snr / 4);
|
||||
const snrBack = (traceroute?.data.snrBack ?? []).map((snr) => snr / 4);
|
||||
const from = getNode(traceroute?.from ?? 0);
|
||||
const longName = from?.user?.longName ??
|
||||
(from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown");
|
||||
const shortName = from?.user?.shortName ??
|
||||
(from ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : "UNK");
|
||||
const to = nodes.get(traceroute?.to ?? 0);
|
||||
const to = getNode(traceroute?.to ?? 0);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{`Traceroute: ${longName} (${shortName})`}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// deno-lint-ignore-file
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
|
||||
import { eventBus } from "@core/utils/eventBus.ts";
|
||||
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
|
||||
|
||||
describe("UnsafeRolesDialog", () => {
|
||||
const mockDevice = {
|
||||
setDialogOpen: vi.fn(),
|
||||
};
|
||||
|
||||
const renderWithDeviceContext = (ui: React.ReactNode) => {
|
||||
return render(
|
||||
<DeviceWrapper device={mockDevice}>
|
||||
{ui}
|
||||
</DeviceWrapper>,
|
||||
);
|
||||
};
|
||||
|
||||
it("renders the dialog when open is true", () => {
|
||||
renderWithDeviceContext(
|
||||
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/I have read the/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/understand the implications/i))
|
||||
.toBeInTheDocument();
|
||||
|
||||
const links = screen.getAllByRole("link");
|
||||
expect(links).toHaveLength(2);
|
||||
expect(links[0]).toHaveTextContent("Device Role Documentation");
|
||||
expect(links[1]).toHaveTextContent("Choosing The Right Device Role");
|
||||
});
|
||||
|
||||
it("displays the correct links", () => {
|
||||
renderWithDeviceContext(
|
||||
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
|
||||
);
|
||||
|
||||
const docLink = screen.getByRole("link", {
|
||||
name: /Device Role Documentation/i,
|
||||
});
|
||||
const blogLink = screen.getByRole("link", {
|
||||
name: /Choosing The Right Device Role/i,
|
||||
});
|
||||
|
||||
expect(docLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://meshtastic.org/docs/configuration/radio/device/",
|
||||
);
|
||||
expect(blogLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://meshtastic.org/blog/choosing-the-right-device-role/",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not allow confirmation until checkbox is checked", () => {
|
||||
renderWithDeviceContext(
|
||||
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
|
||||
);
|
||||
|
||||
const confirmButton = screen.getByRole("button", { name: /confirm/i });
|
||||
|
||||
expect(confirmButton).toBeDisabled();
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(confirmButton).toBeEnabled();
|
||||
});
|
||||
|
||||
it("emits the correct event when closing via close button", () => {
|
||||
const eventSpy = vi.spyOn(eventBus, "emit");
|
||||
renderWithDeviceContext(
|
||||
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
|
||||
);
|
||||
|
||||
const dismissButton = screen.getByRole("button", { name: /close/i });
|
||||
fireEvent.click(dismissButton);
|
||||
|
||||
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", {
|
||||
action: "dismiss",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits the correct event when dismissing", () => {
|
||||
const eventSpy = vi.spyOn(eventBus, "emit");
|
||||
renderWithDeviceContext(
|
||||
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
|
||||
);
|
||||
|
||||
const dismissButton = screen.getByRole("button", { name: /dismiss/i });
|
||||
fireEvent.click(dismissButton);
|
||||
|
||||
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", {
|
||||
action: "dismiss",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits the correct event when confirming", () => {
|
||||
const eventSpy = vi.spyOn(eventBus, "emit");
|
||||
renderWithDeviceContext(
|
||||
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
|
||||
);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
const confirmButton = screen.getByRole("button", { name: /confirm/i });
|
||||
|
||||
fireEvent.click(checkbox);
|
||||
fireEvent.click(confirmButton);
|
||||
|
||||
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", {
|
||||
action: "confirm",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Link } from "@components/UI/Typography/Link.tsx";
|
||||
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { useState } from "react";
|
||||
import { eventBus } from "@core/utils/eventBus.ts";
|
||||
|
||||
export interface RouterRoleDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const UnsafeRolesDialog = (
|
||||
{ open, onOpenChange }: RouterRoleDialogProps,
|
||||
) => {
|
||||
const [confirmState, setConfirmState] = useState(false);
|
||||
const { setDialogOpen } = useDevice();
|
||||
|
||||
const deviceRoleLink =
|
||||
"https://meshtastic.org/docs/configuration/radio/device/";
|
||||
const choosingTheRightDeviceRoleLink =
|
||||
"https://meshtastic.org/blog/choosing-the-right-device-role/";
|
||||
|
||||
const handleCloseDialog = (action: "confirm" | "dismiss") => {
|
||||
setDialogOpen("unsafeRoles", false);
|
||||
setConfirmState(false);
|
||||
eventBus.emit("dialog:unsafeRoles", { action });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-8 flex flex-col">
|
||||
<DialogClose onClick={() => handleCloseDialog("dismiss")} />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you sure?</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription className="text-md">
|
||||
I have read the{" "}
|
||||
<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>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="routerRole"
|
||||
checked={confirmState}
|
||||
onChange={() => setConfirmState(!confirmState)}
|
||||
>
|
||||
Yes, I know what I'm doing
|
||||
</Checkbox>
|
||||
</div>
|
||||
<DialogFooter className="mt-6">
|
||||
<Button
|
||||
variant="default"
|
||||
name="dismiss"
|
||||
onClick={() => handleCloseDialog("dismiss")}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
name="confirm"
|
||||
disabled={!confirmState}
|
||||
onClick={() => handleCloseDialog("confirm")}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, Mock, vi } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import {
|
||||
UNSAFE_ROLES,
|
||||
useUnsafeRolesDialog,
|
||||
} from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
|
||||
import { eventBus } from "@core/utils/eventBus.ts";
|
||||
|
||||
vi.mock("@core/utils/eventBus", () => ({
|
||||
eventBus: {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockDevice = {
|
||||
setDialogOpen: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@core/stores/deviceStore", () => ({
|
||||
useDevice: () => ({
|
||||
setDialogOpen: mockDevice.setDialogOpen,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("useUnsafeRolesDialog", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderUnsafeRolesHook = () => {
|
||||
return renderHook(() => useUnsafeRolesDialog());
|
||||
};
|
||||
|
||||
describe("handleCloseDialog", () => {
|
||||
it("should call setDialogOpen with correct parameters when dialog is closed", () => {
|
||||
const { result } = renderUnsafeRolesHook();
|
||||
|
||||
result.current.handleCloseDialog();
|
||||
|
||||
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith(
|
||||
"unsafeRoles",
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateRoleSelection", () => {
|
||||
it("should resolve with true for safe roles without opening dialog", async () => {
|
||||
const { result } = renderUnsafeRolesHook();
|
||||
const safeRole = "SAFE_ROLE";
|
||||
|
||||
const validationResult = await result.current.validateRoleSelection(
|
||||
safeRole,
|
||||
);
|
||||
|
||||
expect(validationResult).toBe(true);
|
||||
expect(mockDevice.setDialogOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should open dialog for unsafe roles and resolve with true when confirmed", async () => {
|
||||
const { result } = renderUnsafeRolesHook();
|
||||
|
||||
const validationPromise = result.current.validateRoleSelection(
|
||||
UNSAFE_ROLES[0],
|
||||
);
|
||||
|
||||
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith(
|
||||
"unsafeRoles",
|
||||
true,
|
||||
);
|
||||
expect(eventBus.on).toHaveBeenCalledWith(
|
||||
"dialog:unsafeRoles",
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
const onHandler = (eventBus.on as Mock).mock.calls[0][1];
|
||||
onHandler({ action: "confirm" });
|
||||
const validationResult = await validationPromise;
|
||||
|
||||
expect(validationResult).toBe(true);
|
||||
expect(eventBus.off).toHaveBeenCalledWith(
|
||||
"dialog:unsafeRoles",
|
||||
onHandler,
|
||||
);
|
||||
});
|
||||
|
||||
it("should resolve with false when user dismisses the dialog", async () => {
|
||||
const { result } = renderUnsafeRolesHook();
|
||||
const validationPromise = result.current.validateRoleSelection(
|
||||
UNSAFE_ROLES[0],
|
||||
);
|
||||
const onHandler = (eventBus.on as Mock).mock.calls[0][1];
|
||||
onHandler({ action: "dismiss" });
|
||||
|
||||
const validationResult = await validationPromise;
|
||||
expect(validationResult).toBe(false);
|
||||
expect(eventBus.off).toHaveBeenCalledWith(
|
||||
"dialog:unsafeRoles",
|
||||
onHandler,
|
||||
);
|
||||
});
|
||||
|
||||
it("should clean up event listener after response", async () => {
|
||||
const { result } = renderUnsafeRolesHook();
|
||||
|
||||
const validationPromise = result.current.validateRoleSelection(
|
||||
UNSAFE_ROLES[1],
|
||||
);
|
||||
const onHandler = (eventBus.on as Mock).mock.calls[0][1];
|
||||
|
||||
onHandler({ action: "confirm" });
|
||||
await validationPromise;
|
||||
|
||||
expect(eventBus.off).toHaveBeenCalledWith(
|
||||
"dialog:unsafeRoles",
|
||||
onHandler,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should work with all unsafe roles", async () => {
|
||||
const { result } = renderUnsafeRolesHook();
|
||||
|
||||
for (const unsafeRole of UNSAFE_ROLES) {
|
||||
mockDevice.setDialogOpen.mockClear();
|
||||
(eventBus.on as Mock).mockClear();
|
||||
|
||||
const validationPromise = result.current.validateRoleSelection(
|
||||
unsafeRole,
|
||||
);
|
||||
|
||||
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith(
|
||||
"unsafeRoles",
|
||||
true,
|
||||
);
|
||||
|
||||
const onHandler = (eventBus.on as Mock).mock.calls[0][1];
|
||||
onHandler({ action: "confirm" });
|
||||
|
||||
const validationResult = await validationPromise;
|
||||
|
||||
expect(validationResult).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useCallback } from "react";
|
||||
import { eventBus } from "@core/utils/eventBus.ts";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
|
||||
export const UNSAFE_ROLES = ["ROUTER", "REPEATER"];
|
||||
export type UnsafeRole = typeof UNSAFE_ROLES[number];
|
||||
|
||||
export const useUnsafeRolesDialog = () => {
|
||||
const { setDialogOpen } = useDevice();
|
||||
|
||||
const handleCloseDialog = useCallback(() => {
|
||||
setDialogOpen("unsafeRoles", false);
|
||||
}, [setDialogOpen]);
|
||||
|
||||
const validateRoleSelection = useCallback(
|
||||
(newRoleKey: string): Promise<boolean> => {
|
||||
if (!UNSAFE_ROLES.includes(newRoleKey as UnsafeRole)) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
setDialogOpen("unsafeRoles", true);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const handleResponse = (
|
||||
{ action }: { action: "confirm" | "dismiss" },
|
||||
) => {
|
||||
eventBus.off("dialog:unsafeRoles", handleResponse);
|
||||
resolve(action === "confirm");
|
||||
};
|
||||
|
||||
eventBus.on("dialog:unsafeRoles", handleResponse);
|
||||
});
|
||||
},
|
||||
[setDialogOpen],
|
||||
);
|
||||
|
||||
return {
|
||||
handleCloseDialog,
|
||||
validateRoleSelection,
|
||||
};
|
||||
};
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
} from "react-hook-form";
|
||||
import { Heading } from "@components/UI/Typography/Heading.tsx";
|
||||
|
||||
|
||||
interface DisabledBy<T> {
|
||||
fieldName: Path<T>;
|
||||
selector?: number;
|
||||
@@ -124,7 +123,9 @@ export function DynamicForm<T extends FieldValues>({
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{hasSubmitButton && <Button type="submit">Submit</Button>}
|
||||
{hasSubmitButton && (
|
||||
<Button type="submit" variant="outline">Submit</Button>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,11 +3,9 @@ import type {
|
||||
GenericFormElementProps,
|
||||
} from "@components/Form/DynamicForm.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 { useState } from "react";
|
||||
import { Controller, type FieldValues } from "react-hook-form";
|
||||
import { type FieldValues, useController } from "react-hook-form";
|
||||
|
||||
export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||
type: "text" | "number" | "password";
|
||||
@@ -17,10 +15,15 @@ export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
step?: number;
|
||||
action?: {
|
||||
icon: LucideIcon;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
fieldLength?: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
currentValueLength?: number;
|
||||
showCharacterCount?: boolean;
|
||||
};
|
||||
showPasswordToggle?: boolean;
|
||||
showCopyButton?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,42 +32,59 @@ export function GenericInput<T extends FieldValues>({
|
||||
disabled,
|
||||
field,
|
||||
}: GenericFormElementProps<T, InputFieldProps<T>>) {
|
||||
const [passwordShown, setPasswordShown] = useState(false);
|
||||
const togglePasswordVisiblity = () => {
|
||||
setPasswordShown(!passwordShown);
|
||||
const { fieldLength, ...restProperties } = field.properties || {};
|
||||
const [currentLength, setCurrentLength] = useState<number>(
|
||||
fieldLength?.currentValueLength || 0,
|
||||
);
|
||||
|
||||
const { field: controllerField } = useController({
|
||||
name: field.name,
|
||||
control,
|
||||
});
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
|
||||
if (
|
||||
field.properties?.fieldLength?.max &&
|
||||
newValue.length > field.properties?.fieldLength?.max
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setCurrentLength(newValue.length);
|
||||
|
||||
if (field.inputChange) field.inputChange(e);
|
||||
|
||||
controllerField.onChange(
|
||||
field.type === "number"
|
||||
? Number.parseFloat(newValue).toString()
|
||||
: newValue,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
render={({ field: { value, onChange, ...rest } }) => (
|
||||
<Input
|
||||
type={field.type === "password" && passwordShown
|
||||
? "text"
|
||||
: field.type}
|
||||
action={field.type === "password"
|
||||
? {
|
||||
icon: passwordShown ? EyeOff : Eye,
|
||||
onClick: togglePasswordVisiblity,
|
||||
}
|
||||
: undefined}
|
||||
step={field.properties?.step}
|
||||
value={field.type === "number" ? Number.parseFloat(value) : value}
|
||||
id={field.name}
|
||||
onChange={(e) => {
|
||||
if (field.inputChange) field.inputChange(e);
|
||||
onChange(
|
||||
field.type === "number"
|
||||
? Number.parseFloat(e.target.value)
|
||||
: e.target.value,
|
||||
);
|
||||
}}
|
||||
{...field.properties}
|
||||
{...rest}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
type={field.type}
|
||||
step={field.properties?.step}
|
||||
value={field.type === "number"
|
||||
? String(controllerField.value)
|
||||
: controllerField.value}
|
||||
id={field.name}
|
||||
onChange={handleInputChange}
|
||||
showCopyButton={field.properties?.showCopyButton}
|
||||
showPasswordToggle={field.properties?.showPasswordToggle ||
|
||||
field.type === "password"}
|
||||
className={field.properties?.className}
|
||||
{...restProperties}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{fieldLength?.showCharacterCount && fieldLength?.max && (
|
||||
<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}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||