129 Commits
Author SHA1 Message Date
philon-andGitHub 513a285fee Feature: Node favourites (and ignores) (#618)
* Add favourite icon to node Avatar

* Favourites WIP

* Save isFavorite and isIgnored to device

* Fix spelling

* Clean up

* Always sort favorites first

* Add unread count to "Messages" top level menu

* Renaming, UI tweaks

* Add hook tests

* Handle undefined node better
2025-05-13 08:45:24 -04:00
philon-andGitHub b2bb3baa01 Add check for undefined hopsAway (#616)
* Add check for undefined hopsAway

Better UI for already "fixed" #351

* Change conditional
2025-05-12 11:54:06 -04:00
Aria StewartandGitHub 3f933dd166 Remove case-duplicated sidebarButton.tsx (#617) 2025-05-11 20:01:43 -04:00
Dan DitomasoandGitHub 1180b9afb0 fix: moved all svgs into public / dist folder (#615) 2025-05-09 20:57:38 -04:00
philon-andGitHub b4ce6efd7b Fix node list sorting, dark mode text colors (#613)
* Fix node list sorting, dark mode text colors

* Update tests

* Remove unused import

* Fix warnings for missing DialogDescription
2025-05-08 12:54:18 -04:00
Dan DitomasoandGitHub da0ada925f fix: removed overly complex scrolling logic in channel chat (#612) 2025-05-06 12:40:07 -04:00
Dan DitomasoandGitHub f9346931f8 update readme with deployment schedule (#611) 2025-05-06 10:10:24 -04:00
Dan DitomasoandGitHub 1d18abf6c1 fix: connect new node no longer crashes. (#610) 2025-05-06 10:08:42 -04:00
Dan DitomasoandGitHub a644d30228 Add github action to release to stable (#590)
* feat: added github action workflow to release to stable

* updated trigger logic
2025-05-05 13:20:49 -04:00
Dan DitomasoandGitHub 1c453e2981 fix: couple of UI updates, fix: add pre release support to release workflow (#609) 2025-05-05 11:47:36 -04:00
philon-andGitHub 170abfcc2f fix: Adjust table odd/even colors (#606) 2025-05-05 11:23:37 -04:00
philon-andGitHub 0539b15ddc Fix #607 (#608) 2025-05-04 20:08:16 -04:00
Dan DitomasoandGitHub bbadb1a917 chore: optimize deno workflow for CI/CD (#605)
* chore: optimize deno workflow for CI/CD

* add install step
2025-05-04 16:39:19 -04:00
480ca46a95 chore: lint/format all files (#604)
* chore: lint/format all files

* Fix config sidebar button state (#602)

* chore: Update deno.lock version and add Radix UI slider component (#601)

* fix: improve how table addresses even/odd rows

---------

Co-authored-by: philon- <philon-@users.noreply.github.com>
Co-authored-by: Kamil Dzieniszewski <kamil.dzieniszewski@gmail.com>
2025-05-04 15:55:04 -04:00
Kamil DzieniszewskiandGitHub b7bdb1a502 chore: Update deno.lock version and add Radix UI slider component (#601) 2025-05-04 15:29:34 -04:00
philon-andGitHub 3be528d03a Fix config sidebar button state (#602) 2025-05-04 15:28:47 -04:00
Dan DitomasoandGitHub ebc386cfa5 Merge pull request #599 from danditomaso/fix/update-telemetry-labels
fix: updated telemetry field labels
2025-05-04 11:50:58 -04:00
Dan Ditomaso 35f3a347ba fix: updated telemtry field labels 2025-05-03 21:27:26 -04:00
Dan DitomasoandGitHub cfcc9f82d8 Merge pull request #596 from philon-/style-fixes
Minor style fixes
2025-05-03 13:28:22 -04:00
Dan DitomasoandGitHub 7ab4254cd0 Merge pull request #597 from danditomaso/fix/content-overflow-issue
fix: defined height to html/body elements
2025-05-03 13:27:54 -04:00
Dan Ditomaso e3857e61c6 fix: defined height to html/body elements 2025-05-03 13:24:00 -04:00
philon- a02579d6dd Minor style fixes 2025-05-03 00:27:12 +02:00
Dan DitomasoandGitHub 79910dfec7 fix: removed console logs in code (#592) 2025-05-02 11:48:18 -04:00
AustinandGitHub b40540b118 Fix upload build.tar (#591) 2025-05-02 10:57:17 -04:00
Dan DitomasoandGitHub 99711fc44e Remove duplicate node logic, UI Update, flatten build output (#586)
* refactor nodes to getNodes fn. ui updates

* fixed several styling issues

* fix: message specific styling/overflow

* added footer, fixed tests. styling

* fix: added theme support back to app component

* fix: hide emojis/reactions

* fix: added more padding to content element

* fix: fixed padding in content element

* updated color scheme

* fix: more dark mode styling improvements

* fix: padding and alignment fixes

* fix: prevent left sidebar collapse, added battery component

* fix: change scrollbars to "tiny" style, improved message scrolling, fixed bug with message input

* message store fixes, ui fixes

* fix: disabled message persistance until after release
2025-05-02 09:00:24 -04:00
Dan DitomasoandGitHub 3eafad7261 Merge pull request #585 from philon-/feature/map-filtering
Map filtering improvements
2025-04-29 15:29:06 -04:00
Jeremy Gallant 80905d9d29 Change colors to slate 2025-04-29 15:35:34 +02:00
Jeremy Gallant 34db0da87c Add map filter groups / more filters / update UI 2025-04-28 17:38:58 +02:00
Jeremy Gallant ff33554716 Add hover: and active: styling 2025-04-24 20:30:31 +02:00
Jeremy Gallant f399d17721 Added map filter indication
Added map filter indication
+ some more type fixes...
2025-04-24 20:12:41 +02:00
Dan DitomasoandGitHub ce71f22316 Merge pull request #580 from philon-/feature/map-filtering
Add filters to node map
2025-04-24 08:52:55 -04:00
Dan DitomasoandGitHub 1f3f76373d Merge pull request #583 from danditomaso/fix-remove-assets-output-dir
fix: keep js and css assets in same output directory
2025-04-24 07:53:39 -04:00
Dan Ditomaso c5fe2f5e68 fix: dont put js and css assets in sub-directory in output 2025-04-23 23:12:54 -04:00
Dan DitomasoandGitHub c050998f3d Merge pull request #582 from meshtastic/danditomaso-patch-1
Update ci.yml
2025-04-23 15:48:37 -04:00
Dan DitomasoandGitHub 4802a8f6e6 Update ci.yml 2025-04-23 14:14:00 -04:00
Jeremy Gallant 03e516e568 Slider - additional props 2025-04-23 16:41:20 +02:00
Jeremy Gallant ef37397969 Remove ! 2025-04-23 07:10:04 +02:00
Jeremy Gallant c6d122008b Unique id 2025-04-22 23:26:49 +02:00
Jeremy Gallant 3dce031f8e Stricter typing, adjuster colors, mandatory props 2025-04-22 23:18:26 +02:00
philon-andGitHub 91d8776637 Merge branch 'meshtastic:master' into feature/map-filtering 2025-04-22 20:28:05 +02:00
Dan DitomasoandGitHub 01f242b7c3 Merge pull request #577 from philon-/fix/495
Fix #495 - Rescale traceroute SNR
2025-04-22 14:09:45 -04:00
Jeremy Gallant d5cf71c840 Add filters to node map 2025-04-22 19:04:42 +02:00
Jeremy Gallant 5ba70d9764 Fix #495 - Rescale traceroute SNR 2025-04-19 22:14:03 +02:00
Dan DitomasoandGitHub 673476d773 Merge pull request #573 from danditomaso/feat/add-copy-text-to-input
Add Copy to Clipboard option for input fields.
2025-04-13 17:09:40 -04:00
Dan DitomasoandGitHub 5eb9fda015 Merge pull request #574 from danditomaso/issue-557-transparent-background
fix: removed transparent toast background
2025-04-13 11:39:28 -04:00
Dan Ditomaso 81586caea0 fix: removed transparent toast background 2025-04-13 08:07:17 -04:00
Dan Ditomaso a195126df1 fixed when grid collapose's to single column 2025-04-12 22:05:42 -04:00
Dan Ditomaso b3783bab40 added longer field length 2025-04-12 21:59:58 -04:00
Dan DitomasoandGitHub 33d0f93e68 Merge branch 'master' into feat/add-copy-text-to-input 2025-04-12 21:36:11 -04:00
Dan Ditomaso 2050b05d6a feat: add copy text option to input fields. 2025-04-12 21:11:52 -04:00
Dan DitomasoandGitHub 38754b9d1a Merge pull request #572 from danditomaso/fix/improve-responsiveness
Improve form responsiveness
2025-04-12 20:26:44 -04:00
Dan Ditomaso 1867484032 fix: improve-responsiveness 2025-04-12 12:46:01 -04:00
Dan DitomasoandGitHub b52ed19649 Merge pull request #571 from danditomaso/fix/improve-refresh-keys-dialog
fix: improvements to refresh dialog
2025-04-10 23:09:41 -04:00
Dan Ditomaso d53ababf7d fix: improvements to refresh dialog 2025-04-10 16:51:29 -04:00
Hunter ThornsberryandGitHub ff43763721 Merge pull request #568 from Hunter275/clock-settings 2025-04-10 13:30:52 -04:00
Hunter ThornsberryandGitHub c44d7633f2 Merge pull request #570 from danditomaso/fix/minus-node-num 2025-04-10 13:30:09 -04:00
Dan Ditomaso 08d641eb42 fix: ensured node number on loading screen cant be negitive. 2025-04-10 12:19:15 -04:00
Hunter275 a243a044b9 add use12hClock support 2025-04-10 01:48:32 -04:00
Dan DitomasoandGitHub c95a819eaf Merge pull request #566 from danditomaso/issue-564-multiple-connect-clicks-on-connect-dialog
Multiple clicks on "Connect" button, creates multiple nodes.
2025-04-08 08:20:36 -04:00
Dan Ditomaso ce5ae675ea fix: hoisted state to parent in order to coordinate connection 2025-04-07 20:27:51 -04:00
Dan DitomasoandGitHub 0828618c0d Merge pull request #563 from danditomaso/issue-550-incorrect-text-in-dialog
Incorrect text in PKI dialog
2025-04-07 20:19:33 -04:00
Dan Ditomaso 7267101021 fix: extended dialog to allow for dynamic title/description 2025-04-07 15:02:12 -04:00
Dan Ditomaso 0e868cef58 renamed dialog, added reactions menu 2025-04-07 11:42:15 -04:00
Dan DitomasoandGitHub e410ccb2f4 Merge pull request #562 from danditomaso/fix/missing-import
fix: added missing imports
2025-04-05 13:01:19 -04:00
Dan Ditomaso c5b3f2ece6 fix: added missing imports 2025-04-05 12:57:42 -04:00
Dan DitomasoandGitHub 35353c58cb Merge pull request #561 from danditomaso/fix/failing-test
fix: refactor to fix merge issues with  messageStore and unread counts
2025-04-05 11:49:11 -04:00
Dan Ditomaso e80d8e73ae fix: refactor to fix merge issues with messageStore and unread counts 2025-04-05 11:46:32 -04:00
Dan DitomasoandGitHub 494a35a0c3 Merge pull request #497 from Hunter275/unread-counts
Unread Counts
2025-04-05 08:40:54 -04:00
Dan DitomasoandGitHub 818bbb4a30 fix broken test 2025-04-05 08:39:27 -04:00
Dan DitomasoandGitHub 4755c0eeb9 refactor to integrate messageStore and unreadCounts 2025-04-04 22:22:35 -04:00
Dan DitomasoandGitHub c8c89fdc95 Merge branch 'master' into unread-counts 2025-04-04 08:58:13 -04:00
Dan DitomasoandGitHub 52e0924f1c Merge pull request #560 from danditomaso/fix/node-detail-use-message-store
fix: update node details page to use message store
2025-04-03 22:42:48 -04:00
Dan Ditomaso 645c758b42 fixed: removed unneeded prop 2025-04-03 22:41:10 -04:00
Dan Ditomaso 4dc7788981 fixed typo 2025-04-03 22:40:30 -04:00
Dan Ditomaso 9fa945a863 fix: update node details page to use message store 2025-04-03 22:37:50 -04:00
Dan DitomasoandGitHub 38b8695441 Merge pull request #536 from danditomaso/add-message-persistance
Add message persistence using IndexedDB
2025-04-03 22:28:25 -04:00
Dan Ditomaso eadadb5d1d keyed conversations against from/to, updated tests 2025-04-03 17:03:08 -04:00
Dan DitomasoandGitHub 5f424e2e0b Merge branch 'master' into add-message-persistance 2025-04-02 18:02:57 -04:00
James ThomasandDan Ditomaso d807cd2de7 Using existing Link component and standardizing colors 2025-04-02 18:00:44 -04:00
James ThomasandDan Ditomaso 0b4e3a8da9 Cleanup and add link to official docs 2025-04-02 18:00:44 -04:00
James ThomasandDan Ditomaso 31be5e9a25 Adding connection failure warning 2025-04-02 18:00:44 -04:00
vidplace7andDan Ditomaso 367538eeea GHA: Attach build.tar to release 2025-04-02 18:00:33 -04:00
Dan Ditomaso 442c1cb5f1 fix: moved meshtastic packages into package.json 2025-04-02 18:00:33 -04:00
Dan Ditomaso a333e4524f updated deps 2025-04-02 18:00:12 -04:00
Hunter275andDan Ditomaso 1e54f7d99b fix for non-Primary channels 2025-04-02 17:59:59 -04:00
Dan Ditomaso 9f2aa8282d adding tests 2025-04-02 17:59:59 -04:00
Dan Ditomaso 8d5dc440d0 feat: add udp over mesh toggle 2025-04-02 17:59:59 -04:00
Dan Ditomaso 8fffde0165 wip 2025-04-02 17:59:59 -04:00
James ThomasandDan Ditomaso 1a6e99971a Extended loading 2025-04-02 17:58:40 -04:00
Dan Ditomaso 4de88c3add fixed: import issue 2025-04-02 17:58:40 -04:00
Dan Ditomaso 76374893e3 added tests 2025-04-02 17:58:40 -04:00
Dan Ditomaso edc17b304a feat: added reboot to OTA in command menu 2025-04-02 17:58:40 -04:00
Dan Ditomaso ec7b4528f6 added reboot to command menu 2025-04-02 17:58:30 -04:00
Dan Ditomaso 8d75c4afb1 fix: docker build process 2025-04-02 17:58:30 -04:00
James ThomasandDan Ditomaso b30fbf90b9 Prevent tooltip from appearing by default 2025-04-02 17:58:30 -04:00
James ThomasandDan Ditomaso 8fb95e1b06 Lint 2025-04-02 17:58:30 -04:00
James ThomasandDan Ditomaso f5e1a0569f Lint 2025-04-02 17:58:30 -04:00
James ThomasandDan Ditomaso 929f87b411 Adding DM from Map function 2025-04-02 17:58:30 -04:00
Dan Ditomaso 59d97008f2 feat: added tzdef to device config 2025-04-02 17:58:30 -04:00
Dan DitomasoandGitHub 540b8ebb4d Merge pull request #548 from James9074/tls-warning
Adding UX Feedback For Failed Device Connections Over HTTP/s
2025-04-02 13:12:39 -04:00
Dan DitomasoandGitHub 109d4afce2 Merge pull request #556 from vidplace7/release-buildtar
GHA: Attach build.tar to release
2025-04-02 10:09:17 -04:00
vidplace7 aab8bce78e GHA: Attach build.tar to release 2025-04-02 09:18:20 -04:00
James ThomasandGitHub d2c33b4caf Merge branch 'meshtastic:master' into tls-warning 2025-04-02 08:32:23 -04:00
Dan Ditomaso 6443544a6b added dialog to warn before clearing all messages 2025-04-01 14:50:41 -04:00
Dan Ditomaso a56ac84186 add enums, improve tests, add styling 2025-03-31 21:28:58 -04:00
James ThomasandGitHub 443a9ea101 Merge branch 'meshtastic:master' into tls-warning 2025-03-31 09:29:45 -04:00
James Thomas 0faafe8bc4 Using existing Link component and standardizing colors 2025-03-30 21:43:25 -04:00
James Thomas 9948701127 Cleanup and add link to official docs 2025-03-30 21:01:25 -04:00
James Thomas ffae92d233 Adding connection failure warning 2025-03-30 20:52:04 -04:00
Dan Ditomaso 74db087d7d updated node details with new messaging features. 2025-03-28 21:22:10 -04:00
e00239562c Update src/components/PageComponents/Config/Position.tsx
Co-authored-by: James Thomas <james9074@gmail.com>
2025-03-28 21:14:42 -04:00
Dan DitomasoandGitHub 8df67bf76a Merge branch 'master' into add-message-persistance 2025-03-26 16:09:12 -04:00
Dan Ditomaso 80d4670204 state store cleanup, added tests 2025-03-26 15:22:14 -04:00
Dan Ditomaso ed2ab36ed4 feat: added message persistance 2025-03-25 15:23:24 -04:00
Dan Ditomaso 0d6c5878fc WIP 2025-03-24 15:37:51 -04:00
Hunter275 dab76df131 reorder tests so they don't step on each other 2025-03-21 23:43:57 -04:00
Hunter Thornsberry d1c19d9d3e add hasNodeError for tests 2025-03-21 14:20:27 -04:00
Hunter ThornsberryandGitHub 11b052e5bb Merge branch 'master' into unread-counts 2025-03-21 10:36:09 -04:00
Hunter Thornsberry 7e1ba42873 remove defined css class and just use tailwind 2025-03-16 19:45:27 -04:00
Hunter Thornsberry 0bef82ec32 don't update unred if the channel/dm is active 2025-03-14 15:44:02 -04:00
Hunter Thornsberry f80bb6c42d DM vs Channel message detection 2025-03-14 15:28:45 -04:00
Hunter ThornsberryandGitHub 01a74829fc Merge branch 'master' into unread-counts 2025-03-11 16:25:58 -04:00
Hunter Thornsberry 7b77b7f5e9 remove test values 2025-03-11 16:21:44 -04:00
Hunter Thornsberry ce8fcd2269 simplify device list and tests 2025-03-11 16:20:22 -04:00
Hunter Thornsberry f6f64eca10 tests 2025-03-10 23:33:39 -04:00
Hunter275 c103d7012b tests 2025-03-10 20:54:42 -04:00
Hunter275 1f1a3c5de8 spread on the map 2025-03-09 00:57:52 -05:00
Hunter Thornsberry 3bfd96defe wip 2025-03-07 23:59:52 -05:00
Hunter275 cad590f993 wip 2025-03-07 22:01:34 -05:00
184 changed files with 7660 additions and 3555 deletions
+21 -1
View File
@@ -1,8 +1,9 @@
name: CI name: Push to Master/Main CI
on: on:
push: push:
branches: branches:
- main
- master - master
permissions: permissions:
@@ -24,6 +25,25 @@ jobs:
- name: Install Dependencies - name: Install Dependencies
run: deno install run: deno install
- name: Cache Deno dependencies
uses: actions/cache@v4
with:
path: |
~/.cache/deno
./deno.lock
key: ${{ runner.os }}-deno-${{ hashFiles('**/deno.lock') }}
restore-keys: |
${{ runner.os }}-deno-
- name: Cache Dependencies
run: deno cache src/index.tsx
- name: Run linter
run: deno task lint
- name: Check formatter
run: deno task format --check
- name: Run tests - name: Run tests
run: deno task test run: deno task test
+22 -2
View File
@@ -1,6 +1,7 @@
name: Pull Request name: Pull Request CI
on: pull_request on:
pull_request:
jobs: jobs:
build: build:
@@ -14,9 +15,28 @@ jobs:
with: with:
deno-version: v2.x deno-version: v2.x
- name: Cache Deno dependencies
uses: actions/cache@v4
with:
path: |
~/.cache/deno
./deno.lock
key: ${{ runner.os }}-deno-${{ hashFiles('**/deno.lock') }}
restore-keys: |
${{ runner.os }}-deno-
- name: Install Dependencies - name: Install Dependencies
run: deno install run: deno install
- name: Cache Dependencies
run: deno cache src/index.tsx
- name: Run linter
run: deno task lint
- name: Check formatter
run: deno task format --check
- name: Run tests - name: Run tests
run: deno task test run: deno task test
+8 -2
View File
@@ -1,8 +1,8 @@
name: 'Release' name: Release
on: on:
release: release:
types: [released] types: [released, prereleased]
permissions: permissions:
contents: write contents: write
@@ -38,6 +38,12 @@ jobs:
name: build name: build
path: dist/build.tar path: dist/build.tar
- name: Attach build.tar to release
run: |
gh release upload ${{ github.event.release.tag_name }} dist/build.tar
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@@ -0,0 +1,50 @@
name: Update Stable Branch from Master on Latest Release
on:
release:
types: [released]
permissions:
contents: write
jobs:
update-stable-branch:
name: Update Stable Branch from Master
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Configure Git
run: |
git config user.name "GitHub Actions Bot"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Fetch latest master and stable branches
run: |
git fetch origin master:master
git fetch origin stable:stable || echo "Stable branch not found remotely, will create."
- name: Get latest master commit SHA
id: get_master_sha
run: echo "MASTER_SHA=$(git rev-parse master)" >> $GITHUB_ENV
- name: Check out stable branch
run: |
if git show-ref --verify --quiet refs/heads/stable; then
git checkout stable
git pull origin stable # Sync with remote stable if it exists
else
echo "Creating local stable branch based on master HEAD."
git checkout -b stable ${{ env.MASTER_SHA }}
fi
- name: Reset stable branch to latest master
run: git reset --hard ${{ env.MASTER_SHA }}
- name: Force push stable branch
run: git push origin stable --force
+14 -5
View File
@@ -50,11 +50,20 @@ docker run -d -p 8080:8080 --restart always --name Meshtastic-Web ghcr.io/meshta
podman run -d -p 8080:8080 --restart always --name Meshtastic-Web ghcr.io/meshtastic/web podman run -d -p 8080:8080 --restart always --name Meshtastic-Web ghcr.io/meshtastic/web
``` ```
## Nightly releases ## Release Schedule
Our nightly releases provide the latest development builds with cutting-edge Our release process follows these guidelines:
features and fixes. These builds are automatically generated from the latest
main branch every night and are available for testing and early adoption. - **Versioning:** We use Semantic Versioning (`Major.Minor.Patch`).
- **Stable Releases:** Published around the beginning of each month (e.g.,
`v2.3.4`).
- **Pre-releases:** A pre-release is typically issued mid-month for testing and
early adoption.
- **Nightly Builds:** An experimental Docker image containing the latest
cutting-edge features and fixes is automatically built nightly from the
`master` branch.
### Nightly Builds
```bash ```bash
# With Docker # With Docker
@@ -73,7 +82,7 @@ podman run -d -p 8080:8080 --restart always --name Meshtastic-Web ghcr.io/meshta
> new features > new features
> - No guarantee of backward compatibility between nightly builds > - No guarantee of backward compatibility between nightly builds
### Version Information #### Version Information
Each nightly build is tagged with: Each nightly build is tagged with:
+13
View File
@@ -28,6 +28,19 @@
], ],
"strictPropertyInitialization": false "strictPropertyInitialization": false
}, },
"fmt": {
"exclude": [
"*.test.ts",
"*.test.tsx"
]
},
"lint": {
"exclude": [
"*.test.ts",
"*.test.tsx"
],
"report": "pretty"
},
"unstable": [ "unstable": [
"sloppy-imports" "sloppy-imports"
] ]
Generated
+1163 -705
View File
File diff suppressed because it is too large Load Diff
+40 -38
View File
@@ -40,70 +40,72 @@
"@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth", "@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth",
"@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial", "@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial",
"@bufbuild/protobuf": "^2.2.5", "@bufbuild/protobuf": "^2.2.5",
"@noble/curves": "^1.8.1", "@noble/curves": "^1.9.0",
"@radix-ui/react-accordion": "^1.2.3", "@radix-ui/react-accordion": "^1.2.8",
"@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-checkbox": "^1.2.3",
"@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dialog": "^1.1.11",
"@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-label": "^2.1.2", "@radix-ui/react-label": "^2.1.4",
"@radix-ui/react-menubar": "^1.1.6", "@radix-ui/react-menubar": "^1.1.12",
"@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-popover": "^1.1.11",
"@radix-ui/react-scroll-area": "^1.2.3", "@radix-ui/react-scroll-area": "^1.2.6",
"@radix-ui/react-select": "^2.1.6", "@radix-ui/react-select": "^2.2.2",
"@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-separator": "^1.1.4",
"@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-slider": "^1.3.2",
"@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-switch": "^1.2.2",
"@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-tabs": "^1.1.9",
"@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-toast": "^1.2.11",
"@radix-ui/react-tooltip": "^1.2.4",
"@turf/turf": "^7.2.0", "@turf/turf": "^7.2.0",
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"crypto-random-string": "^5.0.0", "crypto-random-string": "^5.0.0",
"idb-keyval": "^6.2.1",
"immer": "^10.1.1", "immer": "^10.1.1",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lucide-react": "^0.486.0", "lucide-react": "^0.507.0",
"maplibre-gl": "5.3.0", "maplibre-gl": "5.4.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-error-boundary": "^5.0.0", "react-error-boundary": "^6.0.0",
"react-hook-form": "^7.55.0", "react-hook-form": "^7.56.2",
"react-map-gl": "8.0.2", "react-map-gl": "8.0.4",
"react-qrcode-logo": "^3.0.0", "react-qrcode-logo": "^3.0.0",
"rfc4648": "^1.5.4", "rfc4648": "^1.5.4",
"vite-plugin-node-polyfills": "^0.23.0", "vite-plugin-node-polyfills": "^0.23.0",
"zod": "^3.24.2", "zod": "^3.24.3",
"zustand": "5.0.3" "zustand": "5.0.4"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.0", "@tailwindcss/postcss": "^4.1.5",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/chrome": "^0.0.313", "@types/chrome": "^0.0.318",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/node": "^22.13.17", "@types/node": "^22.15.3",
"@types/react": "^19.0.12", "@types/react": "^19.1.2",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.1.3",
"@types/serviceworker": "^0.0.127", "@types/serviceworker": "^0.0.133",
"@types/w3c-web-serial": "^1.0.8", "@types/w3c-web-serial": "^1.0.8",
"@types/web-bluetooth": "^0.0.21", "@types/web-bluetooth": "^0.0.21",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"gzipper": "^8.2.1", "gzipper": "^8.2.1",
"happy-dom": "^17.4.4", "happy-dom": "^17.4.6",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"simple-git-hooks": "^2.12.1", "simple-git-hooks": "^2.13.0",
"tailwind-merge": "^3.1.0", "tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.0", "tailwindcss": "^4.1.5",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tar": "^7.4.3", "tar": "^7.4.3",
"testing-library": "^0.0.2", "testing-library": "^0.0.2",
"typescript": "^5.8.2", "typescript": "^5.8.3",
"vite": "^6.2.4", "vite": "^6.3.4",
"vitest": "^3.1.1", "vitest": "^3.1.2",
"vite-plugin-pwa": "^1.0.0" "vite-plugin-pwa": "^1.0.0"
} }
} }
+16
View 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

-5
View File
@@ -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

+17 -10
View File
@@ -1,6 +1,5 @@
import { DeviceWrapper } from "@app/DeviceWrapper.tsx"; import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
import { PageRouter } from "@app/PageRouter.tsx"; import { PageRouter } from "@app/PageRouter.tsx";
import { DeviceSelector } from "@components/DeviceSelector.tsx";
import { DialogManager } from "@components/Dialog/DialogManager.tsx"; import { DialogManager } from "@components/Dialog/DialogManager.tsx";
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx"; import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx";
import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx"; import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx";
@@ -14,7 +13,8 @@ import { ErrorBoundary } from "react-error-boundary";
import { ErrorPage } from "@components/UI/ErrorPage.tsx"; import { ErrorPage } from "@components/UI/ErrorPage.tsx";
import { MapProvider } from "react-map-gl/maplibre"; import { MapProvider } from "react-map-gl/maplibre";
import { CommandPalette } from "@components/CommandPalette/index.tsx"; import { CommandPalette } from "@components/CommandPalette/index.tsx";
import { SidebarProvider } from "@core/stores/sidebarStore.tsx";
import { useTheme } from "@core/hooks/useTheme.ts";
export const App = (): JSX.Element => { export const App = (): JSX.Element => {
const { getDevice } = useDeviceStore(); const { getDevice } = useDeviceStore();
@@ -23,6 +23,9 @@ export const App = (): JSX.Element => {
const device = getDevice(selectedDevice); const device = getDevice(selectedDevice);
// Sets up light/dark mode based on user preferences or system settings
useTheme();
return ( return (
<ErrorBoundary FallbackComponent={ErrorPage}> <ErrorBoundary FallbackComponent={ErrorPage}>
<NewDeviceDialog <NewDeviceDialog
@@ -33,12 +36,15 @@ export const App = (): JSX.Element => {
/> />
<Toaster /> <Toaster />
<DeviceWrapper device={device}> <DeviceWrapper device={device}>
<div className="flex h-screen flex-col overflow-hidden bg-background-primary text-text-primary"> <div
<div className="flex grow"> className="flex h-screen flex-col bg-background-primary text-text-primary"
<DeviceSelector /> style={{ scrollbarWidth: "thin" }}
<div className="flex grow flex-col"> >
{device ? ( <SidebarProvider>
<div className="flex h-screen w-full"> <div className="h-full flex flex-col">
{device
? (
<div className="h-full flex w-full">
<DialogManager /> <DialogManager />
<KeyBackupReminder /> <KeyBackupReminder />
<CommandPalette /> <CommandPalette />
@@ -46,14 +52,15 @@ export const App = (): JSX.Element => {
<PageRouter /> <PageRouter />
</MapProvider> </MapProvider>
</div> </div>
) : ( )
: (
<> <>
<Dashboard /> <Dashboard />
<Footer /> <Footer />
</> </>
)} )}
</div> </div>
</div> </SidebarProvider>
</div> </div>
</DeviceWrapper> </DeviceWrapper>
</ErrorBoundary> </ErrorBoundary>
+9 -8
View File
@@ -1,13 +1,13 @@
import { vi } from 'vitest' import { vi } from "vitest";
vi.mock('@components/UI/Button.tsx', () => ({ vi.mock("@components/UI/Button.tsx", () => ({
Button: ({ children, name, disabled, onClick }: { Button: ({ children, name, disabled, onClick }: {
children: React.ReactNode, children: React.ReactNode;
variant: string, variant: string;
name: string, name: string;
disabled?: boolean, disabled?: boolean;
onClick: () => void onClick: () => void;
}) => }) => (
<button <button
type="button" type="button"
name={name} name={name}
@@ -17,4 +17,5 @@ vi.mock('@components/UI/Button.tsx', () => ({
> >
{children} {children}
</button> </button>
),
})); }));
+17 -4
View File
@@ -1,6 +1,19 @@
import { vi } from 'vitest' import { vi } from "vitest";
vi.mock('@components/UI/Checkbox.tsx', () => ({ vi.mock("@components/UI/Checkbox.tsx", () => ({
Checkbox: ({ id, checked, onChange }: { id: string, checked: boolean, onChange: () => void }) => Checkbox: (
<input data-testid="checkbox" type="checkbox" id={id} checked={checked} onChange={onChange} /> { id, checked, onChange }: {
id: string;
checked: boolean;
onChange: () => void;
},
) => (
<input
data-testid="checkbox"
type="checkbox"
id={id}
checked={checked}
onChange={onChange}
/>
),
})); }));
+20 -18
View File
@@ -1,43 +1,45 @@
import React from 'react'; import React from "react";
export const Dialog = ({ children, open }: { export const Dialog = ({ children, open }: {
children: React.ReactNode, children: React.ReactNode;
open: boolean, open: boolean;
onOpenChange?: (open: boolean) => void onOpenChange?: (open: boolean) => void;
}) => open ? <div data-testid="dialog">{children}</div> : null; }) => open ? <div data-testid="dialog">{children}</div> : null;
export const DialogContent = ({ export const DialogContent = ({
children, children,
className className,
}: { }: {
children: React.ReactNode, children: React.ReactNode;
className?: string className?: string;
}) => <div data-testid="dialog-content" className={className}>{children}</div>; }) => <div data-testid="dialog-content" className={className}>{children}</div>;
export const DialogHeader = ({ export const DialogHeader = ({
children children,
}: { }: {
children: React.ReactNode children: React.ReactNode;
}) => <div data-testid="dialog-header">{children}</div>; }) => <div data-testid="dialog-header">{children}</div>;
export const DialogTitle = ({ export const DialogTitle = ({
children children,
}: { }: {
children: React.ReactNode children: React.ReactNode;
}) => <div data-testid="dialog-title">{children}</div>; }) => <div data-testid="dialog-title">{children}</div>;
export const DialogDescription = ({ export const DialogDescription = ({
children, children,
className className,
}: { }: {
children: React.ReactNode, children: React.ReactNode;
className?: string className?: string;
}) => <div data-testid="dialog-description" className={className}>{children}</div>; }) => (
<div data-testid="dialog-description" className={className}>{children}</div>
);
export const DialogFooter = ({ export const DialogFooter = ({
children, children,
className className,
}: { }: {
children: React.ReactNode, children: React.ReactNode;
className?: string className?: string;
}) => <div data-testid="dialog-footer" className={className}>{children}</div>; }) => <div data-testid="dialog-footer" className={className}>{children}</div>;
+13 -4
View File
@@ -1,6 +1,15 @@
import { vi } from 'vitest' import { vi } from "vitest";
vi.mock('@components/UI/Label.tsx', () => ({ vi.mock("@components/UI/Label.tsx", () => ({
Label: ({ children, htmlFor, className }: { children: React.ReactNode, htmlFor: string, className?: string }) => Label: (
<label data-testid="label" htmlFor={htmlFor} className={className}>{children}</label> { children, htmlFor, className }: {
children: React.ReactNode;
htmlFor: string;
className?: string;
},
) => (
<label data-testid="label" htmlFor={htmlFor} className={className}>
{children}
</label>
),
})); }));
+8 -4
View File
@@ -1,7 +1,11 @@
import { vi } from "vitest"; import { vi } from "vitest";
vi.mock('@components/UI/Typography/Link.tsx', () => ({ vi.mock("@components/UI/Typography/Link.tsx", () => ({
Link: ({ children, href, className }: { children: React.ReactNode, href: string, className?: string }) => Link: (
<a data-testid="link" href={href} className={className}>{children}</a> { children, href, className }: {
children: React.ReactNode;
href: string;
className?: string;
},
) => <a data-testid="link" href={href} className={className}>{children}</a>,
})); }));
+88
View 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;
+21 -20
View File
@@ -17,8 +17,10 @@ import {
FactoryIcon, FactoryIcon,
LayersIcon, LayersIcon,
LinkIcon, LinkIcon,
type LucideIcon,
MapIcon, MapIcon,
MessageSquareIcon, MessageSquareIcon,
Pin,
PlusIcon, PlusIcon,
PowerIcon, PowerIcon,
QrCodeIcon, QrCodeIcon,
@@ -27,8 +29,6 @@ import {
SmartphoneIcon, SmartphoneIcon,
TrashIcon, TrashIcon,
UsersIcon, UsersIcon,
Pin,
type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { Avatar } from "@components/UI/Avatar.tsx"; import { Avatar } from "@components/UI/Avatar.tsx";
@@ -57,11 +57,14 @@ export const CommandPalette = () => {
const { const {
commandPaletteOpen, commandPaletteOpen,
setCommandPaletteOpen, setCommandPaletteOpen,
setConnectDialogOpen,
setSelectedDevice, setSelectedDevice,
} = useAppStore(); } = useAppStore();
const { getDevices } = useDeviceStore(); const { getDevices } = useDeviceStore();
const { setDialogOpen, setActivePage, connection } = useDevice(); const { setDialogOpen, setActivePage, getNode, connection } = useDevice();
const { pinnedItems, togglePinnedItem } = usePinnedItems({ storageName: 'pinnedCommandMenuGroups' }); const { pinnedItems, togglePinnedItem } = usePinnedItems({
storageName: "pinnedCommandMenuGroups",
});
const groups: Group[] = [ const groups: Group[] = [
{ {
@@ -114,15 +117,12 @@ export const CommandPalette = () => {
label: "Switch Node", label: "Switch Node",
icon: ArrowLeftRightIcon, icon: ArrowLeftRightIcon,
subItems: getDevices().map((device) => ({ subItems: getDevices().map((device) => ({
label: label: getNode(device.hardware.myNodeNum)?.user?.longName ??
device.nodes.get(device.hardware.myNodeNum)?.user?.longName ??
device.hardware.myNodeNum.toString(), device.hardware.myNodeNum.toString(),
icon: ( icon: (
<Avatar <Avatar
text={ text={getNode(device.hardware.myNodeNum)?.user?.shortName ??
device.nodes.get(device.hardware.myNodeNum)?.user?.shortName ?? device.hardware.myNodeNum.toString()}
device.hardware.myNodeNum.toString()
}
/> />
), ),
action() { action() {
@@ -134,7 +134,7 @@ export const CommandPalette = () => {
label: "Connect New Node", label: "Connect New Node",
icon: PlusIcon, icon: PlusIcon,
action() { action() {
setSelectedDevice(0); setConnectDialogOpen(true);
}, },
}, },
], ],
@@ -219,10 +219,10 @@ export const CommandPalette = () => {
}, },
}, },
{ {
label: "[WIP] Clear Messages", label: "Clear All Stored Message",
icon: EraserIcon, icon: EraserIcon,
action() { action() {
alert("This feature is not implemented"); setDialogOpen("deleteMessages", true);
}, },
}, },
], ],
@@ -248,7 +248,10 @@ export const CommandPalette = () => {
}, [setCommandPaletteOpen]); }, [setCommandPaletteOpen]);
return ( return (
<CommandDialog open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen}> <CommandDialog
open={commandPaletteOpen}
onOpenChange={setCommandPaletteOpen}
>
<CommandInput placeholder="Type a command or search..." /> <CommandInput placeholder="Type a command or search..." />
<CommandList> <CommandList>
<CommandEmpty>No results found.</CommandEmpty> <CommandEmpty>No results found.</CommandEmpty>
@@ -262,13 +265,11 @@ export const CommandPalette = () => {
type="button" type="button"
onClick={() => togglePinnedItem(group.label)} onClick={() => togglePinnedItem(group.label)}
className={cn( className={cn(
"transition-all duration-300 scale-100 cursor-pointer m-0.5 p-2 focus:*:data-label:opacity-100" "transition-all duration-300 scale-100 cursor-pointer p-2 focus:*:data-label:opacity-100",
)} )}
aria-description={ aria-description={pinnedItems.includes(group.label)
pinnedItems.includes(group.label)
? "Unpin command group" ? "Unpin command group"
: "Pin command group" : "Pin command group"}
}
> >
<span <span
data-label data-label
@@ -280,7 +281,7 @@ export const CommandPalette = () => {
"transition-opacity", "transition-opacity",
pinnedItems.includes(group.label) pinnedItems.includes(group.label)
? "opacity-100 text-red-500" ? "opacity-100 text-red-500"
: "opacity-40 hover:opacity-70" : "opacity-40 hover:opacity-70",
)} )}
/> />
</button> </button>
-76
View File
@@ -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>
);
};
-25
View File
@@ -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>
);
};
+70 -20
View File
@@ -10,10 +10,11 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@components/UI/Dialog.tsx"; } from "@components/UI/Dialog.tsx";
import { Input } from "@components/UI/Input.tsx";
import { Label } from "@components/UI/Label.tsx"; import { Label } from "@components/UI/Label.tsx";
import { Protobuf } from "@meshtastic/core"; import { Protobuf } from "@meshtastic/core";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { GenericInput } from "@components/Form/FormInput.tsx";
import { validateMaxByteLength } from "@core/utils/string.ts";
export interface User { export interface User {
longName: string; longName: string;
@@ -24,32 +25,50 @@ export interface DeviceNameDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
const MAX_LONG_NAME_BYTE_LENGTH = 40;
const MAX_SHORT_NAME_BYTE_LENGTH = 4;
export const DeviceNameDialog = ({ export const DeviceNameDialog = ({
open, open,
onOpenChange, onOpenChange,
}: DeviceNameDialogProps) => { }: DeviceNameDialogProps) => {
const { hardware, nodes, connection } = useDevice(); const { hardware, getNode, connection } = useDevice();
const myNode = getNode(hardware.myNodeNum);
const myNode = nodes.get(hardware.myNodeNum); const defaultValues = {
const { register, handleSubmit } = useForm<User>({
values: {
longName: myNode?.user?.longName ?? "Unknown", longName: myNode?.user?.longName ?? "Unknown",
shortName: myNode?.user?.shortName ?? "Unknown", shortName: myNode?.user?.shortName ?? "??",
}, };
const { getValues, setValue, reset, control, handleSubmit } = useForm<User>({
values: defaultValues,
}); });
const { currentLength: currentLongNameLength } = validateMaxByteLength(
getValues("longName"),
MAX_LONG_NAME_BYTE_LENGTH,
);
const { currentLength: currentShortNameLength } = validateMaxByteLength(
getValues("shortName"),
MAX_SHORT_NAME_BYTE_LENGTH,
);
const onSubmit = handleSubmit((data) => { const onSubmit = handleSubmit((data) => {
connection?.setOwner( connection?.setOwner(
create(Protobuf.Mesh.UserSchema, { create(Protobuf.Mesh.UserSchema, {
...myNode?.user, ...(myNode?.user ?? {}),
...data, ...data,
}), }),
); );
onOpenChange(false); onOpenChange(false);
}); });
const handleReset = () => {
reset({ longName: "", shortName: "" });
setValue("longName", "");
setValue("shortName", "");
};
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
@@ -60,21 +79,52 @@ export const DeviceNameDialog = ({
The Device will restart once the config is saved. The Device will restart once the config is saved.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="gap-4"> <form onSubmit={onSubmit} className="flex flex-col gap-4">
<form onSubmit={onSubmit}> <div>
<Label>Long Name</Label> <Label htmlFor="longName">Long Name</Label>
<Input className="dark:text-slte-900" {...register("longName")} /> <GenericInput
<Label>Short Name</Label> control={control}
<Input field={{
className="dark:text-slte-900" name: "longName",
maxLength={4} label: "Long Name",
{...register("shortName")} type: "text",
properties: {
className: "text-slate-900 dark:text-slate-200",
fieldLength: {
currentValueLength: currentLongNameLength ?? 0,
max: MAX_LONG_NAME_BYTE_LENGTH,
showCharacterCount: true,
},
},
}}
/> />
</form>
</div> </div>
<div>
<Label htmlFor="shortName">Short Name</Label>
<GenericInput
control={control}
field={{
name: "shortName",
label: "Short Name",
type: "text",
properties: {
fieldLength: {
currentValueLength: currentShortNameLength ?? 0,
max: MAX_SHORT_NAME_BYTE_LENGTH,
showCharacterCount: true,
},
},
}}
/>
</div>
<DialogFooter> <DialogFooter>
<Button onClick={() => onSubmit()}>Save</Button> <Button type="button" variant="destructive" onClick={handleReset}>
Reset
</Button>
<Button type="submit">Save</Button>
</DialogFooter> </DialogFooter>
</form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
+7
View File
@@ -10,6 +10,7 @@ import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDeta
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx"; import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx"; import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx";
import { RebootOTADialog } from "@components/Dialog/RebootOTADialog.tsx"; import { RebootOTADialog } from "@components/Dialog/RebootOTADialog.tsx";
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
export const DialogManager = () => { export const DialogManager = () => {
const { channels, config, dialog, setDialogOpen } = useDevice(); const { channels, config, dialog, setDialogOpen } = useDevice();
@@ -84,6 +85,12 @@ export const DialogManager = () => {
setDialogOpen("rebootOTA", open); setDialogOpen("rebootOTA", open);
}} }}
/> />
<DeleteMessagesDialog
open={dialog.deleteMessages}
onOpenChange={(open) => {
setDialogOpen("deleteMessages", open);
}}
/>
</> </>
); );
}; };
-1
View File
@@ -109,7 +109,6 @@ export const ImportDialog = ({
<Input <Input
value={importDialogInput} value={importDialogInput}
suffix={validUrl ? "✅" : "❌"} suffix={validUrl ? "✅" : "❌"}
className="dark:text-slate-900"
onChange={(e) => { onChange={(e) => {
setImportDialogInput(e.target.value); setImportDialogInput(e.target.value);
}} }}
@@ -21,9 +21,9 @@ export const LocationResponseDialog = ({
open, open,
onOpenChange, onOpenChange,
}: LocationResponseDialogProps) => { }: LocationResponseDialogProps) => {
const { nodes } = useDevice(); const { getNode } = useDevice();
const from = nodes.get(location?.from ?? 0); const from = getNode(location?.from ?? 0);
const longName = from?.user?.longName ?? const longName = from?.user?.longName ??
(from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown"); (from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown");
const shortName = from?.user?.shortName ?? const shortName = from?.user?.shortName ??
@@ -43,7 +43,8 @@ export const LocationResponseDialog = ({
Coordinates:{" "} Coordinates:{" "}
<a <a
className="text-blue-500 dark:text-blue-400" className="text-blue-500 dark:text-blue-400"
href={`https://www.openstreetmap.org/?mlat=${location?.data.latitudeI / 1e7 href={`https://www.openstreetmap.org/?mlat=${
location?.data.latitudeI / 1e7
}&mlon=${location?.data.longitudeI / 1e7}&layers=N`} }&mlon=${location?.data.longitudeI / 1e7}&layers=N`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
+7 -5
View File
@@ -87,8 +87,8 @@ const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
{browserFeatures.length > 0 && ( {browserFeatures.length > 0 && (
<> <>
This connection type requires{" "} This connection type requires{" "}
{formatFeatureList(browserFeatures)}. Please use a {formatFeatureList(browserFeatures)}. Please use a supported
supported browser, like Chrome or Edge. browser, like Chrome or Edge.
</> </>
)} )}
{needsSecureContext && ( {needsSecureContext && (
@@ -135,7 +135,7 @@ export const NewDeviceDialog = ({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent aria-describedby={undefined}>
<DialogClose /> <DialogClose />
<DialogHeader> <DialogHeader>
<DialogTitle>Connect New Device</DialogTitle> <DialogTitle>Connect New Device</DialogTitle>
@@ -151,10 +151,12 @@ export const NewDeviceDialog = ({
{tabs.map((tab) => ( {tabs.map((tab) => (
<TabsContent key={tab.label} value={tab.label}> <TabsContent key={tab.label} value={tab.label}>
<fieldset disabled={tab.isDisabled}> <fieldset disabled={tab.isDisabled}>
{tab.isDisabled {(tab.label !== "HTTP" && tab.isDisabled)
? <ErrorMessage missingFeatures={unsupported} /> ? <ErrorMessage missingFeatures={unsupported} />
: null} : null}
<tab.element closeDialog={() => onOpenChange(false)} /> <tab.element
closeDialog={() => onOpenChange(false)}
/>
</fieldset> </fieldset>
</TabsContent> </TabsContent>
))} ))}
@@ -1,14 +1,22 @@
import { describe, it, vi, expect, beforeEach, Mock } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx"; import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useAppStore } from "@core/stores/appStore.ts"; import { useAppStore } from "@core/stores/appStore.ts";
import { Protobuf } from "@meshtastic/core";
vi.mock("@core/stores/deviceStore"); vi.mock("@core/stores/deviceStore", () => {
return {
useDevice: () => ({
setDialogOpen: vi.fn(),
}),
};
});
vi.mock("@core/stores/appStore"); vi.mock("@core/stores/appStore");
const mockUseAppStore = vi.mocked(useAppStore);
describe("NodeDetailsDialog", () => { describe("NodeDetailsDialog", () => {
const mockDevice = { const mockNode = {
num: 1234, num: 1234,
user: { user: {
longName: "Test Node", longName: "Test Node",
@@ -29,45 +37,96 @@ describe("NodeDetailsDialog", () => {
voltage: 4.2, voltage: 4.2,
uptimeSeconds: 3600, uptimeSeconds: 3600,
}, },
}; } as unknown as Protobuf.Mesh.NodeInfo;
beforeEach(() => { beforeEach(() => {
// Reset mocks before each test
vi.resetAllMocks(); vi.resetAllMocks();
(useDevice as Mock).mockReturnValue({ mockUseAppStore.mockReturnValue({
nodes: new Map([[1234, mockDevice]]),
});
(useAppStore as unknown as Mock).mockReturnValue({
nodeNumDetails: 1234, nodeNumDetails: 1234,
}); });
}); });
it("renders node details correctly", () => { it("renders node details correctly", () => {
render(<NodeDetailsDialog open={true} onOpenChange={() => { }} />); render(<NodeDetailsDialog open node={mockNode} onOpenChange={() => {}} />);
expect(screen.getByText(/Node Details for Test Node/i)).toBeInTheDocument(); expect(screen.getByText(/Node Details for Test Node \(TN\)/i))
.toBeInTheDocument();
expect(screen.getByText("Node Number: 1234")).toBeInTheDocument(); expect(screen.getByText("Node Number: 1234")).toBeInTheDocument();
expect(screen.getByText(/Node Hex: !/i)).toBeInTheDocument();
expect(screen.getByText(/Last Heard:/i)).toBeInTheDocument();
expect(screen.getByText(/Coordinates:/i)).toBeInTheDocument();
const link = screen.getByRole("link", { name: /^45, -75$/ });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute(
"href",
expect.stringContaining("openstreetmap.org"),
);
expect(screen.getByText(/Altitude: 200m/i)).toBeInTheDocument();
expect(screen.getByText(/Air TX utilization: 50.12%/i)).toBeInTheDocument(); expect(screen.getByText(/Air TX utilization: 50.12%/i)).toBeInTheDocument();
expect(screen.getByText(/Channel utilization: 75.46%/i)).toBeInTheDocument(); expect(screen.getByText(/Channel utilization: 75.46%/i))
.toBeInTheDocument();
expect(screen.getByText(/Battery level: 88.79%/i)).toBeInTheDocument(); expect(screen.getByText(/Battery level: 88.79%/i)).toBeInTheDocument();
expect(screen.getByText(/Voltage: 4.20V/i)).toBeInTheDocument(); expect(screen.getByText(/Voltage: 4.20V/i)).toBeInTheDocument();
expect(screen.getByText(/Uptime:/i)).toBeInTheDocument(); expect(screen.getByText(/Uptime:/i)).toBeInTheDocument();
expect(screen.getByText(/Coordinates:/i)).toBeInTheDocument();
expect(screen.getByText("45, -75")).toBeInTheDocument(); expect(screen.getByText(/All Raw Metrics:/i)).toBeInTheDocument();
expect(screen.getByText(/Altitude: 200m/i)).toBeInTheDocument();
expect(screen.getByText(/Role:/i)).toBeInTheDocument();
}); });
it("renders null if device is not found", () => { it("renders null if node is undefined", () => {
(useDevice as Mock).mockReturnValue({ const mockNode = undefined;
nodes: new Map(),
});
render(<NodeDetailsDialog open={true} onOpenChange={() => { }} />); const { container } = render(
<NodeDetailsDialog open node={mockNode} onOpenChange={() => {}} />,
);
expect(container.firstChild).toBeNull();
expect(screen.queryByText(/Node Details for/i)).not.toBeInTheDocument(); expect(screen.queryByText(/Node Details for/i)).not.toBeInTheDocument();
}); });
it("renders correctly when position is missing", () => {
const nodeWithoutPosition = { ...mockNode, position: undefined };
render(
<NodeDetailsDialog
open
node={nodeWithoutPosition}
onOpenChange={() => {}}
/>,
);
expect(screen.queryByText(/Coordinates:/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Altitude:/i)).not.toBeInTheDocument();
expect(screen.getByText(/Node Details for Test Node/i)).toBeInTheDocument();
});
it("renders correctly when deviceMetrics are missing", () => {
const nodeWithoutMetrics = { ...mockNode, deviceMetrics: undefined };
render(
<NodeDetailsDialog
open
node={nodeWithoutMetrics}
onOpenChange={() => {}}
/>,
);
expect(screen.queryByText(/Device Metrics:/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Air TX utilization:/i)).not.toBeInTheDocument();
expect(screen.getByText(/Node Details for Test Node/i)).toBeInTheDocument();
});
it("renders 'Never' for lastHeard when timestamp is 0", () => {
const nodeNeverHeard = { ...mockNode, lastHeard: 0 };
render(
<NodeDetailsDialog open node={nodeNeverHeard} onOpenChange={() => {}} />,
);
expect(screen.getByText(/Last Heard: Never/i)).toBeInTheDocument();
});
}); });
@@ -1,5 +1,21 @@
import { useEffect, useState } from "react";
import { useAppStore } from "@core/stores/appStore.ts"; import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import {
MessageType,
useMessageStore,
} from "@core/stores/messageStore/index.ts";
import { Protobuf } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { DeviceImage } from "@components/generic/DeviceImage.tsx";
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
import { Uptime } from "@components/generic/Uptime.tsx";
import { toast } from "@core/hooks/useToast.ts";
import { useFavoriteNode } from "../../../core/hooks/useFavoriteNode.ts";
import { useIgnoreNode } from "../../../core/hooks/useIgnoreNode.ts";
import { cn } from "@core/utils/cn.ts";
import { import {
Accordion, Accordion,
AccordionContent, AccordionContent,
@@ -14,124 +30,282 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@components/UI/Dialog.tsx"; } from "@components/UI/Dialog.tsx";
import { Protobuf } from "@meshtastic/core"; import { Button } from "@components/UI/Button.tsx";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import {
import { DeviceImage } from "@components/generic/DeviceImage.tsx"; BellIcon,
import { TimeAgo } from "@components/generic/TimeAgo.tsx"; BellOffIcon,
import { Uptime } from "@components/generic/Uptime.tsx"; MapPinnedIcon,
MessageSquareIcon,
StarIcon,
TrashIcon,
WaypointsIcon,
} from "lucide-react";
import {
Tooltip,
TooltipArrow,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@components/UI/Tooltip.tsx";
import { Separator } from "@components/UI/Seperator.tsx";
export interface NodeDetailsDialogProps { export interface NodeDetailsDialogProps {
node: Protobuf.Mesh.NodeInfo | undefined;
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
export const NodeDetailsDialog = ({ export const NodeDetailsDialog = ({
node,
open, open,
onOpenChange, onOpenChange,
}: NodeDetailsDialogProps) => { }: NodeDetailsDialogProps) => {
const { nodes } = useDevice(); const { setDialogOpen, connection, setActivePage } = useDevice();
const { nodeNumDetails } = useAppStore(); const { setNodeNumToBeRemoved } = useAppStore();
const { setChatType, setActiveChat } = useMessageStore();
const device = nodes.get(nodeNumDetails); const { updateFavorite } = useFavoriteNode();
const [isFavoriteState, setIsFavoriteState] = useState<boolean>(false);
if (!device) return null; const { updateIgnored } = useIgnoreNode();
const [isIgnoredState, setIsIgnoredState] = useState<boolean>(false);
useEffect(() => {
if (!node) return;
setIsFavoriteState(node?.isFavorite);
setIsIgnoredState(node?.isIgnored);
}, [node]);
if (!node) return;
function handleDirectMessage() {
if (!node) return;
setChatType(MessageType.Direct);
setActiveChat(node.num);
setActivePage("messages");
}
function handleRequestPosition() {
if (!node) return;
toast({
title: "Requesting position, please wait...",
});
connection?.requestPosition(node.num).then(() =>
toast({
title: "Position request sent.",
})
);
onOpenChange(false);
}
function handleTraceroute() {
if (!node) return;
toast({
title: "Sending Traceroute, please wait...",
});
connection?.traceRoute(node.num).then(() =>
toast({
title: "Traceroute sent.",
})
);
onOpenChange(false);
}
function handleNodeRemove() {
if (!node) return;
setNodeNumToBeRemoved(node?.num);
setDialogOpen("nodeRemoval", true);
onOpenChange(false);
}
function handleToggleFavorite() {
if (!node) return;
updateFavorite({ nodeNum: node.num, isFavorite: !isFavoriteState });
setIsFavoriteState(!isFavoriteState);
}
function handleToggleIgnored() {
if (!node) return;
updateIgnored({ nodeNum: node.num, isIgnored: !isIgnoredState });
setIsIgnoredState(!isIgnoredState);
}
const deviceMetricsMap = [ const deviceMetricsMap = [
{ {
key: "airUtilTx", key: "airUtilTx",
label: "Air TX utilization", label: "Air TX utilization",
value: device.deviceMetrics?.airUtilTx, value: node.deviceMetrics?.airUtilTx,
format: (val: number) => `${val.toFixed(2)}%`, format: (val: number) => `${val.toFixed(2)}%`,
}, },
{ {
key: "channelUtilization", key: "channelUtilization",
label: "Channel utilization", label: "Channel utilization",
value: device.deviceMetrics?.channelUtilization, value: node.deviceMetrics?.channelUtilization,
format: (val: number) => `${val.toFixed(2)}%`, format: (val: number) => `${val.toFixed(2)}%`,
}, },
{ {
key: "batteryLevel", key: "batteryLevel",
label: "Battery level", label: "Battery level",
value: device.deviceMetrics?.batteryLevel, value: node.deviceMetrics?.batteryLevel,
format: (val: number) => `${val.toFixed(2)}%`, format: (val: number) => `${val.toFixed(2)}%`,
}, },
{ {
key: "voltage", key: "voltage",
label: "Voltage", label: "Voltage",
value: device.deviceMetrics?.voltage, value: node.deviceMetrics?.voltage,
format: (val: number) => `${val.toFixed(2)}V`, format: (val: number) => `${val.toFixed(2)}V`,
}, },
]; ];
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent > <DialogContent aria-describedby={undefined}>
<DialogClose /> <DialogClose />
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
Node Details for {device.user?.longName ?? "UNKNOWN"} ( Node Details for {node.user?.longName ?? "UNKNOWN"} (
{device.user?.shortName ?? "UNK"}) {node.user?.shortName ?? "UNK"})
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<div className="w-full"> <div className="w-full">
<div className="flex flex-col"> <div className="flex flex-row flex-wrap space-y-1">
<DeviceImage <Button className="mr-1" onClick={handleDirectMessage}>
className="w-32 h-32 mx-auto rounded-lg border-4 border-slate-200 dark:border-slate-800" <MessageSquareIcon className="mr-2" />
deviceType={ Message
Protobuf.Mesh.HardwareModel[device.user?.hwModel ?? 0] </Button>
} <Button className="mr-1" onClick={handleTraceroute}>
<WaypointsIcon className="mr-2" />
Trace Route
</Button>
<Button className="mr-1" onClick={handleToggleFavorite}>
<StarIcon
className={cn(
isFavoriteState ? " fill-yellow-400 stroke-yellow-400" : "",
)}
/> />
<div className="bg-slate-100 text-slate-900 dark:text-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3"> </Button>
<p className="text-lg font-semibold">Details:</p> <div className="flex flex-1 justify-start"></div>
<p>
Hardware:{" "} <TooltipProvider delayDuration={300}>
{Protobuf.Mesh.HardwareModel[device.user?.hwModel ?? 0]} <Tooltip>
</p> <TooltipTrigger asChild>
<p>Node Number: {device.num}</p> <Button
<p>Node Hex: !{numberToHexUnpadded(device.num)}</p> className={cn(
<p> "flex justify-end mr-1 text-white",
Role:{" "} isIgnoredState
{ ? "bg-red-500 dark:bg-red-500 hover:bg-red-600 hover:dark:bg-red-600 text-white dark:text-white"
Protobuf.Config.Config_DeviceConfig_Role[ : "",
device.user?.role ?? 0 )}
] onClick={handleToggleIgnored}
} >
</p> {isIgnoredState ? <BellIcon /> : <BellOffIcon />}
<p> </Button>
Last Heard:{" "} </TooltipTrigger>
{device.lastHeard === 0 ? "Never" : <TimeAgo timestamp={device.lastHeard * 1000} />} <TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
</p> {isIgnoredState ? "Unignore node" : "Ignore node"}
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="destructive"
className="flex justify-end"
onClick={handleNodeRemove}
>
<TrashIcon />
</Button>
</TooltipTrigger>
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
Remove node
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div> </div>
{device.position && ( <Separator className="mt-5 mb-2" />
<div className="flex flex-col flex-wrap space-x-1 space-y-1">
<div className="flex flex-row space-x-2">
<div className="w-full bg-slate-100 text-slate-900 dark:text-slate-100 dark:bg-slate-800 p-3 rounded-lg">
<p className="text-lg font-semibold">Details:</p>
<p>Node Number: {node.num}</p>
<p>Node Hex: !{numberToHexUnpadded(node.num)}</p>
<p>
Role: {Protobuf.Config.Config_DeviceConfig_Role[
node.user?.role ?? 0
].replace(/_/g, " ")}
</p>
<p>
Last Heard: {node.lastHeard === 0
? "Never"
: <TimeAgo timestamp={node.lastHeard * 1000} />}
</p>
<p>
Hardware:{" "}
{(Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0] ??
"Unknown")
.replace(/_/g, " ")}
</p>
</div>
<DeviceImage
className="h-45 w-45 p-2 rounded-lg border-4 border-slate-200 dark:border-slate-800"
deviceType={Protobuf.Mesh
.HardwareModel[node.user?.hwModel ?? 0]}
/>
</div>
</div>
<div>
<div className="text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3"> <div className="text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<p className="text-lg font-semibold">Position:</p> <p className="text-lg font-semibold">Position:</p>
{device.position.latitudeI && device.position.longitudeI && (
{node.position
? (
<>
{node.position.latitudeI &&
node.position.longitudeI && (
<p> <p>
Coordinates:{" "} Coordinates:{" "}
<a <a
className="text-blue-500 dark:text-blue-400" className="text-blue-500 dark:text-blue-400"
href={`https://www.openstreetmap.org/?mlat=${device.position.latitudeI / 1e7 href={`https://www.openstreetmap.org/?mlat=${
}&mlon=${device.position.longitudeI / 1e7 node.position.latitudeI / 1e7
}&layers=N`} }&mlon=${node.position.longitudeI / 1e7}&layers=N`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
{device.position.latitudeI / 1e7},{" "} {node.position.latitudeI / 1e7},{" "}
{device.position.longitudeI / 1e7} {node.position.longitudeI / 1e7}
</a> </a>
</p> </p>
)} )}
{device.position.altitude && ( {node.position.altitude && (
<p>Altitude: {device.position.altitude}m</p> <p>Altitude: {node.position.altitude}m</p>
)} )}
</>
)
: <p>Unknown</p>}
<Button onClick={handleRequestPosition} className="mt-2">
<MapPinnedIcon className="mr-2" />
Request Position
</Button>
</div> </div>
)}
{device.deviceMetrics && ( {node.deviceMetrics && (
<div className="text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3"> <div className="text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50"> <p className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Device Metrics: Device Metrics:
</p> </p>
{deviceMetricsMap.map( {deviceMetricsMap.map(
@@ -140,17 +314,16 @@ export const NodeDetailsDialog = ({
<p key={metric.key}> <p key={metric.key}>
{metric.label}: {metric.format(metric.value)} {metric.label}: {metric.format(metric.value)}
</p> </p>
) ),
)} )}
{device.deviceMetrics.uptimeSeconds && ( {node.deviceMetrics.uptimeSeconds && (
<p> <p>
Uptime:{" "} Uptime:{" "}
<Uptime seconds={device.deviceMetrics.uptimeSeconds} /> <Uptime seconds={node.deviceMetrics.uptimeSeconds} />
</p> </p>
)} )}
</div> </div>
)} )}
</div> </div>
<div className="text-slate-900 dark:text-slate-100 w-full max-w-[464px] bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3"> <div className="text-slate-900 dark:text-slate-100 w-full max-w-[464px] bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
@@ -163,7 +336,7 @@ export const NodeDetailsDialog = ({
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="overflow-x-scroll"> <AccordionContent className="overflow-x-scroll">
<pre className="text-xs w-full"> <pre className="text-xs w-full">
{JSON.stringify(device, null, 2)} {JSON.stringify(node, null, 2)}
</pre> </pre>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
-117
View File
@@ -1,117 +0,0 @@
import { toast } from "../../core/hooks/useToast.ts";
import { useAppStore } from "../../core/stores/appStore.ts";
import { useDevice } from "../../core/stores/deviceStore.ts";
import {
Dialog,
DialogClose,
DialogContent,
DialogHeader,
DialogTitle,
} from "../UI/Dialog.tsx";
import type { Protobuf } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { TrashIcon } from "lucide-react";
import { Button } from "../UI/Button.tsx";
export interface NodeOptionsDialogProps {
node: Protobuf.Mesh.NodeInfo | undefined;
open: boolean;
onOpenChange: () => void;
}
export const NodeOptionsDialog = ({
node,
open,
onOpenChange,
}: NodeOptionsDialogProps) => {
const { setDialogOpen, connection, setActivePage } = useDevice();
const {
setNodeNumToBeRemoved,
setNodeNumDetails,
setChatType,
setActiveChat,
} = useAppStore();
const longName = node?.user?.longName ??
(node ? `!${numberToHexUnpadded(node?.num)}` : "Unknown");
const shortName = node?.user?.shortName ??
(node ? `${numberToHexUnpadded(node?.num).substring(0, 4)}` : "UNK");
function handleDirectMessage() {
if (!node) return;
setChatType("direct");
setActiveChat(node.num);
setActivePage("messages");
}
function handleRequestPosition() {
if (!node) return;
toast({
title: "Requesting position, please wait...",
});
connection?.requestPosition(node.num).then(() =>
toast({
title: "Position request sent.",
})
);
onOpenChange();
}
function handleTraceroute() {
if (!node) return;
toast({
title: "Sending Traceroute, please wait...",
});
connection?.traceRoute(node.num).then(() =>
toast({
title: "Traceroute sent.",
})
);
onOpenChange();
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>{`${longName} (${shortName})`}</DialogTitle>
</DialogHeader>
<div className="flex flex-col space-y-1">
<div>
<Button onClick={handleDirectMessage}>Direct Message</Button>
</div>
<div>
<Button onClick={handleRequestPosition}>Request Position</Button>
</div>
<div>
<Button onClick={handleTraceroute}>Trace Route</Button>
</div>
<div>
<Button
key="remove"
variant="destructive"
onClick={() => {
setNodeNumToBeRemoved(node.num);
setDialogOpen("nodeRemoval", true);
}}
>
<TrashIcon />
Remove
</Button>
</div>
<div>
<Button
onClick={() => {
setNodeNumDetails(node.num);
setDialogOpen("nodeDetails", true);
}}
>
More Details
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
};
+13 -3
View File
@@ -10,12 +10,22 @@ import {
} from "@components/UI/Dialog.tsx"; } from "@components/UI/Dialog.tsx";
export interface PkiRegenerateDialogProps { export interface PkiRegenerateDialogProps {
text: {
title: string;
description: string;
button: string;
};
open: boolean; open: boolean;
onOpenChange: () => void; onOpenChange: () => void;
onSubmit: () => void; onSubmit: () => void;
} }
export const PkiRegenerateDialog = ({ export const PkiRegenerateDialog = ({
text = {
title: "Regenerate Key Pair",
description: "Are you sure you want to regenerate key pair?",
button: "Regenerate",
},
open, open,
onOpenChange, onOpenChange,
onSubmit, onSubmit,
@@ -25,14 +35,14 @@ export const PkiRegenerateDialog = ({
<DialogContent> <DialogContent>
<DialogClose /> <DialogClose />
<DialogHeader> <DialogHeader>
<DialogTitle>Regenerate Key pair?</DialogTitle> <DialogTitle>{text?.title}</DialogTitle>
<DialogDescription> <DialogDescription>
Are you sure you want to regenerate key pair? {text?.description}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="destructive" onClick={() => onSubmit()}> <Button variant="destructive" onClick={() => onSubmit()}>
Regenerate {text?.button}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+5 -3
View File
@@ -108,7 +108,8 @@ export const QRDialog = ({
<div className="flex justify-center"> <div className="flex justify-center">
<button <button
type="button" type="button"
className={`border-slate-900 border-t border-l border-b rounded-l h-10 px-7 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${qrCodeAdd className={`border-slate-900 border-t border-l border-b rounded-l h-10 px-7 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${
qrCodeAdd
? "focus:ring-green-800 bg-green-800 text-white" ? "focus:ring-green-800 bg-green-800 text-white"
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600" : "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
}`} }`}
@@ -118,7 +119,8 @@ export const QRDialog = ({
</button> </button>
<button <button
type="button" type="button"
className={`border-slate-900 border-t border-r border-b rounded-r h-10 px-4 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${!qrCodeAdd className={`border-slate-900 border-t border-r border-b rounded-r h-10 px-4 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${
!qrCodeAdd
? "focus:ring-green-800 bg-green-800 text-white" ? "focus:ring-green-800 bg-green-800 text-white"
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600" : "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
}`} }`}
@@ -133,8 +135,8 @@ export const QRDialog = ({
<Input <Input
value={qrCodeUrl} value={qrCodeUrl}
disabled disabled
className="dark:text-slate-900"
action={{ action={{
key: "copy-value",
icon: ClipboardIcon, icon: ClipboardIcon,
onClick() { onClick() {
void navigator.clipboard.writeText(qrCodeUrl); void navigator.clipboard.writeText(qrCodeUrl);
+30 -26
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { RebootOTADialog } from './RebootOTADialog.tsx'; import { RebootOTADialog } from "./RebootOTADialog.tsx";
import { ReactNode } from "react"; import { ReactNode } from "react";
const rebootOtaMock = vi.fn(); const rebootOtaMock = vi.fn();
@@ -8,41 +8,46 @@ let mockConnection: { rebootOta: (delay: number) => void } | undefined = {
rebootOta: rebootOtaMock, rebootOta: rebootOtaMock,
}; };
vi.mock('@core/stores/deviceStore.ts', () => ({ vi.mock("@core/stores/deviceStore.ts", () => ({
useDevice: () => ({ useDevice: () => ({
connection: mockConnection, connection: mockConnection,
}), }),
})); }));
vi.mock('@components/UI/Button.tsx', async () => { vi.mock("@components/UI/Button.tsx", async () => {
const actual = await vi.importActual('@components/UI/Button.tsx'); const actual = await vi.importActual("@components/UI/Button.tsx");
return { return {
...actual, ...actual,
Button: (props: any) => <button {...props} />, Button: (props) => <button {...props} />,
}; };
}); });
vi.mock('@components/UI/Input.tsx', async () => { vi.mock("@components/UI/Input.tsx", async () => {
const actual = await vi.importActual('@components/UI/Input.tsx'); const actual = await vi.importActual("@components/UI/Input.tsx");
return { return {
...actual, ...actual,
Input: (props: any) => <input {...props} />, Input: (props) => <input {...props} />,
}; };
}); });
vi.mock('@components/UI/Dialog.tsx', () => { vi.mock("@components/UI/Dialog.tsx", () => {
return { return {
Dialog: ({ children }: { children: ReactNode }) => <div>{children}</div>, Dialog: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>, DialogContent: ({ children }: { children: ReactNode }) => (
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>, <div>{children}</div>
),
DialogHeader: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
DialogTitle: ({ children }: { children: ReactNode }) => <h1>{children}</h1>, DialogTitle: ({ children }: { children: ReactNode }) => <h1>{children}</h1>,
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>, DialogDescription: ({ children }: { children: ReactNode }) => (
<p>{children}</p>
),
DialogClose: () => null, DialogClose: () => null,
}; };
}); });
describe("RebootOTADialog", () => {
describe('RebootOTADialog', () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
rebootOtaMock.mockClear(); rebootOtaMock.mockClear();
@@ -52,19 +57,19 @@ describe('RebootOTADialog', () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
it('renders dialog with default input value', () => { it("renders dialog with default input value", () => {
render(<RebootOTADialog open={true} onOpenChange={() => { }} />); render(<RebootOTADialog open onOpenChange={() => {}} />);
expect(screen.getByPlaceholderText(/enter delay/i)).toHaveValue(5); expect(screen.getByPlaceholderText(/enter delay/i)).toHaveValue(5);
expect(screen.getByText(/schedule reboot/i)).toBeInTheDocument(); expect(screen.getByText(/schedule reboot/i)).toBeInTheDocument();
expect(screen.getByText(/reboot to ota mode now/i)).toBeInTheDocument(); expect(screen.getByText(/reboot to ota mode now/i)).toBeInTheDocument();
}); });
it('schedules a reboot with delay and calls rebootOta', async () => { it("schedules a reboot with delay and calls rebootOta", async () => {
const onOpenChangeMock = vi.fn(); const onOpenChangeMock = vi.fn();
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />); render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />);
fireEvent.change(screen.getByPlaceholderText(/enter delay/i), { fireEvent.change(screen.getByPlaceholderText(/enter delay/i), {
target: { value: '3' }, target: { value: "3" },
}); });
fireEvent.click(screen.getByText(/schedule reboot/i)); fireEvent.click(screen.getByText(/schedule reboot/i));
@@ -79,9 +84,9 @@ describe('RebootOTADialog', () => {
}); });
}); });
it('triggers an instant reboot', async () => { it("triggers an instant reboot", async () => {
const onOpenChangeMock = vi.fn(); const onOpenChangeMock = vi.fn();
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />); render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />);
fireEvent.click(screen.getByText(/reboot to ota mode now/i)); fireEvent.click(screen.getByText(/reboot to ota mode now/i));
@@ -91,13 +96,13 @@ describe('RebootOTADialog', () => {
}); });
}); });
it('does not call reboot if connection is undefined', async () => { it("does not call reboot if connection is undefined", async () => {
const onOpenChangeMock = vi.fn(); const onOpenChangeMock = vi.fn();
// simulate no connection // simulate no connection
mockConnection = undefined; mockConnection = undefined;
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />); render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />);
fireEvent.click(screen.getByText(/schedule reboot/i)); fireEvent.click(screen.getByText(/schedule reboot/i));
vi.advanceTimersByTime(5000); vi.advanceTimersByTime(5000);
@@ -110,5 +115,4 @@ describe('RebootOTADialog', () => {
// reset connection for other tests // reset connection for other tests
mockConnection = { rebootOta: rebootOtaMock }; mockConnection = { rebootOta: rebootOtaMock };
}); });
}); });
+8 -6
View File
@@ -19,7 +19,9 @@ export interface RebootOTADialogProps {
const DEFAULT_REBOOT_DELAY = 5; // seconds const DEFAULT_REBOOT_DELAY = 5; // seconds
export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) => { export const RebootOTADialog = (
{ open, onOpenChange }: RebootOTADialogProps,
) => {
const { connection } = useDevice(); const { connection } = useDevice();
const [time, setTime] = useState<number>(DEFAULT_REBOOT_DELAY); const [time, setTime] = useState<number>(DEFAULT_REBOOT_DELAY);
const [isScheduled, setIsScheduled] = useState(false); const [isScheduled, setIsScheduled] = useState(false);
@@ -28,8 +30,8 @@ export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) =>
const handleSetTime = (e: React.ChangeEvent<HTMLInputElement>) => { const handleSetTime = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.validity.valid) { if (!e.target.validity.valid) {
e.preventDefault(); e.preventDefault();
return return;
}; }
const val = e.target.value; const val = e.target.value;
setInputValue(val); setInputValue(val);
@@ -73,7 +75,8 @@ export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) =>
<DialogHeader> <DialogHeader>
<DialogTitle>Reboot to OTA Mode</DialogTitle> <DialogTitle>Reboot to OTA Mode</DialogTitle>
<DialogDescription> <DialogDescription>
Reboot the connected node after a delay into OTA (Over-the-Air) mode. Reboot the connected node after a delay into OTA (Over-the-Air)
mode.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -89,7 +92,7 @@ export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) =>
/> />
<Button onClick={() => handleRebootWithTimeout()} className="w-9/12"> <Button onClick={() => handleRebootWithTimeout()} className="w-9/12">
<ClockIcon className="mr-2" size={18} /> <ClockIcon className="mr-2" size={18} />
{isScheduled ? 'Reboot has been scheduled' : 'Schedule Reboot'} {isScheduled ? "Reboot has been scheduled" : "Schedule Reboot"}
</Button> </Button>
</div> </div>
@@ -101,4 +104,3 @@ export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) =>
</Dialog> </Dialog>
); );
}; };
@@ -1,55 +1,49 @@
import { render, screen, fireEvent } from "@testing-library/react"; import { render } from "@testing-library/react";
import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; import { DeviceContext, useDeviceStore } from "@core/stores/deviceStore.ts";
import { RefreshKeysDialog } from "./RefreshKeysDialog"; import { RefreshKeysDialog } from "./RefreshKeysDialog.tsx";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts"; import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
vi.mock("./useRefreshKeysDialog.ts", () => ({ vi.mock("@core/stores/messageStore");
useRefreshKeysDialog: vi.fn(), vi.mock("./useRefreshKeysDialog");
}));
describe("RefreshKeysDialog Component", () => { const mockUseMessageStore = vi.mocked(useMessageStore);
let handleCloseDialogMock: Mock; const mockUseRefreshKeysDialog = vi.mocked(useRefreshKeysDialog);
let handleNodeRemoveMock: Mock;
let onOpenChangeMock: Mock;
beforeEach(() => { const getInitialState = () =>
handleCloseDialogMock = vi.fn(); useDeviceStore.getInitialState?.() ??
handleNodeRemoveMock = vi.fn(); { devices: new Map(), remoteDevices: new Map() };
onOpenChangeMock = vi.fn();
(useRefreshKeysDialog as Mock).mockReturnValue({ beforeEach(() => {
handleCloseDialog: handleCloseDialogMock, useDeviceStore.setState(getInitialState(), true);
handleNodeRemove: handleNodeRemoveMock, vi.clearAllMocks();
}); });
});
afterEach(() => {
vi.restoreAllMocks();
});
test("does not render dialog if no error exists for active chat", () => {
const deviceId = 1;
const activeChatNum = 54321;
it("renders the dialog with correct content", () => { useDeviceStore.getState().addDevice(deviceId);
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
expect(screen.getByText("Keys Mismatch")).toBeInTheDocument();
expect(screen.getByText("Request New Keys")).toBeInTheDocument();
expect(screen.getByText("Dismiss")).toBeInTheDocument();
});
it("calls handleNodeRemove when 'Request New Keys' button is clicked", () => { const currentDeviceState = useDeviceStore.getState().getDevice(deviceId);
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />); if (!currentDeviceState) throw new Error("Device not found");
fireEvent.click(screen.getByText("Request New Keys"));
expect(handleNodeRemoveMock).toHaveBeenCalled();
});
it("calls handleCloseDialog when 'Dismiss' button is clicked", () => { mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum });
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />); mockUseRefreshKeysDialog.mockReturnValue({
fireEvent.click(screen.getByText("Dismiss")); handleCloseDialog: vi.fn(),
expect(handleCloseDialogMock).toHaveBeenCalled(); handleNodeRemove: vi.fn(),
}); });
it("calls onOpenChange when dialog close button is clicked", () => { const { container } = render(
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />); <DeviceContext.Provider value={currentDeviceState}>
fireEvent.click(screen.getByRole("button", { name: /close/i })); <RefreshKeysDialog open onOpenChange={vi.fn()} />
expect(handleCloseDialogMock).toHaveBeenCalled(); </DeviceContext.Provider>,
}); );
it("does not render when open is false", () => { expect(container.firstChild).toBeNull();
render(<RefreshKeysDialog open={false} onOpenChange={onOpenChangeMock} />);
expect(screen.queryByText("Keys Mismatch")).not.toBeInTheDocument();
});
}); });
@@ -8,27 +8,55 @@ import {
import { Button } from "@components/UI/Button.tsx"; import { Button } from "@components/UI/Button.tsx";
import { LockKeyholeOpenIcon } from "lucide-react"; import { LockKeyholeOpenIcon } from "lucide-react";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts"; import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
export interface RefreshKeysDialogProps { export interface RefreshKeysDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps) => { export const RefreshKeysDialog = (
{ open, onOpenChange }: RefreshKeysDialogProps,
) => {
const { activeChat } = useMessageStore();
const { nodeErrors, getNode } = useDevice();
const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog(); const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog();
const nodeErrorNum = nodeErrors.get(activeChat);
if (!nodeErrorNum) {
return null;
}
const nodeWithError = getNode(nodeErrorNum.node);
const text = {
title: `Keys Mismatch - ${nodeWithError?.user?.longName ?? ""}`,
description: `Your node is unable to send a direct message to node: ${
nodeWithError?.user?.longName ?? ""
} (${
nodeWithError?.user?.shortName ?? ""
}). This is due to the remote node's current public key does not match the previously stored key for this node.`,
};
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-8 flex flex-col gap-2"> <DialogContent
className="max-w-8 flex flex-col gap-2"
aria-describedby={undefined}
>
<DialogClose onClick={handleCloseDialog} /> <DialogClose onClick={handleCloseDialog} />
<DialogHeader> <DialogHeader>
<DialogTitle>Keys Mismatch</DialogTitle> <DialogTitle>{text.title}</DialogTitle>
</DialogHeader> </DialogHeader>
Your node is unable to send a direct message to this node. This is due to the remote node's current public key not matching the previously stored key for this node. {text.description}
<ul className="mt-2"> <ul className="mt-2">
<li className="flex place-items-center gap-2 items-start"> <li className="flex place-items-center gap-2 items-start">
<div className="p-2 bg-slate-500 rounded-lg mt-1"> <div className="p-2 bg-slate-500 rounded-lg mt-1">
<LockKeyholeOpenIcon size={30} className="text-white justify-center" /> <LockKeyholeOpenIcon
size={30}
className="text-white justify-center"
/>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div> <div>
@@ -40,22 +68,19 @@ export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps
<Button <Button
variant="default" variant="default"
onClick={handleNodeRemove} onClick={handleNodeRemove}
className=""
> >
Request New Keys Request New Keys
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
onClick={handleCloseDialog} onClick={handleCloseDialog}
className=""
> >
Dismiss Dismiss
</Button> </Button>
</div> </div>
</li> </li>
</ul> </ul>
{/* </DialogDescription> */}
</DialogContent> </DialogContent>
</Dialog > </Dialog>
); );
}; };
@@ -1,13 +1,13 @@
import { renderHook, act } from "@testing-library/react"; import { act, renderHook } from "@testing-library/react";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts"; import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
vi.mock("@core/stores/appStore.ts", () => ({ vi.mock("@core/stores/messageStore", () => ({
useAppStore: vi.fn(() => ({ activeChat: "chat-123" })), useMessageStore: vi.fn(() => ({ activeChat: "chat-123" })),
})); }));
vi.mock("@core/stores/deviceStore", () => ({
vi.mock("@core/stores/deviceStore.ts", () => ({
useDevice: vi.fn(() => ({ useDevice: vi.fn(() => ({
removeNode: vi.fn(), removeNode: vi.fn(),
setDialogOpen: vi.fn(), setDialogOpen: vi.fn(),
@@ -23,46 +23,54 @@ describe("useRefreshKeysDialog Hook", () => {
let clearNodeErrorMock: Mock; let clearNodeErrorMock: Mock;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks();
removeNodeMock = vi.fn(); removeNodeMock = vi.fn();
setDialogOpenMock = vi.fn(); setDialogOpenMock = vi.fn();
getNodeErrorMock = vi.fn(); getNodeErrorMock = vi.fn().mockReturnValue(undefined);
clearNodeErrorMock = vi.fn(); clearNodeErrorMock = vi.fn();
(useDevice as Mock).mockReturnValue({ vi.mocked(useDevice).mockReturnValue({
removeNode: removeNodeMock, removeNode: removeNodeMock,
setDialogOpen: setDialogOpenMock, setDialogOpen: setDialogOpenMock,
getNodeError: getNodeErrorMock, getNodeError: getNodeErrorMock,
clearNodeError: clearNodeErrorMock, clearNodeError: clearNodeErrorMock,
}); });
vi.mocked(useMessageStore).mockReturnValue({
activeChat: "chat-123",
});
}); });
it("handleNodeRemove should remove the node and update dialog if there is an error", () => { it("handleNodeRemove should remove the node and update dialog if there is an error", () => {
getNodeErrorMock.mockReturnValue({ node: "node-abc" }); getNodeErrorMock.mockReturnValue({ node: "node-abc" });
const { result } = renderHook(() => useRefreshKeysDialog()); const { result } = renderHook(() => useRefreshKeysDialog());
act(() => { act(() => {
result.current.handleNodeRemove(); result.current.handleNodeRemove();
}); });
expect(getNodeErrorMock).toHaveBeenCalledTimes(1);
expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123"); expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123");
expect(clearNodeErrorMock).toHaveBeenCalledTimes(1);
expect(clearNodeErrorMock).toHaveBeenCalledWith("chat-123"); expect(clearNodeErrorMock).toHaveBeenCalledWith("chat-123");
expect(removeNodeMock).toHaveBeenCalledTimes(1);
expect(removeNodeMock).toHaveBeenCalledWith("node-abc"); expect(removeNodeMock).toHaveBeenCalledWith("node-abc");
expect(setDialogOpenMock).toHaveBeenCalledTimes(1);
expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false); expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false);
}); });
it("handleNodeRemove should do nothing if there is no error", () => { it("handleNodeRemove should do nothing if there is no error", () => {
getNodeErrorMock.mockReturnValue(undefined);
const { result } = renderHook(() => useRefreshKeysDialog()); const { result } = renderHook(() => useRefreshKeysDialog());
act(() => { act(() => {
result.current.handleNodeRemove(); result.current.handleNodeRemove();
}); });
expect(getNodeErrorMock).toHaveBeenCalledTimes(1);
expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123");
expect(clearNodeErrorMock).not.toHaveBeenCalled();
expect(removeNodeMock).not.toHaveBeenCalled(); expect(removeNodeMock).not.toHaveBeenCalled();
expect(setDialogOpenMock).not.toHaveBeenCalled(); expect(setDialogOpenMock).not.toHaveBeenCalled();
expect(clearNodeErrorMock).not.toHaveBeenCalled();
}); });
it("handleCloseDialog should close the dialog", () => { it("handleCloseDialog should close the dialog", () => {
@@ -72,6 +80,7 @@ describe("useRefreshKeysDialog Hook", () => {
result.current.handleCloseDialog(); result.current.handleCloseDialog();
}); });
expect(setDialogOpenMock).toHaveBeenCalledTimes(1);
expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false); expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false);
}); });
}); });
@@ -1,10 +1,15 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
export function useRefreshKeysDialog() { export function useRefreshKeysDialog() {
const { removeNode, setDialogOpen, clearNodeError, getNodeError } = useDevice(); const { removeNode, setDialogOpen, clearNodeError, getNodeError } =
const { activeChat } = useAppStore(); useDevice();
const { activeChat } = useMessageStore();
const handleCloseDialog = useCallback(() => {
setDialogOpen("refreshKeys", false);
}, [setDialogOpen]);
const handleNodeRemove = useCallback(() => { const handleNodeRemove = useCallback(() => {
const nodeWithError = getNodeError(activeChat); const nodeWithError = getNodeError(activeChat);
@@ -12,17 +17,12 @@ export function useRefreshKeysDialog() {
return; return;
} }
clearNodeError(activeChat); clearNodeError(activeChat);
handleCloseDialog();; handleCloseDialog();
return removeNode(nodeWithError?.node); return removeNode(nodeWithError?.node);
}, [activeChat, clearNodeError, setDialogOpen, removeNode]); }, [activeChat, clearNodeError, getNodeError, removeNode, handleCloseDialog]);
const handleCloseDialog = useCallback(() => {
setDialogOpen('refreshKeys', false);
}, [setDialogOpen])
return { return {
handleCloseDialog, handleCloseDialog,
handleNodeRemove handleNodeRemove,
}; };
} }
+2 -2
View File
@@ -21,7 +21,7 @@ export const RemoveNodeDialog = ({
open, open,
onOpenChange, onOpenChange,
}: RemoveNodeDialogProps) => { }: RemoveNodeDialogProps) => {
const { connection, nodes, removeNode } = useDevice(); const { connection, getNode, removeNode } = useDevice();
const { nodeNumToBeRemoved } = useAppStore(); const { nodeNumToBeRemoved } = useAppStore();
const onSubmit = () => { const onSubmit = () => {
@@ -42,7 +42,7 @@ export const RemoveNodeDialog = ({
</DialogHeader> </DialogHeader>
<div className="gap-4"> <div className="gap-4">
<form onSubmit={onSubmit}> <form onSubmit={onSubmit}>
<Label>{nodes.get(nodeNumToBeRemoved)?.user?.longName}</Label> <Label>{getNode(nodeNumToBeRemoved)?.user?.longName}</Label>
</form> </form>
</div> </div>
<DialogFooter> <DialogFooter>
-1
View File
@@ -41,7 +41,6 @@ export const ShutdownDialog = ({
type="number" type="number"
value={time} value={time}
onChange={(e) => setTime(Number.parseInt(e.target.value))} onChange={(e) => setTime(Number.parseInt(e.target.value))}
className="dark:text-slate-900"
suffix="Minutes" suffix="Minutes"
/> />
<Button <Button
@@ -23,17 +23,17 @@ export const TracerouteResponseDialog = ({
open, open,
onOpenChange, onOpenChange,
}: TracerouteResponseDialogProps) => { }: TracerouteResponseDialogProps) => {
const { nodes } = useDevice(); const { getNode } = useDevice();
const route: number[] = traceroute?.data.route ?? []; const route: number[] = traceroute?.data.route ?? [];
const routeBack: number[] = traceroute?.data.routeBack ?? []; const routeBack: number[] = traceroute?.data.routeBack ?? [];
const snrTowards = traceroute?.data.snrTowards ?? []; const snrTowards = (traceroute?.data.snrTowards ?? []).map((snr) => snr / 4);
const snrBack = traceroute?.data.snrBack ?? []; const snrBack = (traceroute?.data.snrBack ?? []).map((snr) => snr / 4);
const from = nodes.get(traceroute?.from ?? 0); const from = getNode(traceroute?.from ?? 0);
const longName = from?.user?.longName ?? const longName = from?.user?.longName ??
(from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown"); (from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown");
const shortName = from?.user?.shortName ?? const shortName = from?.user?.shortName ??
(from ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : "UNK"); (from ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : "UNK");
const to = nodes.get(traceroute?.to ?? 0); const to = getNode(traceroute?.to ?? 0);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
@@ -1,6 +1,6 @@
// deno-lint-ignore-file // deno-lint-ignore-file
import { render, screen, fireEvent } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx"; import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
import { eventBus } from "@core/utils/eventBus.ts"; import { eventBus } from "@core/utils/eventBus.ts";
import { DeviceWrapper } from "@app/DeviceWrapper.tsx"; import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
@@ -10,41 +10,58 @@ describe("UnsafeRolesDialog", () => {
setDialogOpen: vi.fn(), setDialogOpen: vi.fn(),
}; };
const renderWithDeviceContext = (ui: any) => { const renderWithDeviceContext = (ui: React.ReactNode) => {
return render( return render(
<DeviceWrapper device={mockDevice}> <DeviceWrapper device={mockDevice}>
{ui} {ui}
</DeviceWrapper> </DeviceWrapper>,
); );
}; };
it("renders the dialog when open is true", () => { it("renders the dialog when open is true", () => {
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />); renderWithDeviceContext(
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
);
const dialog = screen.getByRole('dialog'); const dialog = screen.getByRole("dialog");
expect(dialog).toBeInTheDocument(); expect(dialog).toBeInTheDocument();
expect(screen.getByText(/I have read the/i)).toBeInTheDocument(); expect(screen.getByText(/I have read the/i)).toBeInTheDocument();
expect(screen.getByText(/understand the implications/i)).toBeInTheDocument(); expect(screen.getByText(/understand the implications/i))
.toBeInTheDocument();
const links = screen.getAllByRole('link'); const links = screen.getAllByRole("link");
expect(links).toHaveLength(2); expect(links).toHaveLength(2);
expect(links[0]).toHaveTextContent('Device Role Documentation'); expect(links[0]).toHaveTextContent("Device Role Documentation");
expect(links[1]).toHaveTextContent('Choosing The Right Device Role'); expect(links[1]).toHaveTextContent("Choosing The Right Device Role");
}); });
it("displays the correct links", () => { it("displays the correct links", () => {
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />); renderWithDeviceContext(
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
);
const docLink = screen.getByRole("link", { name: /Device Role Documentation/i }); const docLink = screen.getByRole("link", {
const blogLink = screen.getByRole("link", { name: /Choosing The Right Device Role/i }); name: /Device Role Documentation/i,
});
const blogLink = screen.getByRole("link", {
name: /Choosing The Right Device Role/i,
});
expect(docLink).toHaveAttribute("href", "https://meshtastic.org/docs/configuration/radio/device/"); expect(docLink).toHaveAttribute(
expect(blogLink).toHaveAttribute("href", "https://meshtastic.org/blog/choosing-the-right-device-role/"); "href",
"https://meshtastic.org/docs/configuration/radio/device/",
);
expect(blogLink).toHaveAttribute(
"href",
"https://meshtastic.org/blog/choosing-the-right-device-role/",
);
}); });
it("does not allow confirmation until checkbox is checked", () => { it("does not allow confirmation until checkbox is checked", () => {
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />); renderWithDeviceContext(
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
);
const confirmButton = screen.getByRole("button", { name: /confirm/i }); const confirmButton = screen.getByRole("button", { name: /confirm/i });
@@ -58,27 +75,37 @@ describe("UnsafeRolesDialog", () => {
it("emits the correct event when closing via close button", () => { it("emits the correct event when closing via close button", () => {
const eventSpy = vi.spyOn(eventBus, "emit"); const eventSpy = vi.spyOn(eventBus, "emit");
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />); renderWithDeviceContext(
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
);
const dismissButton = screen.getByRole("button", { name: /close/i }); const dismissButton = screen.getByRole("button", { name: /close/i });
fireEvent.click(dismissButton); fireEvent.click(dismissButton);
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", { action: "dismiss" }); expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", {
action: "dismiss",
});
}); });
it("emits the correct event when dismissing", () => { it("emits the correct event when dismissing", () => {
const eventSpy = vi.spyOn(eventBus, "emit"); const eventSpy = vi.spyOn(eventBus, "emit");
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />); renderWithDeviceContext(
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
);
const dismissButton = screen.getByRole("button", { name: /dismiss/i }); const dismissButton = screen.getByRole("button", { name: /dismiss/i });
fireEvent.click(dismissButton); fireEvent.click(dismissButton);
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", { action: "dismiss" }); expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", {
action: "dismiss",
});
}); });
it("emits the correct event when confirming", () => { it("emits the correct event when confirming", () => {
const eventSpy = vi.spyOn(eventBus, "emit"); const eventSpy = vi.spyOn(eventBus, "emit");
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />); renderWithDeviceContext(
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
);
const checkbox = screen.getByRole("checkbox"); const checkbox = screen.getByRole("checkbox");
const confirmButton = screen.getByRole("button", { name: /confirm/i }); const confirmButton = screen.getByRole("button", { name: /confirm/i });
@@ -86,6 +113,8 @@ describe("UnsafeRolesDialog", () => {
fireEvent.click(checkbox); fireEvent.click(checkbox);
fireEvent.click(confirmButton); fireEvent.click(confirmButton);
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", { action: "confirm" }); expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", {
action: "confirm",
});
}); });
}); });
@@ -19,29 +19,40 @@ export interface RouterRoleDialogProps {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
export const UnsafeRolesDialog = ({ open, onOpenChange }: RouterRoleDialogProps) => { export const UnsafeRolesDialog = (
{ open, onOpenChange }: RouterRoleDialogProps,
) => {
const [confirmState, setConfirmState] = useState(false); const [confirmState, setConfirmState] = useState(false);
const { setDialogOpen } = useDevice(); const { setDialogOpen } = useDevice();
const deviceRoleLink = "https://meshtastic.org/docs/configuration/radio/device/"; const deviceRoleLink =
const choosingTheRightDeviceRoleLink = "https://meshtastic.org/blog/choosing-the-right-device-role/"; "https://meshtastic.org/docs/configuration/radio/device/";
const choosingTheRightDeviceRoleLink =
"https://meshtastic.org/blog/choosing-the-right-device-role/";
const handleCloseDialog = (action: 'confirm' | 'dismiss') => { const handleCloseDialog = (action: "confirm" | "dismiss") => {
setDialogOpen('unsafeRoles', false); setDialogOpen("unsafeRoles", false);
setConfirmState(false); setConfirmState(false);
eventBus.emit('dialog:unsafeRoles', { action }); eventBus.emit("dialog:unsafeRoles", { action });
} };
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-8 flex flex-col"> <DialogContent className="max-w-8 flex flex-col">
<DialogClose onClick={() => handleCloseDialog('dismiss')} /> <DialogClose onClick={() => handleCloseDialog("dismiss")} />
<DialogHeader> <DialogHeader>
<DialogTitle>Are you sure?</DialogTitle> <DialogTitle>Are you sure?</DialogTitle>
</DialogHeader> </DialogHeader>
<DialogDescription className="text-md"> <DialogDescription className="text-md">
I have read the <Link href={deviceRoleLink} className="">Device Role Documentation</Link>{" "} I have read the{" "}
and the blog post about <Link href={choosingTheRightDeviceRoleLink}>Choosing The Right Device Role</Link> and understand the implications of changing the role. <Link href={deviceRoleLink} className="">
Device Role Documentation
</Link>{" "}
and the blog post about{" "}
<Link href={choosingTheRightDeviceRoleLink}>
Choosing The Right Device Role
</Link>{" "}
and understand the implications of changing the role.
</DialogDescription> </DialogDescription>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Checkbox <Checkbox
@@ -56,16 +67,20 @@ export const UnsafeRolesDialog = ({ open, onOpenChange }: RouterRoleDialogProps)
<Button <Button
variant="default" variant="default"
name="dismiss" name="dismiss"
onClick={() => handleCloseDialog('dismiss')}> Dismiss onClick={() => handleCloseDialog("dismiss")}
>
Dismiss
</Button> </Button>
<Button <Button
variant="default" variant="default"
name="confirm" name="confirm"
disabled={!confirmState} disabled={!confirmState}
onClick={() => handleCloseDialog('confirm')}> Confirm onClick={() => handleCloseDialog("confirm")}
>
Confirm
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog > </Dialog>
); );
}; };
@@ -1,9 +1,12 @@
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, Mock, vi } from "vitest";
import { renderHook } from '@testing-library/react'; import { renderHook } from "@testing-library/react";
import { useUnsafeRolesDialog, UNSAFE_ROLES } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog"; import {
import { eventBus } from "@core/utils/eventBus"; UNSAFE_ROLES,
useUnsafeRolesDialog,
} from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
import { eventBus } from "@core/utils/eventBus.ts";
vi.mock('@core/utils/eventBus', () => ({ vi.mock("@core/utils/eventBus", () => ({
eventBus: { eventBus: {
on: vi.fn(), on: vi.fn(),
off: vi.fn(), off: vi.fn(),
@@ -15,13 +18,13 @@ const mockDevice = {
setDialogOpen: vi.fn(), setDialogOpen: vi.fn(),
}; };
vi.mock('@core/stores/deviceStore', () => ({ vi.mock("@core/stores/deviceStore", () => ({
useDevice: () => ({ useDevice: () => ({
setDialogOpen: mockDevice.setDialogOpen, setDialogOpen: mockDevice.setDialogOpen,
}), }),
})); }));
describe('useUnsafeRolesDialog', () => { describe("useUnsafeRolesDialog", () => {
beforeEach(() => { beforeEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
}); });
@@ -34,80 +37,111 @@ describe('useUnsafeRolesDialog', () => {
return renderHook(() => useUnsafeRolesDialog()); return renderHook(() => useUnsafeRolesDialog());
}; };
describe('handleCloseDialog', () => { describe("handleCloseDialog", () => {
it('should call setDialogOpen with correct parameters when dialog is closed', () => { it("should call setDialogOpen with correct parameters when dialog is closed", () => {
const { result } = renderUnsafeRolesHook(); const { result } = renderUnsafeRolesHook();
result.current.handleCloseDialog(); result.current.handleCloseDialog();
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith('unsafeRoles', false); expect(mockDevice.setDialogOpen).toHaveBeenCalledWith(
"unsafeRoles",
false,
);
}); });
}); });
describe('validateRoleSelection', () => { describe("validateRoleSelection", () => {
it('should resolve with true for safe roles without opening dialog', async () => { it("should resolve with true for safe roles without opening dialog", async () => {
const { result } = renderUnsafeRolesHook(); const { result } = renderUnsafeRolesHook();
const safeRole = 'SAFE_ROLE'; const safeRole = "SAFE_ROLE";
const validationResult = await result.current.validateRoleSelection(safeRole); const validationResult = await result.current.validateRoleSelection(
safeRole,
);
expect(validationResult).toBe(true); expect(validationResult).toBe(true);
expect(mockDevice.setDialogOpen).not.toHaveBeenCalled(); expect(mockDevice.setDialogOpen).not.toHaveBeenCalled();
}); });
it('should open dialog for unsafe roles and resolve with true when confirmed', async () => { it("should open dialog for unsafe roles and resolve with true when confirmed", async () => {
const { result } = renderUnsafeRolesHook(); const { result } = renderUnsafeRolesHook();
const validationPromise = result.current.validateRoleSelection(UNSAFE_ROLES[0]); const validationPromise = result.current.validateRoleSelection(
UNSAFE_ROLES[0],
);
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith('unsafeRoles', true); expect(mockDevice.setDialogOpen).toHaveBeenCalledWith(
expect(eventBus.on).toHaveBeenCalledWith('dialog:unsafeRoles', expect.any(Function)); "unsafeRoles",
true,
);
expect(eventBus.on).toHaveBeenCalledWith(
"dialog:unsafeRoles",
expect.any(Function),
);
const onHandler = (eventBus.on as Mock).mock.calls[0][1]; const onHandler = (eventBus.on as Mock).mock.calls[0][1];
onHandler({ action: 'confirm' }); onHandler({ action: "confirm" });
const validationResult = await validationPromise; const validationResult = await validationPromise;
expect(validationResult).toBe(true); expect(validationResult).toBe(true);
expect(eventBus.off).toHaveBeenCalledWith('dialog:unsafeRoles', onHandler); expect(eventBus.off).toHaveBeenCalledWith(
"dialog:unsafeRoles",
onHandler,
);
}); });
it('should resolve with false when user dismisses the dialog', async () => { it("should resolve with false when user dismisses the dialog", async () => {
const { result } = renderUnsafeRolesHook(); const { result } = renderUnsafeRolesHook();
const validationPromise = result.current.validateRoleSelection(UNSAFE_ROLES[0]); const validationPromise = result.current.validateRoleSelection(
UNSAFE_ROLES[0],
);
const onHandler = (eventBus.on as Mock).mock.calls[0][1]; const onHandler = (eventBus.on as Mock).mock.calls[0][1];
onHandler({ action: 'dismiss' }); onHandler({ action: "dismiss" });
const validationResult = await validationPromise; const validationResult = await validationPromise;
expect(validationResult).toBe(false); expect(validationResult).toBe(false);
expect(eventBus.off).toHaveBeenCalledWith('dialog:unsafeRoles', onHandler); expect(eventBus.off).toHaveBeenCalledWith(
"dialog:unsafeRoles",
onHandler,
);
}); });
it('should clean up event listener after response', async () => { it("should clean up event listener after response", async () => {
const { result } = renderUnsafeRolesHook(); const { result } = renderUnsafeRolesHook();
const validationPromise = result.current.validateRoleSelection(UNSAFE_ROLES[1]); const validationPromise = result.current.validateRoleSelection(
UNSAFE_ROLES[1],
);
const onHandler = (eventBus.on as Mock).mock.calls[0][1]; const onHandler = (eventBus.on as Mock).mock.calls[0][1];
onHandler({ action: 'confirm' }); onHandler({ action: "confirm" });
await validationPromise; await validationPromise;
expect(eventBus.off).toHaveBeenCalledWith('dialog:unsafeRoles', onHandler); expect(eventBus.off).toHaveBeenCalledWith(
"dialog:unsafeRoles",
onHandler,
);
}); });
}); });
it('should work with all unsafe roles', async () => { it("should work with all unsafe roles", async () => {
const { result } = renderUnsafeRolesHook(); const { result } = renderUnsafeRolesHook();
for (const unsafeRole of UNSAFE_ROLES) { for (const unsafeRole of UNSAFE_ROLES) {
mockDevice.setDialogOpen.mockClear(); mockDevice.setDialogOpen.mockClear();
(eventBus.on as Mock).mockClear(); (eventBus.on as Mock).mockClear();
const validationPromise = result.current.validateRoleSelection(unsafeRole); const validationPromise = result.current.validateRoleSelection(
unsafeRole,
);
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith('unsafeRoles', true); expect(mockDevice.setDialogOpen).toHaveBeenCalledWith(
"unsafeRoles",
true,
);
const onHandler = (eventBus.on as Mock).mock.calls[0][1]; const onHandler = (eventBus.on as Mock).mock.calls[0][1];
onHandler({ action: 'confirm' }); onHandler({ action: "confirm" });
const validationResult = await validationPromise; const validationResult = await validationPromise;
@@ -21,7 +21,9 @@ export const useUnsafeRolesDialog = () => {
setDialogOpen("unsafeRoles", true); setDialogOpen("unsafeRoles", true);
return new Promise((resolve) => { return new Promise((resolve) => {
const handleResponse = ({ action }: { action: "confirm" | "dismiss" }) => { const handleResponse = (
{ action }: { action: "confirm" | "dismiss" },
) => {
eventBus.off("dialog:unsafeRoles", handleResponse); eventBus.off("dialog:unsafeRoles", handleResponse);
resolve(action === "confirm"); resolve(action === "confirm");
}; };
@@ -29,7 +31,7 @@ export const useUnsafeRolesDialog = () => {
eventBus.on("dialog:unsafeRoles", handleResponse); eventBus.on("dialog:unsafeRoles", handleResponse);
}); });
}, },
[setDialogOpen] [setDialogOpen],
); );
return { return {
+3 -2
View File
@@ -15,7 +15,6 @@ import {
} from "react-hook-form"; } from "react-hook-form";
import { Heading } from "@components/UI/Typography/Heading.tsx"; import { Heading } from "@components/UI/Typography/Heading.tsx";
interface DisabledBy<T> { interface DisabledBy<T> {
fieldName: Path<T>; fieldName: Path<T>;
selector?: number; selector?: number;
@@ -124,7 +123,9 @@ export function DynamicForm<T extends FieldValues>({
})} })}
</div> </div>
))} ))}
{hasSubmitButton && <Button type="submit" variant="outline">Submit</Button>} {hasSubmitButton && (
<Button type="submit" variant="outline">Submit</Button>
)}
</form> </form>
); );
} }
+25 -28
View File
@@ -3,11 +3,9 @@ import type {
GenericFormElementProps, GenericFormElementProps,
} from "@components/Form/DynamicForm.tsx"; } from "@components/Form/DynamicForm.tsx";
import { Input } from "@components/UI/Input.tsx"; import { Input } from "@components/UI/Input.tsx";
import type { LucideIcon } from "lucide-react";
import { Eye, EyeOff } from "lucide-react";
import type { ChangeEventHandler } from "react"; import type { ChangeEventHandler } from "react";
import { useState } from "react"; import { useState } from "react";
import { useController, type FieldValues } from "react-hook-form"; import { type FieldValues, useController } from "react-hook-form";
export interface InputFieldProps<T> extends BaseFormBuilderProps<T> { export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
type: "text" | "number" | "password"; type: "text" | "number" | "password";
@@ -17,16 +15,15 @@ export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
prefix?: string; prefix?: string;
suffix?: string; suffix?: string;
step?: number; step?: number;
className?: string;
fieldLength?: { fieldLength?: {
min?: number; min?: number;
max?: number; max?: number;
currentValueLength?: number; currentValueLength?: number;
showCharacterCount?: boolean; showCharacterCount?: boolean;
},
action?: {
icon: LucideIcon;
onClick: () => void;
}; };
showPasswordToggle?: boolean;
showCopyButton?: boolean;
}; };
} }
@@ -36,55 +33,55 @@ export function GenericInput<T extends FieldValues>({
field, field,
}: GenericFormElementProps<T, InputFieldProps<T>>) { }: GenericFormElementProps<T, InputFieldProps<T>>) {
const { fieldLength, ...restProperties } = field.properties || {}; const { fieldLength, ...restProperties } = field.properties || {};
const [currentLength, setCurrentLength] = useState<number>(
const [passwordShown, setPasswordShown] = useState(false); fieldLength?.currentValueLength || 0,
const [currentLength, setCurrentLength] = useState<number>(fieldLength?.currentValueLength || 0); );
const { field: controllerField } = useController({ const { field: controllerField } = useController({
name: field.name, name: field.name,
control, control,
}); });
const togglePasswordVisiblity = () => {
setPasswordShown(!passwordShown);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value; const newValue = e.target.value;
if (field.properties?.fieldLength?.max && newValue.length > field.properties?.fieldLength?.max) { if (
field.properties?.fieldLength?.max &&
newValue.length > field.properties?.fieldLength?.max
) {
return; return;
} }
setCurrentLength(newValue.length); setCurrentLength(newValue.length);
if (field.inputChange) field.inputChange(e); if (field.inputChange) field.inputChange(e);
controllerField.onChange(field.type === "number" ? Number.parseFloat(newValue).toString() : newValue); controllerField.onChange(
field.type === "number"
? Number.parseFloat(newValue).toString()
: newValue,
);
}; };
return ( return (
<div className="relative w-full"> <div className="relative w-full">
<Input <Input
type={field.type === "password" && passwordShown ? "text" : field.type} type={field.type}
action={
field.type === "password"
? {
icon: passwordShown ? EyeOff : Eye,
onClick: togglePasswordVisiblity,
}
: undefined
}
step={field.properties?.step} step={field.properties?.step}
value={field.type === "number" ? String(controllerField.value) : controllerField.value} value={field.type === "number"
? String(controllerField.value)
: controllerField.value}
id={field.name} id={field.name}
onChange={handleInputChange} onChange={handleInputChange}
showCopyButton={field.properties?.showCopyButton}
showPasswordToggle={field.properties?.showPasswordToggle ||
field.type === "password"}
className={field.properties?.className}
{...restProperties} {...restProperties}
disabled={disabled} disabled={disabled}
/> />
{fieldLength?.showCharacterCount && fieldLength?.max && ( {fieldLength?.showCharacterCount && fieldLength?.max && (
<div className="absolute inset-y-0 right-0 flex items-center pr-3 text-sm text-slate-500 dark:text-slate-400"> <div className="absolute inset-y-0 right-0 flex items-center pr-3 text-sm text-slate-900 dark:text-slate-200">
{currentLength ?? fieldLength?.currentValueLength}/{fieldLength?.max} {currentLength ?? fieldLength?.currentValueLength}/{fieldLength?.max}
</div> </div>
)} )}
+8 -14
View File
@@ -4,10 +4,9 @@ import type {
} from "@components/Form/DynamicForm.tsx"; } from "@components/Form/DynamicForm.tsx";
import type { ButtonVariant } from "../UI/Button.tsx"; import type { ButtonVariant } from "../UI/Button.tsx";
import { Generator } from "@components/UI/Generator.tsx"; import { Generator } from "@components/UI/Generator.tsx";
import { Eye, EyeOff } from "lucide-react";
import type { ChangeEventHandler } from "react"; import type { ChangeEventHandler } from "react";
import { useState } from "react";
import { Controller, type FieldValues } from "react-hook-form"; import { Controller, type FieldValues } from "react-hook-form";
import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts";
export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> { export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
type: "passwordGenerator"; type: "passwordGenerator";
@@ -15,7 +14,7 @@ export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
hide?: boolean; hide?: boolean;
bits?: { text: string; value: string; key: string }[]; bits?: { text: string; value: string; key: string }[];
devicePSKBitCount: number; devicePSKBitCount: number;
inputChange: ChangeEventHandler; inputChange: ChangeEventHandler<HTMLInputElement> | undefined;
selectChange: (event: string) => void; selectChange: (event: string) => void;
actionButtons: { actionButtons: {
text: string; text: string;
@@ -23,6 +22,8 @@ export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
variant: ButtonVariant; variant: ButtonVariant;
className?: string; className?: string;
}[]; }[];
showPasswordToggle?: boolean;
showCopyButton?: boolean;
} }
export function PasswordGenerator<T extends FieldValues>({ export function PasswordGenerator<T extends FieldValues>({
@@ -30,10 +31,7 @@ export function PasswordGenerator<T extends FieldValues>({
field, field,
disabled, disabled,
}: GenericFormElementProps<T, PasswordGeneratorProps<T>>) { }: GenericFormElementProps<T, PasswordGeneratorProps<T>>) {
const [passwordShown, setPasswordShown] = useState(false); const { isVisible } = usePasswordVisibilityToggle();
const togglePasswordVisiblity = () => {
setPasswordShown(!passwordShown);
};
return ( return (
<Controller <Controller
@@ -41,14 +39,8 @@ export function PasswordGenerator<T extends FieldValues>({
control={control} control={control}
render={({ field: { value, ...rest } }) => ( render={({ field: { value, ...rest } }) => (
<Generator <Generator
type={field.hide && !passwordShown ? "password" : "text"} type={field.hide && !isVisible ? "password" : "text"}
id={field.id} id={field.id}
action={field.hide
? {
icon: passwordShown ? EyeOff : Eye,
onClick: togglePasswordVisiblity,
}
: undefined}
devicePSKBitCount={field.devicePSKBitCount} devicePSKBitCount={field.devicePSKBitCount}
bits={field.bits} bits={field.bits}
inputChange={field.inputChange} inputChange={field.inputChange}
@@ -56,6 +48,8 @@ export function PasswordGenerator<T extends FieldValues>({
value={value} value={value}
variant={field.validationText ? "invalid" : "default"} variant={field.validationText ? "invalid" : "default"}
actionButtons={field.actionButtons} actionButtons={field.actionButtons}
showPasswordToggle={field.showPasswordToggle}
showCopyButton={field.showCopyButton}
{...field.properties} {...field.properties}
{...rest} {...rest}
disabled={disabled} disabled={disabled}
+3 -2
View File
@@ -9,7 +9,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@components/UI/Select.tsx"; } from "@components/UI/Select.tsx";
import { useController, type FieldValues } from "react-hook-form"; import { type FieldValues, useController } from "react-hook-form";
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> { export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
type: "select"; type: "select";
@@ -46,7 +46,8 @@ export function SelectInput<T extends FieldValues>({
control, control,
}); });
const { enumValue, formatEnumName, ...remainingProperties } = field.properties; const { enumValue, formatEnumName, ...remainingProperties } =
field.properties;
const valueToKeyMap: Record<string, string> = {}; const valueToKeyMap: Record<string, string> = {};
const optionsEnumValues: [string, number][] = []; const optionsEnumValues: [string, number][] = [];
+7 -6
View File
@@ -20,20 +20,21 @@ export const FieldWrapper = ({
}: FieldWrapperProps) => ( }: FieldWrapperProps) => (
<div className="pt-6 sm:pt-5"> <div className="pt-6 sm:pt-5">
<fieldset aria-labelledby="label-notifications"> <fieldset aria-labelledby="label-notifications">
<div className="sm:grid sm:grid-cols-3 sm:items-baseline sm:gap-4"> {/* first column = labels/heading, second column = fields, third column = gutter */}
<div className="grid grid-cols-1 lg:grid-cols-[0.6fr_2fr_.1fr] sm:items-baseline gap-4">
<Label htmlFor={fieldName}>{label}</Label> <Label htmlFor={fieldName}>{label}</Label>
<div className="sm:col-span-2"> <div className="max-w-3xl">
<div className="max-w-lg"> <p className="text-sm text-slate-500 dark:text-slate-400">
<p className="text-sm text-slate-500">{description}</p> {description}
</p>
<p hidden={valid ?? true} className="text-sm text-red-500"> <p hidden={valid ?? true} className="text-sm text-red-500">
{validationText} {validationText}
</p> </p>
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4 sm:col-span-2">
<div className="flex items-center">{children}</div> <div className="flex items-center">{children}</div>
</div> </div>
</div> </div>
</div> </div>
</div>
</fieldset> </fieldset>
</div> </div>
); );
+18 -5
View File
@@ -1,4 +1,4 @@
import type { ChannelValidation } from "@app/validation/channel.tsx"; import type { ChannelValidation } from "@app/validation/channel.ts";
import { create } from "@bufbuild/protobuf"; import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useToast } from "@core/hooks/useToast.ts"; import { useToast } from "@core/hooks/useToast.ts";
@@ -97,9 +97,14 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
settings: { settings: {
...channel?.settings, ...channel?.settings,
psk: pass, psk: pass,
moduleSettings: {...channel?.settings?.moduleSettings, moduleSettings: {
positionPrecision: channel?.settings?.moduleSettings?.positionPrecision === undefined ? 10 : channel?.settings?.moduleSettings?.positionPrecision, ...channel?.settings?.moduleSettings,
} positionPrecision:
channel?.settings?.moduleSettings?.positionPrecision ===
undefined
? 10
: channel?.settings?.moduleSettings?.positionPrecision,
},
}, },
}, },
}} }}
@@ -124,7 +129,7 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
{ {
type: "passwordGenerator", type: "passwordGenerator",
name: "settings.psk", name: "settings.psk",
id: 'channel-psk', id: "channel-psk",
label: "Pre-Shared Key", label: "Pre-Shared Key",
description: description:
"Supported PSK lengths: 256-bit, 128-bit, 8-bit, Empty (0-bit)", "Supported PSK lengths: 256-bit, 128-bit, 8-bit, Empty (0-bit)",
@@ -142,6 +147,8 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
hide: true, hide: true,
properties: { properties: {
value: pass, value: pass,
showPasswordToggle: true,
showCopyButton: true,
}, },
}, },
{ {
@@ -206,6 +213,12 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
]} ]}
/> />
<PkiRegenerateDialog <PkiRegenerateDialog
text={{
button: "Regenerate",
title: "Regenerate Pre-Shared Key?",
description:
"Are you sure you want to regenerate the pre-shared key?",
}}
open={preSharedDialogOpen} open={preSharedDialogOpen}
onOpenChange={() => setPreSharedDialogOpen(false)} onOpenChange={() => setPreSharedDialogOpen(false)}
onSubmit={() => preSharedKeyRegenerate()} onSubmit={() => preSharedKeyRegenerate()}
@@ -1,5 +1,5 @@
import { useAppStore } from "../../../core/stores/appStore.ts"; import { useAppStore } from "../../../core/stores/appStore.ts";
import type { BluetoothValidation } from "@app/validation/config/bluetooth.tsx"; import type { BluetoothValidation } from "@app/validation/config/bluetooth.ts";
import { create } from "@bufbuild/protobuf"; import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
@@ -1,21 +1,21 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Device } from '@components/PageComponents/Config/Device/index.tsx'; import { Device } from "@components/PageComponents/Config/Device/index.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts"; import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
import { Protobuf } from "@meshtastic/core"; import { Protobuf } from "@meshtastic/core";
vi.mock('@core/stores/deviceStore', () => ({ vi.mock("@core/stores/deviceStore.ts", () => ({
useDevice: vi.fn() useDevice: vi.fn(),
})); }));
vi.mock('@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog', () => ({ vi.mock("@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts", () => ({
useUnsafeRolesDialog: vi.fn() useUnsafeRolesDialog: vi.fn(),
})); }));
// Mock the DynamicForm component since we're testing the Device component, // Mock the DynamicForm component since we're testing the Device component,
// not the DynamicForm implementation // not the DynamicForm implementation
vi.mock('@components/Form/DynamicForm', () => ({ vi.mock("@components/Form/DynamicForm", () => ({
DynamicForm: vi.fn(({ onSubmit }) => { DynamicForm: vi.fn(({ onSubmit }) => {
// Render a simplified version of the form for testing // Render a simplified version of the form for testing
return ( return (
@@ -28,13 +28,16 @@ vi.mock('@components/Form/DynamicForm', () => ({
onSubmit(mockData); onSubmit(mockData);
}} }}
> >
{Object.entries(Protobuf.Config.Config_DeviceConfig_Role).map(([key, value]) => ( {Object.entries(Protobuf.Config.Config_DeviceConfig_Role).map((
[key, value],
) => (
<option key={key} value={value}> <option key={key} value={value}>
{key} {key}
</option> </option>
))} ))}
</select> </select>
<button type="submit" <button
type="submit"
data-testid="submit-button" data-testid="submit-button"
onClick={() => onSubmit({ role: "CLIENT" })} onClick={() => onSubmit({ role: "CLIENT" })}
> >
@@ -42,10 +45,10 @@ vi.mock('@components/Form/DynamicForm', () => ({
</button> </button>
</div> </div>
); );
}) }),
})); }));
describe('Device component', () => { describe("Device component", () => {
const setWorkingConfigMock = vi.fn(); const setWorkingConfigMock = vi.fn();
const validateRoleSelectionMock = vi.fn(); const validateRoleSelectionMock = vi.fn();
const mockDeviceConfig = { const mockDeviceConfig = {
@@ -63,17 +66,17 @@ describe('Device component', () => {
vi.resetAllMocks(); vi.resetAllMocks();
// Mock the useDevice hook // Mock the useDevice hook
(useDevice as any).mockReturnValue({ useDevice.mockReturnValue({
config: { config: {
device: mockDeviceConfig device: mockDeviceConfig,
}, },
setWorkingConfig: setWorkingConfigMock setWorkingConfig: setWorkingConfigMock,
}); });
// Mock the useUnsafeRolesDialog hook // Mock the useUnsafeRolesDialog hook
validateRoleSelectionMock.mockResolvedValue(true); validateRoleSelectionMock.mockResolvedValue(true);
(useUnsafeRolesDialog as any).mockReturnValue({ useUnsafeRolesDialog.mockReturnValue({
validateRoleSelection: validateRoleSelectionMock validateRoleSelection: validateRoleSelectionMock,
}); });
}); });
@@ -81,48 +84,47 @@ describe('Device component', () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it('should render the Device form', () => { it("should render the Device form", () => {
render(<Device />); render(<Device />);
expect(screen.getByTestId('dynamic-form')).toBeInTheDocument(); expect(screen.getByTestId("dynamic-form")).toBeInTheDocument();
}); });
it('should use the validateRoleSelection from the unsafe roles hook', () => { it("should use the validateRoleSelection from the unsafe roles hook", () => {
render(<Device />); render(<Device />);
expect(useUnsafeRolesDialog).toHaveBeenCalled(); expect(useUnsafeRolesDialog).toHaveBeenCalled();
}); });
it('should call setWorkingConfig when form is submitted', async () => { it("should call setWorkingConfig when form is submitted", async () => {
render(<Device />); render(<Device />);
fireEvent.click(screen.getByTestId('submit-button')); fireEvent.click(screen.getByTestId("submit-button"));
await waitFor(() => { await waitFor(() => {
expect(setWorkingConfigMock).toHaveBeenCalledWith( expect(setWorkingConfigMock).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
payloadVariant: { payloadVariant: {
case: "device", case: "device",
value: expect.objectContaining({ role: "CLIENT" }) value: expect.objectContaining({ role: "CLIENT" }),
} },
}) }),
); );
}); });
}); });
it("should create config with proper structure", async () => {
it('should create config with proper structure', async () => {
render(<Device />); render(<Device />);
// Simulate form submission // Simulate form submission
fireEvent.click(screen.getByTestId('submit-button')); fireEvent.click(screen.getByTestId("submit-button"));
await waitFor(() => { await waitFor(() => {
expect(setWorkingConfigMock).toHaveBeenCalledWith( expect(setWorkingConfigMock).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
payloadVariant: { payloadVariant: {
case: "device", case: "device",
value: expect.any(Object) value: expect.any(Object),
} },
}) }),
); );
}); });
}); });
@@ -16,7 +16,7 @@ export const Device = () => {
case: "device", case: "device",
value: data, value: data,
}, },
}) }),
); );
}; };
return ( return (
@@ -83,16 +83,16 @@ export const Device = () => {
description: "Disable triple click", description: "Disable triple click",
}, },
{ {
type: 'text', type: "text",
name: 'tzdef', name: "tzdef",
label: 'POSIX Timezone', label: "POSIX Timezone",
description: 'The POSIX timezone string for the device', description: "The POSIX timezone string for the device",
properties: { properties: {
fieldLength: { fieldLength: {
max: 64, max: 64,
currentValueLength: config.device?.tzdef?.length, currentValueLength: config.device?.tzdef?.length,
showCharacterCount: true, showCharacterCount: true,
} },
}, },
}, },
{ {
@@ -61,6 +61,12 @@ export const Display = () => {
label: "Compass North Top", label: "Compass North Top",
description: "Fix north to the top of compass", description: "Fix north to the top of compass",
}, },
{
type: "toggle",
name: "use12hClock",
label: "12-Hour Clock",
description: "Use 12-hour clock format",
},
{ {
type: "toggle", type: "toggle",
name: "flipScreen", name: "flipScreen",
@@ -1,131 +1,24 @@
// import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
// import { Network } from '@components/PageComponents/Config/Network/index.tsx'; import { Network } from "@components/PageComponents/Config/Network/index.tsx";
// import { useDevice } from "@core/stores/deviceStore.ts";
// import { Protobuf } from "@meshtastic/core";
// vi.mock('@core/stores/deviceStore', () => ({
// useDevice: vi.fn()
// }));
// vi.mock('@components/Form/DynamicForm', () => ({
// DynamicForm: vi.fn(({ onSubmit }) => {
// return (
// <div data-testid="dynamic-form">
// <select
// data-testid="role-select"
// onChange={(e) => {
// const mockData = { role: e.target.value };
// onSubmit(mockData);
// }}
// >
// {Object.entries(Protobuf.Config.Config_DeviceConfig_Role).map(([key, value]) => (
// <option key={key} value={value}>
// {key}
// </option>
// ))}
// </select>
// <button type="submit"
// data-testid="submit-button"
// onClick={() => onSubmit({ role: "CLIENT" })}
// >
// Submit
// </button>
// </div>
// );
// })
// }));
// describe('Network component', () => {
// const setWorkingConfigMock = vi.fn();
// const mockDeviceConfig = {
// role: "CLIENT",
// buttonGpio: 0,
// buzzerGpio: 0,
// rebroadcastMode: "ALL",
// nodeInfoBroadcastSecs: 300,
// doubleTapAsButtonPress: false,
// disableTripleClick: false,
// ledHeartbeatDisabled: false,
// };
// beforeEach(() => {
// vi.resetAllMocks();
// (useDevice as any).mockReturnValue({
// config: {
// device: mockDeviceConfig
// },
// setWorkingConfig: setWorkingConfigMock
// });
// });
// afterEach(() => {
// vi.clearAllMocks();
// });
// it('should render the Network form', () => {
// render(<Network />);
// expect(screen.getByTestId('dynamic-form')).toBeInTheDocument();
// });
// it('should call setWorkingConfig when form is submitted', async () => {
// render(<Network />);
// fireEvent.click(screen.getByTestId('submit-button'));
// await waitFor(() => {
// expect(setWorkingConfigMock).toHaveBeenCalledWith(
// expect.objectContaining({
// payloadVariant: {
// case: "device",
// value: expect.objectContaining({ role: "CLIENT" })
// }
// })
// );
// });
// });
// it('should create config with proper structure', async () => {
// render(<Network />);
// // Simulate form submission
// fireEvent.click(screen.getByTestId('submit-button'));
// await waitFor(() => {
// expect(setWorkingConfigMock).toHaveBeenCalledWith(
// expect.objectContaining({
// payloadVariant: {
// case: "network",
// value: expect.any(Object)
// }
// })
// );
// });
// });
// });
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { Network } from '@components/PageComponents/Config/Network/index.tsx';
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core"; import { Protobuf } from "@meshtastic/core";
vi.mock('@core/stores/deviceStore', () => ({ vi.mock("@core/stores/deviceStore", () => ({
useDevice: vi.fn() useDevice: vi.fn(),
})); }));
vi.mock('@components/Form/DynamicForm', async () => { vi.mock("@components/Form/DynamicForm", async () => {
const React = await import('react'); const React = await import("react");
const { useState } = React; const { useState } = React;
return { return {
DynamicForm: ({ onSubmit, defaultValues }: any) => { DynamicForm: ({ onSubmit, defaultValues }) => {
const [wifiEnabled, setWifiEnabled] = useState(defaultValues.wifiEnabled ?? false); const [wifiEnabled, setWifiEnabled] = useState(
const [ssid, setSsid] = useState(defaultValues.wifiSsid ?? ''); defaultValues.wifiEnabled ?? false,
const [psk, setPsk] = useState(defaultValues.wifiPsk ?? ''); );
const [ssid, setSsid] = useState(defaultValues.wifiSsid ?? "");
const [psk, setPsk] = useState(defaultValues.wifiPsk ?? "");
return ( return (
<form <form
@@ -166,15 +59,14 @@ vi.mock('@components/Form/DynamicForm', async () => {
}, },
}; };
}); });
;
describe('Network component', () => { describe("Network component", () => {
const setWorkingConfigMock = vi.fn(); const setWorkingConfigMock = vi.fn();
const mockNetworkConfig = { const mockNetworkConfig = {
wifiEnabled: false, wifiEnabled: false,
wifiSsid: '', wifiSsid: "",
wifiPsk: '', wifiPsk: "",
ntpServer: '', ntpServer: "",
ethEnabled: false, ethEnabled: false,
addressMode: Protobuf.Config.Config_NetworkConfig_AddressMode.DHCP, addressMode: Protobuf.Config.Config_NetworkConfig_AddressMode.DHCP,
ipv4Config: { ipv4Config: {
@@ -185,17 +77,17 @@ describe('Network component', () => {
}, },
enabledProtocols: enabledProtocols:
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST, Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
rsyslogServer: '', rsyslogServer: "",
}; };
beforeEach(() => { beforeEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
(useDevice as any).mockReturnValue({ useDevice.mockReturnValue({
config: { config: {
network: mockNetworkConfig network: mockNetworkConfig,
}, },
setWorkingConfig: setWorkingConfigMock setWorkingConfig: setWorkingConfigMock,
}); });
}); });
@@ -203,21 +95,21 @@ describe('Network component', () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it('should render the Network form', () => { it("should render the Network form", () => {
render(<Network />); render(<Network />);
expect(screen.getByTestId('dynamic-form')).toBeInTheDocument(); expect(screen.getByTestId("dynamic-form")).toBeInTheDocument();
}); });
it('should disable SSID and PSK fields when wifi is off', () => { it("should disable SSID and PSK fields when wifi is off", () => {
render(<Network />); render(<Network />);
expect(screen.getByLabelText("SSID")).toBeDisabled(); expect(screen.getByLabelText("SSID")).toBeDisabled();
expect(screen.getByLabelText("PSK")).toBeDisabled(); expect(screen.getByLabelText("PSK")).toBeDisabled();
}); });
it('should enable SSID and PSK when wifi is toggled on', async () => { it("should enable SSID and PSK when wifi is toggled on", async () => {
render(<Network />); render(<Network />);
const toggle = screen.getByLabelText("WiFi Enabled"); const toggle = screen.getByLabelText("WiFi Enabled");
screen.debug() screen.debug();
fireEvent.click(toggle); // turns wifiEnabled = true fireEvent.click(toggle); // turns wifiEnabled = true
@@ -227,7 +119,7 @@ describe('Network component', () => {
}); });
}); });
it('should call setWorkingConfig with the right structure on submit', async () => { it("should call setWorkingConfig with the right structure on submit", async () => {
render(<Network />); render(<Network />);
fireEvent.click(screen.getByTestId("submit-button")); fireEvent.click(screen.getByTestId("submit-button"));
@@ -239,28 +131,28 @@ describe('Network component', () => {
case: "network", case: "network",
value: expect.objectContaining({ value: expect.objectContaining({
wifiEnabled: false, wifiEnabled: false,
wifiSsid: '', wifiSsid: "",
wifiPsk: '', wifiPsk: "",
ntpServer: '', ntpServer: "",
ethEnabled: false, ethEnabled: false,
rsyslogServer: '', rsyslogServer: "",
}) }),
} },
}) }),
); );
}); });
}); });
it('should submit valid data after enabling wifi and entering SSID and PSK', async () => { it("should submit valid data after enabling wifi and entering SSID and PSK", async () => {
render(<Network />); render(<Network />);
fireEvent.click(screen.getByLabelText("WiFi Enabled")); fireEvent.click(screen.getByLabelText("WiFi Enabled"));
fireEvent.change(screen.getByLabelText("SSID"), { fireEvent.change(screen.getByLabelText("SSID"), {
target: { value: "MySSID" } target: { value: "MySSID" },
}); });
fireEvent.change(screen.getByLabelText("PSK"), { fireEvent.change(screen.getByLabelText("PSK"), {
target: { value: "MySecretPSK" } target: { value: "MySecretPSK" },
}); });
fireEvent.click(screen.getByTestId("submit-button")); fireEvent.click(screen.getByTestId("submit-button"));
@@ -273,10 +165,10 @@ describe('Network component', () => {
value: expect.objectContaining({ value: expect.objectContaining({
wifiEnabled: true, wifiEnabled: true,
wifiSsid: "MySSID", wifiSsid: "MySSID",
wifiPsk: "MySecretPSK" wifiPsk: "MySecretPSK",
}) }),
} },
}) }),
); );
}); });
}); });
@@ -1,4 +1,7 @@
import { NetworkValidationSchema, type NetworkValidation } from "@app/validation/config/network.ts"; import {
type NetworkValidation,
NetworkValidationSchema,
} from "@app/validation/config/network.ts";
import { create } from "@bufbuild/protobuf"; import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
@@ -55,8 +58,8 @@ export const Network = () => {
), ),
dns: convertIntToIpAddress(config.network?.ipv4Config?.dns ?? 0), dns: convertIntToIpAddress(config.network?.ipv4Config?.dns ?? 0),
}, },
enabledProtocols: config.network?.enabledProtocols ?? Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST enabledProtocols: config.network?.enabledProtocols ??
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
}} }}
fieldGroups={[ fieldGroups={[
{ {
@@ -183,10 +186,9 @@ export const Network = () => {
name: "enabledProtocols", name: "enabledProtocols",
label: "Mesh via UDP", label: "Mesh via UDP",
properties: { properties: {
enumValue: enumValue: Protobuf.Config.Config_NetworkConfig_ProtocolFlags,
Protobuf.Config.Config_NetworkConfig_ProtocolFlags,
formatEnumName: true, formatEnumName: true,
} },
}, },
], ],
}, },
@@ -1,16 +1,12 @@
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx"; import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useAppStore } from "@core/stores/appStore.ts"; import { useAppStore } from "@core/stores/appStore.ts";
import { import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts";
getX25519PrivateKey,
getX25519PublicKey,
} from "@core/utils/x25519.ts";
import type { SecurityValidation } from "@app/validation/config/security.ts"; import type { SecurityValidation } from "@app/validation/config/security.ts";
import { create } from "@bufbuild/protobuf"; import { create } from "@bufbuild/protobuf";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core"; import { Protobuf } from "@meshtastic/core";
import { fromByteArray, toByteArray } from "base64-js"; import { fromByteArray, toByteArray } from "base64-js";
import { Eye, EyeOff } from "lucide-react";
import { useReducer } from "react"; import { useReducer } from "react";
import { securityReducer } from "@components/PageComponents/Config/Security/securityReducer.tsx"; import { securityReducer } from "@components/PageComponents/Config/Security/securityReducer.tsx";
@@ -58,7 +54,8 @@ export const Security = () => {
if (input.length % 4 !== 0) { if (input.length % 4 !== 0) {
addError( addError(
fieldName, fieldName,
`${fieldName === "privateKey" ? "Private" : "Admin" `${
fieldName === "privateKey" ? "Private" : "Admin"
} Key is required to be a 256 bit pre-shared key (PSK)`, } Key is required to be a 256 bit pre-shared key (PSK)`,
); );
return; return;
@@ -73,7 +70,8 @@ export const Security = () => {
console.error(e); console.error(e);
addError( addError(
fieldName, fieldName,
`Invalid ${fieldName === "privateKey" ? "Private" : "Admin" `Invalid ${
fieldName === "privateKey" ? "Private" : "Admin"
} Key format`, } Key format`,
); );
} }
@@ -195,11 +193,8 @@ export const Security = () => {
], ],
properties: { properties: {
value: state.privateKey, value: state.privateKey,
action: { showCopyButton: true,
icon: state.privateKeyVisible ? EyeOff : Eye, showPasswordToggle: true,
onClick: () =>
dispatch({ type: "TOGGLE_PRIVATE_KEY_VISIBILITY" }),
},
}, },
}, },
{ {
@@ -211,6 +206,7 @@ export const Security = () => {
"Sent out to other nodes on the mesh to allow them to compute a shared secret key", "Sent out to other nodes on the mesh to allow them to compute a shared secret key",
properties: { properties: {
value: state.publicKey, value: state.publicKey,
showCopyButton: true,
}, },
}, },
], ],
@@ -244,7 +240,7 @@ export const Security = () => {
? getErrorMessage("adminKey") ? getErrorMessage("adminKey")
: "", : "",
inputChange: adminKeyInputChangeEvent, inputChange: adminKeyInputChangeEvent,
selectChange: () => { }, selectChange: () => {},
bits: [{ text: "256 bit", value: "32", key: "bit256" }], bits: [{ text: "256 bit", value: "32", key: "bit256" }],
devicePSKBitCount: state.privateKeyBitCount, devicePSKBitCount: state.privateKeyBitCount,
hide: !state.adminKeyVisible, hide: !state.adminKeyVisible,
@@ -271,11 +267,7 @@ export const Security = () => {
], ],
properties: { properties: {
value: state.adminKey, value: state.adminKey,
action: { showCopyButton: true,
icon: state.adminKeyVisible ? EyeOff : Eye,
onClick: () =>
dispatch({ type: "TOGGLE_ADMIN_KEY_VISIBILITY" }),
},
}, },
}, },
], ],
@@ -302,6 +294,11 @@ export const Security = () => {
]} ]}
/> />
<PkiRegenerateDialog <PkiRegenerateDialog
text={{
button: "Regenerate",
title: "Regenerate Key pair?",
description: "Are you sure you want to regenerate key pair?",
}}
open={state.privateKeyDialogOpen} open={state.privateKeyDialogOpen}
onOpenChange={() => onOpenChange={() =>
dispatch({ type: "SHOW_PRIVATE_KEY_DIALOG", payload: false })} dispatch({ type: "SHOW_PRIVATE_KEY_DIALOG", payload: false })}
+20 -6
View File
@@ -7,10 +7,15 @@ import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts"; import { randId } from "@core/utils/randId.ts";
import { BleConnection, ServiceUuid } from "@meshtastic/js"; import { BleConnection, ServiceUuid } from "@meshtastic/js";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
export const BLE = ({ closeDialog }: TabElementProps) => { export const BLE = (
{ closeDialog }: TabElementProps,
) => {
const [connectionInProgress, setConnectionInProgress] = useState(false);
const [bleDevices, setBleDevices] = useState<BluetoothDevice[]>([]); const [bleDevices, setBleDevices] = useState<BluetoothDevice[]>([]);
const { addDevice } = useDeviceStore(); const { addDevice } = useDeviceStore();
const messageStore = useMessageStore();
const { setSelectedDevice } = useAppStore(); const { setSelectedDevice } = useAppStore();
const updateBleDeviceList = useCallback(async (): Promise<void> => { const updateBleDeviceList = useCallback(async (): Promise<void> => {
@@ -30,19 +35,23 @@ export const BLE = ({ closeDialog }: TabElementProps) => {
device: bleDevice, device: bleDevice,
}); });
device.addConnection(connection); device.addConnection(connection);
subscribeAll(device, connection); subscribeAll(device, connection, messageStore);
closeDialog(); closeDialog();
}; };
return ( return (
<div className="flex w-full flex-col gap-2 p-4"> <fieldset
className="flex w-full flex-col gap-2 p-4"
disabled={connectionInProgress}
>
<div className="flex h-48 flex-col gap-2 overflow-y-auto"> <div className="flex h-48 flex-col gap-2 overflow-y-auto">
{bleDevices.map((device) => ( {bleDevices.map((device) => (
<Button <Button
key={device.id} key={device.id}
className="dark:bg-slate-900 dark:text-white" variant="default"
onClick={() => { onClick={() => {
setConnectionInProgress(true);
onConnect(device); onConnect(device);
}} }}
> >
@@ -54,7 +63,7 @@ export const BLE = ({ closeDialog }: TabElementProps) => {
)} )}
</div> </div>
<Button <Button
className="dark:bg-slate-900 dark:text-white" variant="default"
onClick={async () => { onClick={async () => {
await navigator.bluetooth await navigator.bluetooth
.requestDevice({ .requestDevice({
@@ -65,11 +74,16 @@ export const BLE = ({ closeDialog }: TabElementProps) => {
if (exists === -1) { if (exists === -1) {
setBleDevices(bleDevices.concat(device)); setBleDevices(bleDevices.concat(device));
} }
}).catch((error) => {
console.error("Error requesting device:", error);
setConnectionInProgress(false);
}).finally(() => {
setConnectionInProgress(false);
}); });
}} }}
> >
<span>New device</span> <span>New device</span>
</Button> </Button>
</div> </fieldset>
); );
}; };

Some files were not shown because too many files have changed in this diff Show More