Browse Source

Merge branch 'main' into config-reset-work

pull/652/head
philon- 1 year ago
parent
commit
fccf59143e
  1. 3
      .github/pull_request_template.md
  2. 25
      .github/workflows/pr.yml
  3. 112
      CONTRIBUTING_I18N_DEVELOPER_GUIDE.md
  4. 31
      CONTRIBUTING_TRANSLATIONS.md
  5. 18
      deno.json
  6. 715
      deno.lock
  7. 20
      package.json
  8. 108
      src/components/BatteryStatus.tsx
  9. 6
      src/components/Dialog/DeviceNameDialog.tsx
  10. 24
      src/components/Dialog/ImportDialog.tsx
  11. 66
      src/components/Dialog/LocationResponseDialog.tsx
  12. 149
      src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx
  13. 10
      src/components/Dialog/QRDialog.tsx
  14. 8
      src/components/Dialog/RebootDialog.tsx
  15. 22
      src/components/Dialog/RebootOTADialog.test.tsx
  16. 86
      src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.test.ts
  17. 20
      src/components/Dialog/TracerouteResponseDialog.tsx
  18. 134
      src/components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.test.tsx
  19. 314
      src/components/Form/DynamicForm.test.tsx
  20. 18
      src/components/Form/FormSelect.tsx
  21. 131
      src/components/PageComponents/Config/Device/Device.test.tsx
  22. 173
      src/components/PageComponents/Config/Network/Network.test.tsx
  23. 3
      src/components/PageComponents/Connect/BLE.tsx
  24. 4
      src/components/PageComponents/Connect/HTTP.tsx
  25. 11
      src/components/PageComponents/Connect/Serial.tsx
  26. 16
      src/components/PageComponents/Messages/MessageItem.tsx
  27. 92
      src/components/PageComponents/Messages/TraceRoute.test.tsx
  28. 24
      src/components/PageComponents/Messages/TraceRoute.tsx
  29. 70
      src/components/UI/Button.tsx
  30. 17
      src/components/UI/Checkbox/Checkbox.test.tsx
  31. 4
      src/components/UI/Dialog.tsx
  32. 6
      src/components/UI/Typography/Link.tsx
  33. 294
      src/components/generic/Filter/useFilterNode.ts
  34. 178
      src/components/generic/Table/index.test.tsx
  35. 206
      src/components/generic/Table/index.tsx
  36. 4
      src/core/hooks/useBrowserFeatureDetection.ts
  37. 6
      src/core/hooks/useCookie.ts
  38. 68
      src/core/hooks/usePinnedItems.test.ts
  39. 82
      src/core/stores/deviceStore.mock.ts
  40. 68
      src/core/stores/deviceStore.ts
  41. 15
      src/core/stores/storage/indexDB.ts
  42. 2
      src/core/utils/eventBus.test.ts
  43. 8
      src/core/utils/eventBus.ts
  44. 2
      src/core/utils/ip.test.ts
  45. 18
      src/core/utils/sort.ts
  46. 80
      src/core/utils/test.tsx
  47. 2
      src/i18n/config.ts
  48. 2
      src/i18n/locales/en/commandPalette.json
  49. 6
      src/i18n/locales/en/moduleConfig.json
  50. 6
      src/i18n/locales/en/ui.json
  51. 4
      src/index.css
  52. 36
      src/pages/Dashboard/index.tsx
  53. 81
      src/pages/Messages.test.tsx
  54. 107
      src/pages/Messages.tsx
  55. 227
      src/pages/Nodes/index.tsx
  56. 59
      src/routeTree.gen.ts
  57. 35
      src/routes.tsx
  58. 0
      src/tests/setup.ts
  59. 37
      src/tests/test-utils.tsx
  60. 11
      vite-env.d.ts
  61. 6
      vite.config.ts
  62. 4
      vitest.config.ts

3
.github/pull_request_template.md

@ -46,4 +46,5 @@ Check all that apply. If an item doesn't apply to your PR, you can leave it unch
- [ ] Code follows project style guidelines - [ ] Code follows project style guidelines
- [ ] Documentation has been updated or added - [ ] Documentation has been updated or added
- [ ] Tests have been added or updated - [ ] Tests have been added or updated
- [ ] All i18n translation labels have bee added - [ ] All i18n translation labels have been added (read
CONTRIBUTING_I18N_DEVELOPER_GUIDE.md for more details)

25
.github/workflows/pr.yml

@ -24,18 +24,33 @@ jobs:
key: ${{ runner.os }}-deno-${{ hashFiles('**/deno.lock') }} key: ${{ runner.os }}-deno-${{ hashFiles('**/deno.lock') }}
restore-keys: | restore-keys: |
${{ runner.os }}-deno- ${{ runner.os }}-deno-
- name: Install Dependencies - name: Install Dependencies
run: deno install run: deno install
- name: Cache Dependencies - name: Cache Dependencies
run: deno cache src/index.tsx run: deno cache src/index.tsx
- name: Run linter - name: Get changed files
run: deno task lint id: changed-files
uses: tj-actions/changed-files@v46
with:
files: |
**/*.ts
**/*.tsx
# Uncomment the following lines when you have figured out how to ignore files
# - name: Type check changed files
# if: steps.changed-files.outputs.all_changed_files != ''
# run: deno check ${{ steps.changed-files.outputs.all_changed_files }}
- name: Run linter on changed files
if: steps.changed-files.outputs.all_changed_files != ''
run: deno task lint ${{ steps.changed-files.outputs.all_changed_files }}
- name: Check formatter - name: Check format on changed files
run: deno task format --check if: steps.changed-files.outputs.all_changed_files != ''
run: deno task format --check ${{ steps.changed-files.outputs.all_changed_files }}
- name: Run tests - name: Run tests
run: deno task test run: deno task test

112
CONTRIBUTING_I18N_DEVELOPER_GUIDE.md

@ -0,0 +1,112 @@
# i18n Developer Guide
When developing new components, all user-facing text must be added as an i18n
key and rendered using our translation functions. This ensures your UI can be
translated into multiple languages.
## Adding New i18n Keys
### Search Before Creating
Before adding a new key, please perform a quick search to see if one that fits
your needs already exists. Many common labels like "Save," "Cancel," "Name,"
"Description," "Loading...," or "Error" are likely already present, especially
in the common.json namespace. Reusing existing keys prevents duplication and
ensures consistency across the application. Using your code editor's search
function across the /src/i18n/locales/en/ directory is an effective way to do
this.
### Key Naming and Structure Rules
To maintain consistency and ease of use, please adhere to the following rules
when creating new keys in the JSON files.
- **Keys are camelCase:** `exampleKey`, `anotherExampleKey`.
- **Avoid Deep Nesting:** One or two levels of nesting are acceptable for
grouping related keys (e.g., all labels for a specific menu). However, nesting
deeper than two levels should be avoided to maintain readability and ease of
use.
- **Good (1 level):**
```json
"buttons": {
"save": "Save",
"cancel": "Cancel"
}
```
- **Acceptable (2 levels):**
```json
"userMenu": {
"items": {
"profile": "Profile",
"settings": "Settings"
}
}
```
- **Avoid (3+ levels):**
```json
"userMenu": {
"items": {
"actions": {
"viewProfile": "View Profile"
}
}
}
```
- **Organize for Retrieval, Not UI Layout:** Keys should be named logically for
easy retrieval, not to mirror the layout of your component.
### Namespace Rules
We use namespaces to organize keys. All source keys are added to the English
(`en`) files located at `/src/i18n/locales/en/`. Place your new keys in the
appropriate file based on these rules:
- `common.json`:
- All button labels (`save`, `cancel`, `submit`, etc.).
- Any text that is repeated and used throughout the application (e.g.,
"Loading...", "Error").
- `ui.json`:
- Labels and text specific to a distinct UI element or view that isn't a
dialog or a config page.
- `dialog.json`:
- All text specific to modal dialogs (titles, body text, prompts).
- `messages.json`:
- Text specifically related to the messaging interface.
- `deviceConfig.json` & `moduleConfig.json`:
- Labels and descriptions for the settings on the Device and Module
configuration pages.
## Using i18n Keys in Components
We use the `useTranslation` hook from `react-i18next` to access the translation
function, `t`.
### Default Namespaces
Our i18next configuration has fallback namespaces configured which includes
`common`, `ui`, and `dialog`. This means you **do not** need to explicitly
specify these namespaces when calling the hook. The system will automatically
check these files for your key.
For any keys in `common.json`, `ui.json`, or `dialog.json`, you can instantiate
the hook simply:
```typescript
import { useTranslation } from "react-i18next";
// In your component
const { t } = useTranslation(["messages"]);
// Usage
return <p>{t("someMessageLabel")}</p>;
```
You can also specify the namespace on a per-call basis using the options object.
This is useful if a component primarily uses a default namespace but needs a
single key from another.
```typescript
const { t } = useTranslation();
return <p>{t("someMessageLabel", { ns: "messages" })}</p>;
```

31
CONTRIBUTING_TRANSLATIONS.md

@ -0,0 +1,31 @@
# Contributing Translations
Thank you for your interest in making the Meshtastic Web Client accessible to a
global audience! Your translation efforts are greatly appreciated.
## Our Translation Platform: Crowdin
We manage all our translations through a platform called
[Crowdin](https://crowdin.com/). This allows for a collaborative and streamlined
translation process. All translation work should be done on our Crowdin project,
not directly in the code repository via Pull Requests.
### How to Get Started
1. **Create a Crowdin Account:** If you don't already have one, sign up for a
free account on Crowdin.
2. **Join Our Project:** Please ask for a link to our specific Crowdin project
on the Meshtastic Discord.
3. **Request Translator Role:** Once you have an account, join the Meshtastic
Discord and notify an admin in the `#web` channel. They will grant you the
necessary permissions to start translating.
4. **Start Translating:** Once you have your role, you can begin translating the
source labels into your native language directly on the Crowdin platform.
### Language Activation
A new language will only be added to the web client and appear in the language
picker once its translation is 100% complete on Crowdin. The repository
maintainers will handle this process once the milestone is reached.
Thank you for helping us bring Meshtastic to more users around the world!

18
deno.json

@ -7,6 +7,7 @@
"@layouts/": "./src/layouts/", "@layouts/": "./src/layouts/",
"@std/path": "jsr:@std/path@^1.1.0" "@std/path": "jsr:@std/path@^1.1.0"
}, },
"include": ["src", "./vite-env.d.ts"],
"compilerOptions": { "compilerOptions": {
"lib": [ "lib": [
"DOM", "DOM",
@ -24,26 +25,35 @@
"types": [ "types": [
"vite/client", "vite/client",
"node", "node",
"@types/web-bluetooth", "npm:@types/w3c-web-serial",
"@types/w3c-web-serial" "npm:@types/web-bluetooth"
], ],
"strictPropertyInitialization": false "strictPropertyInitialization": false
}, },
"fmt": { "fmt": {
"exclude": [ "exclude": [
"src/*.gen.ts", "src/routeTree.gen.ts",
"*.test.ts", "*.test.ts",
"*.test.tsx" "*.test.tsx"
] ]
}, },
"lint": { "lint": {
"exclude": [ "exclude": [
"src/*.gen.ts", "src/routeTree.gen.ts",
"*.test.ts", "*.test.ts",
"*.test.tsx" "*.test.tsx"
], ],
"report": "pretty" "report": "pretty"
}, },
"exclude": [
"routeTree.gen.ts",
"node_modules/",
"dist",
"build",
"coverage",
"out",
".vscode-test"
],
"unstable": [ "unstable": [
"sloppy-imports" "sloppy-imports"
] ]

715
deno.lock

File diff suppressed because it is too large

20
package.json

@ -13,6 +13,7 @@
"dev": "deno task dev:ui", "dev": "deno task dev:ui",
"dev:ui": "VITE_APP_VERSION=development deno run -A npm:vite dev", "dev:ui": "VITE_APP_VERSION=development deno run -A npm:vite dev",
"test": "deno run -A npm:vitest", "test": "deno run -A npm:vitest",
"check": "deno check",
"preview": "deno run -A npm:vite preview", "preview": "deno run -A npm:vite preview",
"package": "gzipper c -i html,js,css,png,ico,svg,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ ." "package": "gzipper c -i html,js,css,png,ico,svg,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ ."
}, },
@ -35,10 +36,11 @@
"homepage": "https://meshtastic.org", "homepage": "https://meshtastic.org",
"dependencies": { "dependencies": {
"@bufbuild/protobuf": "^2.2.5", "@bufbuild/protobuf": "^2.2.5",
"@meshtastic/core": "npm:@jsr/[email protected]", "@meshtastic/core": "npm:@jsr/[email protected]",
"@meshtastic/transport-http": "npm:@jsr/[email protected]", "@meshtastic/js": "npm:@jsr/[email protected]",
"@meshtastic/transport-web-bluetooth": "npm:@jsr/[email protected]", "@meshtastic/transport-http": "npm:@jsr/meshtastic__transport-http",
"@meshtastic/transport-web-serial": "npm:@jsr/[email protected]", "@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth",
"@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial",
"@noble/curves": "^1.9.0", "@noble/curves": "^1.9.0",
"@radix-ui/react-accordion": "^1.2.8", "@radix-ui/react-accordion": "^1.2.8",
"@radix-ui/react-checkbox": "^1.2.3", "@radix-ui/react-checkbox": "^1.2.3",
@ -60,6 +62,7 @@
"@tanstack/react-router-devtools": "^1.120.16", "@tanstack/react-router-devtools": "^1.120.16",
"@tanstack/router-devtools": "^1.120.15", "@tanstack/router-devtools": "^1.120.15",
"@turf/turf": "^7.2.0", "@turf/turf": "^7.2.0",
"@types/web-bluetooth": "^0.0.21",
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -81,10 +84,8 @@
"react-map-gl": "8.0.4", "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-i18n-ally": "^6.0.1", "zod": "^3.25.62",
"vite-plugin-node-polyfills": "^0.23.0", "zustand": "5.0.5"
"zod": "^3.25.0",
"zustand": "5.0.4"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.5", "@tailwindcss/postcss": "^4.1.5",
@ -93,13 +94,12 @@
"@testing-library/react": "^16.3.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.318", "@types/chrome": "^0.0.318",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.15.3", "@types/node": "^22.15.3",
"@types/react": "^19.1.2", "@types/react": "^19.1.2",
"@types/react-dom": "^19.1.3", "@types/react-dom": "^19.1.3",
"@types/serviceworker": "^0.0.133", "@types/serviceworker": "^0.0.133",
"@types/js-cookie": "^3.0.6",
"@types/w3c-web-serial": "^1.0.8", "@types/w3c-web-serial": "^1.0.8",
"@types/web-bluetooth": "^0.0.21",
"@vitejs/plugin-react": "^4.4.1", "@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"gzipper": "^8.2.1", "gzipper": "^8.2.1",

108
src/components/BatteryStatus.tsx

@ -8,56 +8,45 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { DeviceMetrics } from "./types.ts"; import { DeviceMetrics } from "./types.ts";
interface BatteryStateConfig { type BatteryStatusKey = keyof typeof BATTERY_STATUS;
condition: (level: number) => boolean;
Icon: React.ElementType; interface BatteryStatusProps {
className: string; deviceMetrics?: DeviceMetrics | null;
text: (level: number) => string;
} }
interface BatteryStatusProps { interface BatteryStatusProps {
deviceMetrics?: DeviceMetrics | null; deviceMetrics?: DeviceMetrics | null;
} }
const getBatteryStates = ( interface StatusConfig {
t: (key: string, options?: object) => string, Icon: React.ElementType;
): BatteryStateConfig[] => { className: string;
return [ text: string;
{ }
condition: (level) => level > 100,
Icon: PlugZapIcon, const BATTERY_STATUS = {
className: "text-gray-500", PLUGGED_IN: "PLUGGED_IN",
text: () => t("batteryStatus.pluggedIn"), FULL: "FULL",
}, MEDIUM: "MEDIUM",
{ LOW: "LOW",
condition: (level) => level > 80, } as const;
Icon: BatteryFullIcon,
className: "text-green-500",
text: (level) => t("batteryStatus.charging", { level }),
},
{
condition: (level) => level > 20,
Icon: BatteryMediumIcon,
className: "text-yellow-500",
text: (level) => t("batteryStatus.charging", { level }),
},
{
condition: () => true,
Icon: BatteryLowIcon,
className: "text-red-500",
text: (level) => t("batteryStatus.charging", { level }),
},
];
};
const getBatteryState = ( export const getBatteryStatus = (level: number): BatteryStatusKey => {
level: number, if (level > 100) {
batteryStates: BatteryStateConfig[], return BATTERY_STATUS.PLUGGED_IN;
) => { }
return batteryStates.find((state) => state.condition(level)); if (level > 80) {
return BATTERY_STATUS.FULL;
}
if (level > 20) {
return BATTERY_STATUS.MEDIUM;
}
return BATTERY_STATUS.LOW;
}; };
const BatteryStatus: React.FC<BatteryStatusProps> = ({ deviceMetrics }) => { const BatteryStatus: React.FC<BatteryStatusProps> = ({ deviceMetrics }) => {
const { t } = useTranslation();
if ( if (
deviceMetrics?.batteryLevel === undefined || deviceMetrics?.batteryLevel === undefined ||
deviceMetrics?.batteryLevel === null deviceMetrics?.batteryLevel === null
@ -65,16 +54,39 @@ const BatteryStatus: React.FC<BatteryStatusProps> = ({ deviceMetrics }) => {
return null; return null;
} }
const { t } = useTranslation();
const batteryStates = getBatteryStates(t);
const { batteryLevel } = deviceMetrics; const { batteryLevel } = deviceMetrics;
const currentState = getBatteryState(batteryLevel, batteryStates) ??
batteryStates[batteryStates.length - 1];
const BatteryIcon = currentState.Icon; const statusKey = getBatteryStatus(batteryLevel);
const iconClassName = currentState.className;
const statusText = currentState.text(batteryLevel); const statusConfigMap: Record<BatteryStatusKey, StatusConfig> = {
[BATTERY_STATUS.PLUGGED_IN]: {
Icon: PlugZapIcon,
className: "text-gray-500",
text: t("batteryStatus.pluggedIn"),
},
[BATTERY_STATUS.FULL]: {
Icon: BatteryFullIcon,
className: "text-green-500",
text: t("batteryStatus.charging", { level: batteryLevel }),
},
[BATTERY_STATUS.MEDIUM]: {
Icon: BatteryMediumIcon,
className: "text-yellow-500",
text: t("batteryStatus.charging", { level: batteryLevel }),
},
[BATTERY_STATUS.LOW]: {
Icon: BatteryLowIcon,
className: "text-red-500",
text: t("batteryStatus.charging", { level: batteryLevel }),
},
};
// 3. Use the key to get the current state configuration
const {
Icon: BatteryIcon,
className: iconClassName,
text: statusText,
} = statusConfigMap[statusKey];
return ( return (
<div <div

6
src/components/Dialog/DeviceNameDialog.tsx

@ -55,10 +55,14 @@ export const DeviceNameDialog = ({
MAX_SHORT_NAME_BYTE_LENGTH, MAX_SHORT_NAME_BYTE_LENGTH,
); );
if (!myNode?.user) {
console.warn("DeviceNameDialog: No user data available");
return null;
}
const onSubmit = handleSubmit((data) => { const onSubmit = handleSubmit((data) => {
connection?.setOwner( connection?.setOwner(
create(Protobuf.Mesh.UserSchema, { create(Protobuf.Mesh.UserSchema, {
...(myNode?.user ?? {}),
...data, ...data,
}), }),
); );

24
src/components/Dialog/ImportDialog.tsx

@ -72,17 +72,19 @@ export const ImportDialog = ({
}, [importDialogInput]); }, [importDialogInput]);
const apply = () => { const apply = () => {
channelSet?.settings.map((ch: unknown, index: number) => { channelSet?.settings.map(
connection?.setChannel( (ch: Protobuf.Channel.ChannelSettings, index: number) => {
create(Protobuf.Channel.ChannelSchema, { connection?.setChannel(
index, create(Protobuf.Channel.ChannelSchema, {
role: index === 0 index,
? Protobuf.Channel.Channel_Role.PRIMARY role: index === 0
: Protobuf.Channel.Channel_Role.SECONDARY, ? Protobuf.Channel.Channel_Role.PRIMARY
settings: ch, : Protobuf.Channel.Channel_Role.SECONDARY,
}), settings: ch,
); }),
}); );
},
);
if (channelSet?.loraConfig) { if (channelSet?.loraConfig) {
connection?.setConfig( connection?.setConfig(

66
src/components/Dialog/LocationResponseDialog.tsx

@ -12,7 +12,7 @@ import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export interface LocationResponseDialogProps { export interface LocationResponseDialogProps {
location: Types.PacketMetadata<Protobuf.Mesh.location> | undefined; location: Types.PacketMetadata<Protobuf.Mesh.Position> | undefined;
open: boolean; open: boolean;
onOpenChange: () => void; onOpenChange: () => void;
} }
@ -33,6 +33,13 @@ export const LocationResponseDialog = ({
? `${numberToHexUnpadded(from?.num).substring(0, 4)}` ? `${numberToHexUnpadded(from?.num).substring(0, 4)}`
: t("unknown.shortName")); : t("unknown.shortName"));
const position = location?.data;
const hasCoordinates = position &&
typeof position.latitudeI === "number" &&
typeof position.longitudeI === "number" &&
typeof position.altitude === "number";
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
@ -45,31 +52,40 @@ export const LocationResponseDialog = ({
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<DialogDescription> <DialogDescription>
<div className="ml-5 flex"> {hasCoordinates
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary"> ? (
<p> <div className="ml-5 flex">
{t("locationResponse.coordinates")} <span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
<a <p>
className="text-blue-500 dark:text-blue-400" {t("locationResponse.coordinates")}
href={`https://www.openstreetmap.org/?mlat=${ <a
location?.data.latitudeI / 1e7 className="text-blue-500 dark:text-blue-400"
}&mlon=${location?.data.longitudeI / 1e7}&layers=N`} href={`https://www.openstreetmap.org/?mlat=${
target="_blank" position.latitudeI ?? 0 / 1e7
rel="noreferrer" }&mlon=${position.longitudeI ?? 0 / 1e7}&layers=N`}
> target="_blank"
{location?.data.latitudeI / 1e7},{" "} rel="noreferrer"
{location?.data.longitudeI / 1e7} >
</a> {" "}
</p> {position.latitudeI ?? 0 / 1e7},{" "}
<p> {position.longitudeI ?? 0 / 1e7}
{t("locationResponse.altitude")} </a>
{location?.data.altitude} </p>
{location?.data.altitde < 1 <p>
? t("unit.meter.one") {t("locationResponse.altitude")} {position.altitude}
: t("unit.meter.plural")} {(position.altitude ?? 0) < 1
? t("unit.meter.one")
: t("unit.meter.plural")}
</p>
</span>
</div>
)
: (
// Optional: Show a message if coordinates are not available
<p className="text-textPrimary">
{t("locationResponse.noCoordinates")}
</p> </p>
</span> )}
</div>
</DialogDescription> </DialogDescription>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

149
src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx

@ -1,149 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useAppStore } from "@core/stores/appStore.ts";
import type { Protobuf } from "@meshtastic/core";
vi.mock("@core/stores/deviceStore");
vi.mock("@core/stores/appStore");
const mockUseDevice = vi.mocked(useDevice);
const mockUseAppStore = vi.mocked(useAppStore);
vi.mock("@tanstack/react-router", () => ({
useNavigate: vi.fn(),
}));
describe("NodeDetailsDialog", () => {
const mockNode = {
num: 1234,
user: {
longName: "Test Node",
shortName: "TN",
hwModel: 1,
role: 1,
},
lastHeard: 1697500000,
position: {
latitudeI: 450000000,
longitudeI: -750000000,
altitude: 200,
},
deviceMetrics: {
airUtilTx: 50.123,
channelUtilization: 75.456,
batteryLevel: 88.789,
voltage: 4.2,
uptimeSeconds: 3600,
},
} as unknown as Protobuf.Mesh.NodeInfo;
beforeEach(() => {
vi.resetAllMocks();
mockUseDevice.mockReturnValue({
getNode: (nodeNum: number) => {
if (nodeNum === 1234) {
return mockNode;
}
return undefined;
},
});
mockUseAppStore.mockReturnValue({
nodeNumDetails: 1234,
});
});
it("renders node details correctly", () => {
render(<NodeDetailsDialog open onOpenChange={() => {}} />);
expect(screen.getByText(/Node Details for Test Node \(TN\)/i))
.toBeInTheDocument();
expect(screen.getByText("Node Number: 1234")).toBeInTheDocument();
expect(screen.getByText(/Node Hex: !/i)).toBeInTheDocument();
expect(screen.getByText(/Last Heard:/i)).toBeInTheDocument();
expect(screen.getByText(/Coordinates:/i)).toBeInTheDocument();
const link = screen.getByRole("link", { name: /^45, -75$/ });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute(
"href",
expect.stringContaining("openstreetmap.org"),
);
expect(screen.getByText(/Altitude: 200m/i)).toBeInTheDocument();
expect(screen.getByText(/Air TX utilization: 50.12%/i)).toBeInTheDocument();
expect(screen.getByText(/Channel utilization: 75.46%/i))
.toBeInTheDocument();
expect(screen.getByText(/Battery level: 88.79%/i)).toBeInTheDocument();
expect(screen.getByText(/Voltage: 4.20V/i)).toBeInTheDocument();
expect(screen.getByText(/Uptime:/i)).toBeInTheDocument();
expect(screen.getByText(/All Raw Metrics:/i)).toBeInTheDocument();
});
it("renders null if node is undefined", () => {
const requestedNodeNum = 5678;
mockUseAppStore.mockReturnValue({
nodeNumDetails: requestedNodeNum,
});
mockUseDevice.mockReturnValue({
getNode: (nodeNum: number) => {
if (nodeNum === requestedNodeNum) {
return undefined;
}
if (nodeNum === 1234) {
return mockNode;
}
return undefined;
},
});
const { container } = render(
<NodeDetailsDialog open onOpenChange={() => {}} />,
);
expect(container.firstChild).toBeNull();
expect(screen.queryByText(/Node Details for/i)).not.toBeInTheDocument();
});
it("renders correctly when position is missing", () => {
const nodeWithoutPosition = { ...mockNode, position: undefined };
mockUseDevice.mockReturnValue({ getNode: () => nodeWithoutPosition });
mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 });
render(<NodeDetailsDialog open onOpenChange={() => {}} />);
expect(screen.queryByText(/Coordinates:/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Altitude:/i)).not.toBeInTheDocument();
expect(screen.getByText(/Node Details for Test Node/i)).toBeInTheDocument();
});
it("renders correctly when deviceMetrics are missing", () => {
const nodeWithoutMetrics = { ...mockNode, deviceMetrics: undefined };
mockUseDevice.mockReturnValue({ getNode: () => nodeWithoutMetrics });
mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 });
render(<NodeDetailsDialog open onOpenChange={() => {}} />);
expect(screen.queryByText(/Device Metrics:/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Air TX utilization:/i)).not.toBeInTheDocument();
expect(screen.getByText(/Node Details for Test Node/i)).toBeInTheDocument();
});
it("renders 'Never' for lastHeard when timestamp is 0", () => {
const nodeNeverHeard = { ...mockNode, lastHeard: 0 };
mockUseDevice.mockReturnValue({ getNode: () => nodeNeverHeard });
mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 });
render(<NodeDetailsDialog open onOpenChange={() => {}} />);
expect(screen.getByText(/Last Heard: Never/i)).toBeInTheDocument();
});
});

10
src/components/Dialog/QRDialog.tsx

@ -13,7 +13,6 @@ import { Input } from "@components/UI/Input.tsx";
import { Label } from "@components/UI/Label.tsx"; import { Label } from "@components/UI/Label.tsx";
import { Protobuf, type Types } from "@meshtastic/core"; import { Protobuf, type Types } from "@meshtastic/core";
import { fromByteArray } from "base64-js"; import { fromByteArray } from "base64-js";
import { ClipboardIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { QRCode } from "react-qrcode-logo"; import { QRCode } from "react-qrcode-logo";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -92,7 +91,7 @@ export const QRDialog = ({
<Checkbox <Checkbox
key={channel.index} key={channel.index}
checked={selectedChannels.includes(channel.index)} checked={selectedChannels.includes(channel.index)}
onCheckedChange={() => { onChange={() => {
if (selectedChannels.includes(channel.index)) { if (selectedChannels.includes(channel.index)) {
setSelectedChannels( setSelectedChannels(
selectedChannels.filter((c) => selectedChannels.filter((c) =>
@ -144,13 +143,6 @@ export const QRDialog = ({
<Input <Input
value={qrCodeUrl} value={qrCodeUrl}
disabled disabled
action={{
key: "copy-value",
icon: ClipboardIcon,
onClick() {
void navigator.clipboard.writeText(qrCodeUrl);
},
}}
/> />
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

8
src/components/Dialog/RebootDialog.tsx

@ -9,7 +9,7 @@ import {
} from "@components/UI/Dialog.tsx"; } from "@components/UI/Dialog.tsx";
import { Input } from "@components/UI/Input.tsx"; import { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { ClockIcon, RefreshCwIcon } from "lucide-react"; import { RefreshCwIcon } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useState } from "react"; import { useState } from "react";
@ -45,12 +45,6 @@ export const RebootDialog = ({
className="dark:text-slate-900" className="dark:text-slate-900"
value={time} value={time}
onChange={(e) => setTime(Number.parseInt(e.target.value))} onChange={(e) => setTime(Number.parseInt(e.target.value))}
action={{
icon: ClockIcon,
onClick() {
connection?.reboot(time * 60).then(() => onOpenChange(false));
},
}}
/> />
<Button <Button
className="w-24" className="w-24"

22
src/components/Dialog/RebootOTADialog.test.tsx

@ -1,7 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, 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 {
ButtonHTMLAttributes,
ClassAttributes,
InputHTMLAttributes,
ReactNode,
} from "react";
import { JSX } from "react/jsx-runtime";
const rebootOtaMock = vi.fn(); const rebootOtaMock = vi.fn();
let mockConnection: { rebootOta: (delay: number) => void } | undefined = { let mockConnection: { rebootOta: (delay: number) => void } | undefined = {
@ -18,7 +24,12 @@ 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) => <button {...props} />, Button: (
props:
& JSX.IntrinsicAttributes
& ClassAttributes<HTMLButtonElement>
& ButtonHTMLAttributes<HTMLButtonElement>,
) => <button {...props} />,
}; };
}); });
@ -26,7 +37,12 @@ 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) => <input {...props} />, Input: (
props:
& JSX.IntrinsicAttributes
& ClassAttributes<HTMLInputElement>
& InputHTMLAttributes<HTMLInputElement>,
) => <input {...props} />,
}; };
}); });

86
src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.test.ts

@ -1,86 +0,0 @@
import { act, renderHook } from "@testing-library/react";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
vi.mock("@core/stores/messageStore", () => ({
useMessageStore: vi.fn(() => ({ activeChat: "chat-123" })),
}));
vi.mock("@core/stores/deviceStore", () => ({
useDevice: vi.fn(() => ({
removeNode: vi.fn(),
setDialogOpen: vi.fn(),
getNodeError: vi.fn(),
clearNodeError: vi.fn(),
})),
}));
describe("useRefreshKeysDialog Hook", () => {
let removeNodeMock: Mock;
let setDialogOpenMock: Mock;
let getNodeErrorMock: Mock;
let clearNodeErrorMock: Mock;
beforeEach(() => {
vi.clearAllMocks();
removeNodeMock = vi.fn();
setDialogOpenMock = vi.fn();
getNodeErrorMock = vi.fn().mockReturnValue(undefined);
clearNodeErrorMock = vi.fn();
vi.mocked(useDevice).mockReturnValue({
removeNode: removeNodeMock,
setDialogOpen: setDialogOpenMock,
getNodeError: getNodeErrorMock,
clearNodeError: clearNodeErrorMock,
});
vi.mocked(useMessageStore).mockReturnValue({
activeChat: "chat-123",
});
});
it("handleNodeRemove should remove the node and update dialog if there is an error", () => {
getNodeErrorMock.mockReturnValue({ node: "node-abc" });
const { result } = renderHook(() => useRefreshKeysDialog());
act(() => {
result.current.handleNodeRemove();
});
expect(getNodeErrorMock).toHaveBeenCalledTimes(1);
expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123");
expect(clearNodeErrorMock).toHaveBeenCalledTimes(1);
expect(clearNodeErrorMock).toHaveBeenCalledWith("chat-123");
expect(removeNodeMock).toHaveBeenCalledTimes(1);
expect(removeNodeMock).toHaveBeenCalledWith("node-abc");
expect(setDialogOpenMock).toHaveBeenCalledTimes(1);
expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false);
});
it("handleNodeRemove should do nothing if there is no error", () => {
const { result } = renderHook(() => useRefreshKeysDialog());
act(() => {
result.current.handleNodeRemove();
});
expect(getNodeErrorMock).toHaveBeenCalledTimes(1);
expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123");
expect(clearNodeErrorMock).not.toHaveBeenCalled();
expect(removeNodeMock).not.toHaveBeenCalled();
expect(setDialogOpenMock).not.toHaveBeenCalled();
});
it("handleCloseDialog should close the dialog", () => {
const { result } = renderHook(() => useRefreshKeysDialog());
act(() => {
result.current.handleCloseDialog();
});
expect(setDialogOpenMock).toHaveBeenCalledTimes(1);
expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false);
});
});

20
src/components/Dialog/TracerouteResponseDialog.tsx

@ -1,4 +1,4 @@
import { useDevice } from "../../core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { import {
Dialog, Dialog,
DialogClose, DialogClose,
@ -31,13 +31,19 @@ export const TracerouteResponseDialog = ({
const snrTowards = (traceroute?.data.snrTowards ?? []).map((snr) => snr / 4); const snrTowards = (traceroute?.data.snrTowards ?? []).map((snr) => snr / 4);
const snrBack = (traceroute?.data.snrBack ?? []).map((snr) => snr / 4); const snrBack = (traceroute?.data.snrBack ?? []).map((snr) => snr / 4);
const from = getNode(traceroute?.from ?? 0); const from = getNode(traceroute?.from ?? 0);
const longName = from?.user?.longName ?? const fromLongName = from?.user?.longName ??
(from ? `!${numberToHexUnpadded(from?.num)}` : t("unknown.shortName")); (from ? `!${numberToHexUnpadded(from?.num)}` : t("unknown.shortName"));
const shortName = from?.user?.shortName ?? const fromShortName = from?.user?.shortName ??
(from (from
? `${numberToHexUnpadded(from?.num).substring(0, 4)}` ? `${numberToHexUnpadded(from?.num).substring(0, 4)}`
: t("unknown.shortName")); : t("unknown.shortName"));
const to = getNode(traceroute?.to ?? 0);
const toUser = getNode(traceroute?.to ?? 0);
if (!toUser || !from) {
return null;
}
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
@ -45,7 +51,7 @@ export const TracerouteResponseDialog = ({
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{t("tracerouteResponse.title", { {t("tracerouteResponse.title", {
identifier: `${longName} (${shortName})`, identifier: `${fromLongName} (${fromShortName})`,
})} })}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
@ -53,8 +59,8 @@ export const TracerouteResponseDialog = ({
<TraceRoute <TraceRoute
route={route} route={route}
routeBack={routeBack} routeBack={routeBack}
from={from} from={{ user: from.user }}
to={to} to={{ user: toUser.user }}
snrTowards={snrTowards} snrTowards={snrTowards}
snrBack={snrBack} snrBack={snrBack}
/> />

134
src/components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.test.tsx

@ -1,134 +0,0 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
import {
createMemoryHistory,
createRootRoute,
createRouter,
RouterProvider,
} from "@tanstack/react-router";
import { eventBus } from "@core/utils/eventBus.ts";
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
const rootRoute = createRootRoute();
describe.skip("UnsafeRolesDialog", () => {
const mockDevice = {
setDialogOpen: vi.fn(),
};
const renderWithProviders = (ui: React.ReactNode) => {
const testRouter = createRouter({
routeTree: rootRoute,
history: createMemoryHistory(),
});
return render(
<RouterProvider router={testRouter}>
<DeviceWrapper device={mockDevice}>
{ui}
</DeviceWrapper>
</RouterProvider>,
);
};
it("renders the dialog when open is true", () => {
renderWithProviders(
<UnsafeRolesDialog open onOpenChange={vi.fn()} />,
);
const dialog = screen.getByRole("dialog");
expect(dialog).toBeInTheDocument();
expect(screen.getByText(/I have read the/i)).toBeInTheDocument();
expect(screen.getByText(/understand the implications/i))
.toBeInTheDocument();
const links = screen.getAllByRole("link");
expect(links).toHaveLength(2);
expect(links[0]).toHaveTextContent("Device Role Documentation");
expect(links[1]).toHaveTextContent("Choosing The Right Device Role");
});
it("displays the correct links", () => {
renderWithProviders(
<UnsafeRolesDialog open onOpenChange={vi.fn()} />,
);
const docLink = screen.getByRole("link", {
name: /Device Role Documentation/i,
});
const blogLink = screen.getByRole("link", {
name: /Choosing The Right Device Role/i,
});
expect(docLink).toHaveAttribute(
"to",
"https://meshtastic.org/docs/configuration/radio/device/",
);
expect(blogLink).toHaveAttribute(
"to",
"https://meshtastic.org/blog/choosing-the-right-device-role/",
);
});
it("does not allow confirmation until checkbox is checked", () => {
renderWithProviders(
<UnsafeRolesDialog open onOpenChange={vi.fn()} />,
);
const confirmButton = screen.getByRole("button", { name: /confirm/i });
expect(confirmButton).toBeDisabled();
const checkbox = screen.getByRole("checkbox");
fireEvent.click(checkbox);
expect(confirmButton).toBeEnabled();
});
it("emits the correct event when closing via close button", () => {
const eventSpy = vi.spyOn(eventBus, "emit");
renderWithProviders(
<UnsafeRolesDialog open onOpenChange={vi.fn()} />,
);
const dismissButton = screen.getByRole("button", { name: /close/i });
fireEvent.click(dismissButton);
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", {
action: "dismiss",
});
});
it("emits the correct event when dismissing", () => {
const eventSpy = vi.spyOn(eventBus, "emit");
renderWithProviders(
<UnsafeRolesDialog open onOpenChange={vi.fn()} />,
);
const dismissButton = screen.getByRole("button", { name: /dismiss/i });
fireEvent.click(dismissButton);
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", {
action: "dismiss",
});
});
it("emits the correct event when confirming", () => {
const eventSpy = vi.spyOn(eventBus, "emit");
renderWithProviders(
<UnsafeRolesDialog open onOpenChange={vi.fn()} />,
);
const checkbox = screen.getByRole("checkbox");
const confirmButton = screen.getByRole("button", { name: /confirm/i });
fireEvent.click(checkbox);
fireEvent.click(confirmButton);
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", {
action: "confirm",
});
});
});

314
src/components/Form/DynamicForm.test.tsx

@ -1,314 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@core/utils/test.tsx";
import { DynamicForm } from "./DynamicForm.tsx";
import { z } from "zod/v4";
import { useAppStore } from "@core/stores/appStore.ts";
import userEvent from "@testing-library/user-event";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string | string[]) => (Array.isArray(key) ? key[0] : key),
}),
}));
const addErrorMock = vi.fn();
const removeErrorMock = vi.fn();
vi.mock("@core/stores/appStore.ts", () => ({
useAppStore: () => ({
addError: addErrorMock,
removeError: removeErrorMock,
}),
}));
describe.skip("DynamicForm", () => {
const schema = z.object({
name: z.string().min(3, { message: "Too short" }),
});
const fieldGroups = [
{
label: "Test Group",
description: "Testing validation",
fields: [
{
type: "text",
id: "name",
name: "name",
label: "Name",
description: "Enter your name",
properties: {},
},
],
},
];
it("shows validation error when input is too short", async () => {
render(
<DynamicForm<z.infer<typeof schema>>
onSubmit={vi.fn()}
validationSchema={schema}
defaultValues={{ name: "" }}
fieldGroups={fieldGroups}
/>,
);
const input = screen.getByLabelText("Name") as HTMLInputElement;
fireEvent.input(input, { target: { value: "ab" } });
const error = await screen.findByText(
"formValidation.tooSmall.string",
);
expect(error).toBeVisible();
});
it("clears validation error when input becomes valid", async () => {
render(
<DynamicForm<z.infer<typeof schema>>
onSubmit={vi.fn()}
validationSchema={schema}
defaultValues={{ name: "" }}
fieldGroups={fieldGroups}
/>,
);
const input = screen.getByLabelText("Name") as HTMLInputElement;
fireEvent.input(input, { target: { value: "ab" } });
expect(
await screen.findByText("formValidation.tooSmall.string"),
).toBeVisible();
fireEvent.input(input, { target: { value: "abcd" } });
await waitFor(() =>
expect(
screen.queryByText("formValidation.tooSmall.string"),
).toBeNull()
);
});
it("calls onSubmit when form is valid onChange", async () => {
const onSubmit = vi.fn();
render(
<DynamicForm<z.infer<typeof schema>>
onSubmit={onSubmit}
validationSchema={schema}
defaultValues={{ name: "" }}
fieldGroups={fieldGroups}
/>,
);
const input = screen.getByLabelText("Name") as HTMLInputElement;
fireEvent.input(input, { target: { value: "ab" } });
expect(
await screen.findByText("formValidation.tooSmall.string"),
).toBeVisible();
fireEvent.input(input, { target: { value: "abcd" } });
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledTimes(1);
});
expect(onSubmit).toHaveBeenCalledWith(
{ name: "abcd" },
expect.any(Object),
);
});
it("renders a button and only calls onSubmit on click with submitType='onSubmit'", async () => {
// Use the userEvent setup
const user = userEvent.setup();
const onSubmit = vi.fn();
render(
<DynamicForm<z.infer<typeof schema>>
onSubmit={onSubmit}
submitType="onSubmit"
hasSubmitButton
validationSchema={schema}
defaultValues={{ name: "" }}
fieldGroups={fieldGroups}
/>,
);
const nameInput = screen.getByLabelText("Name");
const submitButton = screen.getByRole("button", { name: /submit/i });
expect(submitButton).toBeInTheDocument();
await user.type(nameInput, "ab");
expect(await screen.findByText("formValidation.tooSmall.string"))
.toBeInTheDocument();
await user.click(submitButton);
expect(onSubmit).not.toHaveBeenCalled();
await user.clear(nameInput);
await user.type(nameInput, "abcd");
await waitFor(() => {
expect(screen.queryByText("formValidation.tooSmall.string")).not
.toBeInTheDocument();
});
await user.click(submitButton);
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledTimes(1);
});
expect(onSubmit).toHaveBeenCalledWith({ name: "abcd" }, expect.any(Object));
});
it("renders defaultValues correctly", () => {
render(
<DynamicForm<{ name: string }>
onSubmit={vi.fn()}
// no validationSchema
defaultValues={{ name: "Alice" }}
fieldGroups={[
{
label: "Group",
description: "",
fields: [
{
type: "text",
name: "name",
label: "Name",
description: "",
properties: {},
},
],
},
]}
/>,
);
const input = screen.getByLabelText("Name") as HTMLInputElement;
expect(input.value).toBe("Alice");
});
it("toggles disabled state based on disabledBy rules", async () => {
const schema = z.object({
enable: z.boolean(),
follow: z.string(),
});
render(
<DynamicForm<z.infer<typeof schema>>
onSubmit={vi.fn()}
validationSchema={schema}
defaultValues={{ enable: false, follow: "" }}
fieldGroups={[
{
label: "Group",
description: "",
fields: [
{
type: "toggle",
name: "enable",
label: "enable",
description: "",
},
{
type: "text",
name: "follow",
label: "follow",
description: "",
disabledBy: [{ fieldName: "enable" }],
properties: {},
},
],
},
]}
/>,
);
const enable = screen.getByRole("switch", {
name: "enable",
}) as HTMLInputElement;
const follow = screen.getByLabelText("follow") as HTMLInputElement;
await waitFor(() => {
expect(enable.getAttribute("aria-checked")).toBe("false");
expect(follow).toBeDisabled();
});
fireEvent.click(enable);
await waitFor(() => {
expect(enable.getAttribute("aria-checked")).toBe("true");
expect(follow).not.toBeDisabled();
});
});
it("always calls onSubmit onChange when no validationSchema is provided", async () => {
const onSubmit = vi.fn();
render(
<DynamicForm<{ foo: string }>
onSubmit={onSubmit}
// no validationSchema
defaultValues={{ foo: "" }}
fieldGroups={[
{
label: "G",
description: "",
fields: [
{
type: "text",
name: "foo",
label: "Foo",
description: "",
properties: {},
},
],
},
]}
/>,
);
const input = screen.getByLabelText("Foo") as HTMLInputElement;
fireEvent.input(input, { target: { value: "bar" } });
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith({ foo: "bar" }, expect.any(Object));
});
});
it("syncs errors to appStore when formId is set", async () => {
const { addError, removeError } = useAppStore();
const schema = z.object({ foo: z.string().min(2) });
const groups = [
{
label: "G",
description: "",
fields: [
{
type: "text",
name: "foo",
label: "Foo",
description: "",
properties: {},
},
],
},
];
render(
<DynamicForm<z.infer<typeof schema>>
onSubmit={vi.fn()}
formId="myForm"
validationSchema={schema}
defaultValues={{ foo: "" }}
fieldGroups={groups}
/>,
);
const input = screen.getByLabelText("Foo") as HTMLInputElement;
fireEvent.input(input, { target: { value: "a" } });
await screen.findByText(/tooSmall/i);
expect(addError).toHaveBeenCalledWith("foo", "");
expect(addError).toHaveBeenCalledWith("myForm", "");
fireEvent.input(input, { target: { value: "abc" } });
await waitFor(() => {
expect(removeError).toHaveBeenCalledWith("foo");
expect(removeError).toHaveBeenCalledWith("myForm");
});
});
});

18
src/components/Form/FormSelect.tsx

@ -43,14 +43,19 @@ export function SelectInput<T extends FieldValues>({
invalid, invalid,
}: GenericFormElementProps<T, SelectFieldProps<T>>) { }: GenericFormElementProps<T, SelectFieldProps<T>>) {
const { const {
field: { value, onChange, ...rest }, field: { value, onChange, ref, onBlur, ...rest },
} = useController({ } = useController({
name: field.name, name: field.name,
control, control,
}); });
const { enumValue, formatEnumName, ...remainingProperties } = const {
field.properties; enumValue,
formatEnumName,
defaultValue,
className,
...remainingProperties
} = field.properties;
const valueToKeyMap: Record<string, string> = {}; const valueToKeyMap: Record<string, string> = {};
const optionsEnumValues: [string, number][] = []; const optionsEnumValues: [string, number][] = [];
@ -80,18 +85,21 @@ export function SelectInput<T extends FieldValues>({
onValueChange={handleValueChange} onValueChange={handleValueChange}
disabled={disabled} disabled={disabled}
value={value?.toString()} value={value?.toString()}
{...remainingProperties} defaultValue={defaultValue?.toString()}
{...rest} {...rest}
> >
<SelectTrigger <SelectTrigger
id={field.name} id={field.name}
className={cn([ className={cn([
field.properties?.className, className,
isDirty ? "focus:ring-sky-500 ring-sky-500 ring-2 ring-offset-2" : "", isDirty ? "focus:ring-sky-500 ring-sky-500 ring-2 ring-offset-2" : "",
invalid invalid
? "focus:ring-red-500 ring-red-500 ring-2 outline-offset-2" ? "focus:ring-red-500 ring-red-500 ring-2 outline-offset-2"
: "", : "",
])} ])}
ref={ref}
onBlur={onBlur}
{...remainingProperties}
> >
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>

131
src/components/PageComponents/Config/Device/Device.test.tsx

@ -1,131 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Device } from "@components/PageComponents/Config/Device/index.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
import { Protobuf } from "@meshtastic/core";
vi.mock("@core/stores/deviceStore.ts", () => ({
useDevice: vi.fn(),
}));
vi.mock("@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts", () => ({
useUnsafeRolesDialog: vi.fn(),
}));
// Mock the DynamicForm component since we're testing the Device component,
// not the DynamicForm implementation
vi.mock("@components/Form/DynamicForm", () => ({
DynamicForm: vi.fn(({ onSubmit }) => {
// Render a simplified version of the form for testing
return (
<div data-testid="dynamic-form">
<select
data-testid="role-select"
onChange={(e) => {
// Simulate the validation and submission process
const mockData = { role: e.target.value };
onSubmit(mockData);
}}
>
{Object.entries(Protobuf.Config.Config_DeviceConfig_Role).map((
[key, value],
) => (
<option key={key} value={value}>
{key}
</option>
))}
</select>
<button
type="submit"
data-testid="submit-button"
onClick={() => onSubmit({ role: "CLIENT" })}
>
Submit
</button>
</div>
);
}),
}));
describe("Device component", () => {
const setWorkingConfigMock = vi.fn();
const validateRoleSelectionMock = vi.fn();
const mockDeviceConfig = {
role: "CLIENT",
buttonGpio: 0,
buzzerGpio: 0,
rebroadcastMode: "ALL",
nodeInfoBroadcastSecs: 300,
doubleTapAsButtonPress: false,
disableTripleClick: false,
ledHeartbeatDisabled: false,
};
beforeEach(() => {
vi.resetAllMocks();
// Mock the useDevice hook
useDevice.mockReturnValue({
config: {
device: mockDeviceConfig,
},
setWorkingConfig: setWorkingConfigMock,
});
// Mock the useUnsafeRolesDialog hook
validateRoleSelectionMock.mockResolvedValue(true);
useUnsafeRolesDialog.mockReturnValue({
validateRoleSelection: validateRoleSelectionMock,
});
});
afterEach(() => {
vi.clearAllMocks();
});
it("should render the Device form", () => {
render(<Device />);
expect(screen.getByTestId("dynamic-form")).toBeInTheDocument();
});
it("should use the validateRoleSelection from the unsafe roles hook", () => {
render(<Device />);
expect(useUnsafeRolesDialog).toHaveBeenCalled();
});
it("should call setWorkingConfig when form is submitted", async () => {
render(<Device />);
fireEvent.click(screen.getByTestId("submit-button"));
await waitFor(() => {
expect(setWorkingConfigMock).toHaveBeenCalledWith(
expect.objectContaining({
payloadVariant: {
case: "device",
value: expect.objectContaining({ role: "CLIENT" }),
},
}),
);
});
});
it("should create config with proper structure", async () => {
render(<Device />);
// Simulate form submission
fireEvent.click(screen.getByTestId("submit-button"));
await waitFor(() => {
expect(setWorkingConfigMock).toHaveBeenCalledWith(
expect.objectContaining({
payloadVariant: {
case: "device",
value: expect.any(Object),
},
}),
);
});
});
});

173
src/components/PageComponents/Config/Network/Network.test.tsx

@ -1,173 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Network } from "@components/PageComponents/Config/Network/index.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
vi.mock("@core/stores/deviceStore", () => ({
useDevice: vi.fn(),
}));
vi.mock("@components/Form/DynamicForm", async () => {
const React = await import("react");
const { useState } = React;
return {
DynamicForm: ({ onSubmit, defaultValues }) => {
const [wifiEnabled, setWifiEnabled] = useState(
defaultValues.wifiEnabled ?? false,
);
const [ssid, setSsid] = useState(defaultValues.wifiSsid ?? "");
const [psk, setPsk] = useState(defaultValues.wifiPsk ?? "");
return (
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit({
...defaultValues,
wifiEnabled,
wifiSsid: ssid,
wifiPsk: psk,
});
}}
data-testid="dynamic-form"
>
<input
type="checkbox"
aria-label="WiFi Enabled"
checked={wifiEnabled}
onChange={(e) => setWifiEnabled(e.target.checked)}
/>
<input
aria-label="SSID"
value={ssid}
onChange={(e) => setSsid(e.target.value)}
disabled={!wifiEnabled}
/>
<input
aria-label="PSK"
value={psk}
onChange={(e) => setPsk(e.target.value)}
disabled={!wifiEnabled}
/>
<button type="submit" data-testid="submit-button">
Submit
</button>
</form>
);
},
};
});
describe("Network component", () => {
const setWorkingConfigMock = vi.fn();
const mockNetworkConfig = {
wifiEnabled: false,
wifiSsid: "",
wifiPsk: "",
ntpServer: "",
ethEnabled: false,
addressMode: Protobuf.Config.Config_NetworkConfig_AddressMode.DHCP,
ipv4Config: {
ip: 0,
gateway: 0,
subnet: 0,
dns: 0,
},
enabledProtocols:
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
rsyslogServer: "",
};
beforeEach(() => {
vi.resetAllMocks();
useDevice.mockReturnValue({
config: {
network: mockNetworkConfig,
},
setWorkingConfig: setWorkingConfigMock,
});
});
afterEach(() => {
vi.clearAllMocks();
});
it("should render the Network form", () => {
render(<Network />);
expect(screen.getByTestId("dynamic-form")).toBeInTheDocument();
});
it("should disable SSID and PSK fields when wifi is off", () => {
render(<Network />);
expect(screen.getByLabelText("SSID")).toBeDisabled();
expect(screen.getByLabelText("PSK")).toBeDisabled();
});
it("should enable SSID and PSK when wifi is toggled on", async () => {
render(<Network />);
const toggle = screen.getByLabelText("WiFi Enabled");
fireEvent.click(toggle); // turns wifiEnabled = true
await waitFor(() => {
expect(screen.getByLabelText("SSID")).not.toBeDisabled();
expect(screen.getByLabelText("PSK")).not.toBeDisabled();
});
});
it("should call setWorkingConfig with the right structure on submit", async () => {
render(<Network />);
fireEvent.click(screen.getByTestId("submit-button"));
await waitFor(() => {
expect(setWorkingConfigMock).toHaveBeenCalledWith(
expect.objectContaining({
payloadVariant: {
case: "network",
value: expect.objectContaining({
wifiEnabled: false,
wifiSsid: "",
wifiPsk: "",
ntpServer: "",
ethEnabled: false,
rsyslogServer: "",
}),
},
}),
);
});
});
it("should submit valid data after enabling wifi and entering SSID and PSK", async () => {
render(<Network />);
fireEvent.click(screen.getByLabelText("WiFi Enabled"));
fireEvent.change(screen.getByLabelText("SSID"), {
target: { value: "MySSID" },
});
fireEvent.change(screen.getByLabelText("PSK"), {
target: { value: "MySecretPSK" },
});
fireEvent.click(screen.getByTestId("submit-button"));
await waitFor(() => {
expect(setWorkingConfigMock).toHaveBeenCalledWith(
expect.objectContaining({
payloadVariant: {
case: "network",
value: expect.objectContaining({
wifiEnabled: true,
wifiSsid: "MySSID",
wifiPsk: "MySecretPSK",
}),
},
}),
);
});
});
});

3
src/components/PageComponents/Connect/BLE.tsx

@ -7,6 +7,7 @@ import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts"; import { randId } from "@core/utils/randId.ts";
import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth"; import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth";
import { MeshDevice } from "@meshtastic/core"; import { MeshDevice } from "@meshtastic/core";
import type { BluetoothDevice } from "web-bluetooth";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useMessageStore } from "@core/stores/messageStore/index.ts"; import { useMessageStore } from "@core/stores/messageStore/index.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -77,7 +78,7 @@ export const BLE = (
if (exists === -1) { if (exists === -1) {
setBleDevices(bleDevices.concat(device)); setBleDevices(bleDevices.concat(device));
} }
}).catch((error) => { }).catch((error: Error) => {
console.error("Error requesting device:", error); console.error("Error requesting device:", error);
setConnectionInProgress(false); setConnectionInProgress(false);
}).finally(() => { }).finally(() => {

4
src/components/PageComponents/Connect/HTTP.tsx

@ -66,7 +66,9 @@ export const HTTP = (
subscribeAll(device, connection, messageStore); subscribeAll(device, connection, messageStore);
closeDialog(); closeDialog();
} catch (error) { } catch (error) {
console.error("Connection error:", error); if (error instanceof Error) {
console.error("Connection error:", error);
}
// Capture all connection errors regardless of type // Capture all connection errors regardless of type
setConnectionError({ host: data.ip, secure: data.tls }); setConnectionError({ host: data.ip, secure: data.tls });
setConnectionInProgress(false); setConnectionInProgress(false);

11
src/components/PageComponents/Connect/Serial.tsx

@ -9,7 +9,8 @@ import { MeshDevice } from "@meshtastic/core";
import { TransportWebSerial } from "@meshtastic/transport-web-serial"; import { TransportWebSerial } from "@meshtastic/transport-web-serial";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; import type { SerialPort } from "w3c-web-serial";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
export const Serial = ( export const Serial = (
{ closeDialog }: TabElementProps, { closeDialog }: TabElementProps,
@ -22,13 +23,13 @@ export const Serial = (
const { t } = useTranslation(); const { t } = useTranslation();
const updateSerialPortList = useCallback(async () => { const updateSerialPortList = useCallback(async () => {
setSerialPorts(await navigator?.serial.getPorts()); setSerialPorts(await navigator.serial.getPorts());
}, []); }, []);
navigator?.serial?.addEventListener("connect", () => { navigator.serial.addEventListener("connect", () => {
updateSerialPortList(); updateSerialPortList();
}); });
navigator?.serial?.addEventListener("disconnect", () => { navigator.serial.addEventListener("disconnect", () => {
updateSerialPortList(); updateSerialPortList();
}); });
useEffect(() => { useEffect(() => {
@ -89,7 +90,7 @@ export const Serial = (
await navigator.serial.requestPort().then((port) => { await navigator.serial.requestPort().then((port) => {
setSerialPorts(serialPorts.concat(port)); setSerialPorts(serialPorts.concat(port));
// No need to setConnectionInProgress(false) here if requestPort is quick // No need to setConnectionInProgress(false) here if requestPort is quick
}).catch((error) => { }).catch((error: Error) => {
console.error("Error requesting port:", error); console.error("Error requesting port:", error);
}).finally(() => { }).finally(() => {
setConnectionInProgress(false); setConnectionInProgress(false);

16
src/components/PageComponents/Messages/MessageItem.tsx

@ -56,21 +56,21 @@ export const MessageItem = ({ message }: MessageItemProps) => {
const MESSAGE_STATUS_MAP = useMemo( const MESSAGE_STATUS_MAP = useMemo(
(): Record<MessageState, MessageStatusInfo> => ({ (): Record<MessageState, MessageStatusInfo> => ({
[MessageState.Ack]: { [MessageState.Ack]: {
displayText: t("message_item_status_delivered_displayText"), displayText: t("deliveryStatus.deliveryStatus."),
icon: CheckCircle2, icon: CheckCircle2,
ariaLabel: t("message_item_status_delivered_ariaLabel"), ariaLabel: t("deliveryStatus.delivered"),
iconClassName: "text-green-500", iconClassName: "text-green-500",
}, },
[MessageState.Waiting]: { [MessageState.Waiting]: {
displayText: t("message_item_status_waiting_displayText"), displayText: t("deliveryStatus.waiting"),
icon: CircleEllipsis, icon: CircleEllipsis,
ariaLabel: t("message_item_status_waiting_ariaLabel"), ariaLabel: t("deliveryStatus.waiting"),
iconClassName: "text-slate-400", iconClassName: "text-slate-400",
}, },
[MessageState.Failed]: { [MessageState.Failed]: {
displayText: t("message_item_status_failed_displayText"), displayText: t("deliveryStatus.failed"),
icon: AlertCircle, icon: AlertCircle,
ariaLabel: t("message_item_status_failed_ariaLabel"), ariaLabel: t("deliveryStatus.failed"),
iconClassName: "text-red-500 dark:text-red-400", iconClassName: "text-red-500 dark:text-red-400",
}, },
}), }),
@ -78,9 +78,9 @@ export const MessageItem = ({ message }: MessageItemProps) => {
); );
const UNKNOWN_STATUS = useMemo((): MessageStatusInfo => ({ const UNKNOWN_STATUS = useMemo((): MessageStatusInfo => ({
displayText: t("message_item_status_unknown_displayText"), displayText: t("delveryStatus.unknown"),
icon: AlertCircle, icon: AlertCircle,
ariaLabel: t("message_item_status_unknown_ariaLabel"), ariaLabel: t("deliveryStatus.unknown"),
iconClassName: "text-red-500 dark:text-red-400", iconClassName: "text-red-500 dark:text-red-400",
}), [t]); }), [t]);

92
src/components/PageComponents/Messages/TraceRoute.test.tsx

@ -2,29 +2,71 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx"; import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import type { Protobuf } from "@meshtastic/core"; import { mockDeviceStore } from "@core/stores/deviceStore.mock.ts";
import { Protobuf } from "@meshtastic/core";
vi.mock("@core/stores/deviceStore"); vi.mock("@core/stores/deviceStore");
describe("TraceRoute", () => { describe("TraceRoute", () => {
const fromUser = {
user: {
$typeName: "meshtastic.User",
longName: "Source Node",
publicKey: new Uint8Array([1, 2, 3]),
shortName: "Source",
hwModel: 1,
macaddr: new Uint8Array([0x01, 0x02, 0x03, 0x04]),
id: "source-node",
isLicensed: false,
role: Protobuf.Config.Config_DeviceConfig_Role["CLIENT"],
} as Protobuf.Mesh.NodeInfo["user"],
};
const toUser = {
user: {
$typeName: "meshtastic.User",
longName: "Destination Node",
publicKey: new Uint8Array([4, 5, 6]),
shortName: "Destination",
hwModel: 2,
macaddr: new Uint8Array([0x05, 0x06, 0x07, 0x08]),
id: "destination-node",
isLicensed: false,
role: Protobuf.Config.Config_DeviceConfig_Role["CLIENT"],
} as Protobuf.Mesh.NodeInfo["user"],
};
const mockNodes = new Map<number, Protobuf.Mesh.NodeInfo>([ const mockNodes = new Map<number, Protobuf.Mesh.NodeInfo>([
[ [
1, 1,
{ num: 1, user: { longName: "Node A" } } as Protobuf.Mesh.NodeInfo, {
num: 1,
user: { longName: "Node A", $typeName: "meshtastic.User" },
$typeName: "meshtastic.NodeInfo",
} as Protobuf.Mesh.NodeInfo,
], ],
[ [
2, 2,
{ num: 2, user: { longName: "Node B" } } as Protobuf.Mesh.NodeInfo, {
num: 2,
user: { longName: "Node B", $typeName: "meshtastic.User" },
$typeName: "meshtastic.NodeInfo",
} as Protobuf.Mesh.NodeInfo,
], ],
[ [
3, 3,
{ num: 3, user: { longName: "Node C" } } as Protobuf.Mesh.NodeInfo, {
num: 3,
user: { longName: "Node C", $typeName: "meshtastic.User" },
$typeName: "meshtastic.NodeInfo",
} as Protobuf.Mesh.NodeInfo,
], ],
]); ]);
beforeEach(() => { beforeEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
vi.mocked(useDevice).mockReturnValue({ vi.mocked(useDevice).mockReturnValue({
...mockDeviceStore,
getNode: (nodeNum: number): Protobuf.Mesh.NodeInfo | undefined => { getNode: (nodeNum: number): Protobuf.Mesh.NodeInfo | undefined => {
return mockNodes.get(nodeNum); return mockNodes.get(nodeNum);
}, },
@ -34,16 +76,15 @@ describe("TraceRoute", () => {
it("renders the route to destination with SNR values", () => { it("renders the route to destination with SNR values", () => {
render( render(
<TraceRoute <TraceRoute
from={{ user: { longName: "Source Node" } }} from={fromUser}
to={{ user: { longName: "Destination Node" } }} to={toUser}
route={[1, 2]} route={[1, 2]}
snrTowards={[10, 20, 30]} snrTowards={[10, 20, 30]}
/>, />,
); );
expect(screen.getAllByText("Source Node")).toHaveLength(1); expect(screen.getByText("Source Node")).toBeInTheDocument();
expect(screen.getByText("Destination Node")).toBeInTheDocument(); expect(screen.getByText("Destination Node")).toBeInTheDocument();
expect(screen.getByText("Node A")).toBeInTheDocument(); expect(screen.getByText("Node A")).toBeInTheDocument();
expect(screen.getByText("Node B")).toBeInTheDocument(); expect(screen.getByText("Node B")).toBeInTheDocument();
@ -56,8 +97,8 @@ describe("TraceRoute", () => {
it("renders the route back when provided", () => { it("renders the route back when provided", () => {
render( render(
<TraceRoute <TraceRoute
from={{ user: { longName: "Source Node" } }} from={fromUser}
to={{ user: { longName: "Destination Node" } }} to={toUser}
route={[1]} route={[1]}
snrTowards={[15, 25]} snrTowards={[15, 25]}
routeBack={[3]} routeBack={[3]}
@ -65,46 +106,33 @@ describe("TraceRoute", () => {
/>, />,
); );
// Check for the translated title
expect(screen.getByText("Route back:")).toBeInTheDocument(); expect(screen.getByText("Route back:")).toBeInTheDocument();
// With route back, both names appear twice
expect(screen.getAllByText("Source Node")).toHaveLength(2); expect(screen.getAllByText("Source Node")).toHaveLength(2);
expect(screen.getAllByText("Destination Node")).toHaveLength(2); expect(screen.getAllByText("Destination Node")).toHaveLength(2);
expect(screen.getByText("Node C")).toBeInTheDocument();
expect(screen.getByText("Node A")).toBeInTheDocument(); expect(screen.getByText("Node A")).toBeInTheDocument();
expect(screen.getByText("Node C")).toBeInTheDocument();
expect(screen.getByText("↓ 35dBm")).toBeInTheDocument();
expect(screen.getByText("↓ 45dBm")).toBeInTheDocument();
expect(screen.getByText("↓ 15dBm")).toBeInTheDocument(); expect(screen.getByText("↓ 15dBm")).toBeInTheDocument();
expect(screen.getByText("↓ 25dBm")).toBeInTheDocument(); expect(screen.getByText("↓ 25dBm")).toBeInTheDocument();
expect(screen.getByText("↓ 35dBm")).toBeInTheDocument();
expect(screen.getByText("↓ 45dBm")).toBeInTheDocument();
}); });
it("renders '??' for missing SNR values", () => { it("renders '??' for missing SNR values", () => {
render( render(
<TraceRoute <TraceRoute
from={{ user: { longName: "Source" } }} from={fromUser}
to={{ user: { longName: "Dest" } }} to={toUser}
route={[1]} route={[1]}
/>, />,
); );
expect(screen.getByText("Node A")).toBeInTheDocument(); expect(screen.getByText("Node A")).toBeInTheDocument();
expect(screen.getAllByText("↓ ??dBm")).toHaveLength(2); // Check for translated '??' placeholder
}); expect(screen.getAllByText(/↓ \?\?dBm/)).toHaveLength(2);
it("renders hop hex if node is not found", () => {
render(
<TraceRoute
from={{ user: { longName: "Source" } } as unknown}
to={{ user: { longName: "Dest" } } as unknown}
route={[99]}
snrTowards={[5, 15]}
/>,
);
expect(screen.getByText("↓ 5dBm")).toBeInTheDocument();
expect(screen.getByText("↓ 15dBm")).toBeInTheDocument();
}); });
}); });

24
src/components/PageComponents/Messages/TraceRoute.tsx

@ -3,9 +3,11 @@ import type { Protobuf } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
type NodeUser = Pick<Protobuf.Mesh.NodeInfo, "user">;
export interface TraceRouteProps { export interface TraceRouteProps {
from?: Protobuf.Mesh.NodeInfo; from: NodeUser;
to?: Protobuf.Mesh.NodeInfo; to: NodeUser;
route: Array<number>; route: Array<number>;
routeBack?: Array<number>; routeBack?: Array<number>;
snrTowards?: Array<number>; snrTowards?: Array<number>;
@ -14,14 +16,14 @@ export interface TraceRouteProps {
interface RoutePathProps { interface RoutePathProps {
title: string; title: string;
startNode?: Protobuf.Mesh.NodeInfo; from: NodeUser;
endNode?: Protobuf.Mesh.NodeInfo; to: NodeUser;
path: number[]; path: number[];
snr?: number[]; snr?: number[];
} }
const RoutePath = ( const RoutePath = (
{ title, startNode, endNode, path, snr }: RoutePathProps, { title, from, to, path, snr }: RoutePathProps,
) => { ) => {
const { getNode } = useDevice(); const { getNode } = useDevice();
const { t } = useTranslation(); const { t } = useTranslation();
@ -32,7 +34,7 @@ const RoutePath = (
className="ml-4 border-l-2 pl-2 border-l-slate-900 text-slate-900 dark:text-slate-100 dark:border-l-slate-100" className="ml-4 border-l-2 pl-2 border-l-slate-900 text-slate-900 dark:text-slate-100 dark:border-l-slate-100"
> >
<p className="font-semibold">{title}</p> <p className="font-semibold">{title}</p>
<p>{startNode?.user?.longName}</p> <p>{from?.user?.longName}</p>
<p> <p>
{snr?.[0] ?? t("unknown.num")} {snr?.[0] ?? t("unknown.num")}
{t("unit.dbm")} {t("unit.dbm")}
@ -49,7 +51,7 @@ const RoutePath = (
</p> </p>
</span> </span>
))} ))}
<p>{endNode?.user?.longName}</p> <p>{to?.user?.longName}</p>
</span> </span>
); );
}; };
@ -67,16 +69,16 @@ export const TraceRoute = ({
<div className="ml-5 flex"> <div className="ml-5 flex">
<RoutePath <RoutePath
title={t("traceRoute.routeToDestination")} title={t("traceRoute.routeToDestination")}
startNode={to} to={to}
endNode={from} from={from}
path={route} path={route}
snr={snrTowards} snr={snrTowards}
/> />
{routeBack && routeBack.length > 0 && ( {routeBack && routeBack.length > 0 && (
<RoutePath <RoutePath
title={t("traceRoute.routeBack")} title={t("traceRoute.routeBack")}
startNode={from} to={from}
endNode={to} from={to}
path={routeBack} path={routeBack}
snr={snrBack} snr={snrBack}
/> />

70
src/components/UI/Button.tsx

@ -46,44 +46,36 @@ export interface ButtonProps
iconAlignment?: "left" | "right"; iconAlignment?: "left" | "right";
} }
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( const Button = ({
( className,
{ variant,
className, size,
variant, disabled,
size, icon,
disabled, iconAlignment = "left",
icon, children,
iconAlignment = "left", ...props
children, }: ButtonProps) => {
...props return (
}, <button
ref, type="button"
) => { className={cn(
return ( buttonVariants({ variant, size, className }),
<button { "cursor-not-allowed": disabled },
type="button" "inline-flex items-center",
className={cn( )}
buttonVariants({ variant, size, className }), disabled={disabled}
{ "cursor-not-allowed": disabled }, {...props}
"inline-flex items-center", >
)} {icon && iconAlignment === "left" && (
ref={ref} <span className={cn({ "mr-2": !!children })}>{icon}</span>
disabled={disabled} )}
{...props} {children}
> {icon && iconAlignment === "right" && (
{icon && iconAlignment === "left" && ( <span className={cn({ "ml-2": !!children })}>{icon}</span>
<span className={cn({ "mr-2": !!children })}>{icon}</span> )}
)} </button>
{children} );
};
{icon && iconAlignment === "right" && (
<span className={cn({ "ml-2": !!children })}>{icon}</span>
)}
</button>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants }; export { Button, buttonVariants };

17
src/components/UI/Checkbox/Checkbox.test.tsx

@ -23,18 +23,6 @@ vi.mock("@components/UI/Label.tsx", () => ({
), ),
})); }));
vi.mock("@core/utils/cn.ts", () => ({
cn: (...args) => args.filter(Boolean).join(" "),
}));
vi.mock("react", async () => {
const actual = await vi.importActual("react");
return {
...actual,
useId: () => "test-id",
};
});
describe("Checkbox", () => { describe("Checkbox", () => {
beforeEach(cleanup); beforeEach(cleanup);
@ -67,11 +55,6 @@ describe("Checkbox", () => {
expect(screen.getByRole("checkbox").id).toBe("custom-id"); expect(screen.getByRole("checkbox").id).toBe("custom-id");
}); });
it("generates id when not provided", () => {
render(<Checkbox />);
expect(screen.getByRole("checkbox").id).toBe("test-id");
});
it("renders children in Label component", () => { it("renders children in Label component", () => {
render(<Checkbox>Test Label</Checkbox>); render(<Checkbox>Test Label</Checkbox>);
expect(screen.getByTestId("label-component")).toHaveTextContent( expect(screen.getByTestId("label-component")).toHaveTextContent(

4
src/components/UI/Dialog.tsx

@ -58,9 +58,7 @@ DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogClose = ({ const DialogClose = ({
className, className,
...props ...props
}: DialogPrimitive.DialogCloseProps & React.RefAttributes<HTMLButtonElement> & { }: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Close>) => (
className?: string;
}) => (
<DialogPrimitive.Close <DialogPrimitive.Close
aria-label="Close" aria-label="Close"
data-testid="dialog-close-button" data-testid="dialog-close-button"

6
src/components/UI/Typography/Link.tsx

@ -6,7 +6,11 @@ import {
export interface LinkProps extends RouterLinkProps { export interface LinkProps extends RouterLinkProps {
href: string; href: string;
children?: React.ReactNode; children?:
| React.ReactNode
| ((
state: { isActive: boolean; isTransitioning: boolean },
) => React.ReactNode);
className?: string; className?: string;
} }

294
src/components/generic/Filter/useFilterNode.ts

@ -1,153 +1,185 @@
import { Protobuf } from "@meshtastic/core"; import { Protobuf } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { useMemo } from "react"; import { useCallback, useMemo } from "react";
export type FilterState = { export type FilterState = {
nodeName: string; nodeName: string;
hopsAway: [number, number]; hopsAway: [number, number];
lastHeard: [number, number]; lastHeard: [number, number];
isFavorite: boolean | undefined; // undefined -> don't filter isFavorite: boolean | undefined;
viaMqtt: boolean | undefined; // undefined -> don't filter viaMqtt: boolean | undefined;
snr: [number, number]; snr: [number, number];
channelUtilization: [number, number]; channelUtilization: [number, number];
airUtilTx: [number, number]; airUtilTx: [number, number];
batteryLevel: [number, number]; batteryLevel: [number, number];
voltage: [number, number]; voltage: [number, number];
role: (Protobuf.Config.Config_DeviceConfig_Role)[]; role: Protobuf.Config.Config_DeviceConfig_Role[];
hwModel: (Protobuf.Mesh.HardwareModel)[]; hwModel: Protobuf.Mesh.HardwareModel[];
}; };
export function useFilterNode() { const shallowEqualArray = <T>(a: T[], b: T[]): boolean => {
const defaultFilterValues = useMemo<FilterState>(() => ({ if (a.length !== b.length) {
nodeName: "", return false;
hopsAway: [0, 7], }
lastHeard: [0, 864000], // 0-10 days for (let i = 0; i < a.length; i++) {
isFavorite: undefined, if (a[i] !== b[i]) {
viaMqtt: undefined,
snr: [-20, 10],
channelUtilization: [0, 100],
airUtilTx: [0, 100],
batteryLevel: [0, 101],
voltage: [0, 5],
role: Object.values(Protobuf.Config.Config_DeviceConfig_Role).filter(
(v): v is Protobuf.Config.Config_DeviceConfig_Role =>
typeof v === "number",
),
hwModel: Object.values(Protobuf.Mesh.HardwareModel).filter(
(v): v is Protobuf.Mesh.HardwareModel => typeof v === "number",
),
}), []);
function nodeFilter(
node: Protobuf.Mesh.NodeInfo,
filterOverrides?: Partial<FilterState>,
): boolean {
const filterState: FilterState = {
...defaultFilterValues,
...filterOverrides,
};
if (!node.user) return false;
const nodeName = filterState.nodeName.toLowerCase();
if (
!(
node.user?.shortName.toLowerCase().includes(nodeName) ||
node.user?.longName.toLowerCase().includes(nodeName) ||
node?.num.toString().includes(nodeName) ||
numberToHexUnpadded(node?.num).includes(
nodeName.replace(/!/g, ""),
)
)
) return false;
const hops = node?.hopsAway ?? 7;
if (hops < filterState.hopsAway[0] || hops > filterState.hopsAway[1]) {
return false; return false;
} }
const secondsAgo = Date.now() / 1000 - (node?.lastHeard ?? 0);
if (
secondsAgo < filterState.lastHeard[0] ||
(
secondsAgo > filterState.lastHeard[1] &&
filterState.lastHeard[1] !== defaultFilterValues.lastHeard[1]
)
) return false;
if (
typeof filterState.isFavorite !== "undefined" &&
node.isFavorite !== filterState.isFavorite
) return false;
if (
typeof filterState.viaMqtt !== "undefined" &&
node.viaMqtt !== filterState.viaMqtt
) return false;
const snr = node?.snr ?? -20;
if (
snr < filterState.snr[0] ||
snr > filterState.snr[1]
) return false;
const channelUtilization = node?.deviceMetrics?.channelUtilization ?? 0;
if (
channelUtilization < filterState.channelUtilization[0] ||
channelUtilization > filterState.channelUtilization[1]
) return false;
const airUtilTx = node?.deviceMetrics?.airUtilTx ?? 0;
if (
airUtilTx < filterState.airUtilTx[0] ||
airUtilTx > filterState.airUtilTx[1]
) return false;
const batt = node?.deviceMetrics?.batteryLevel ?? 101;
if (
batt < filterState.batteryLevel[0] ||
batt > filterState.batteryLevel[1]
) return false;
const voltage = node?.deviceMetrics?.voltage ?? 0;
if (
voltage < filterState.voltage[0] ||
voltage > filterState.voltage[1]
) return false;
const role: Protobuf.Config.Config_DeviceConfig_Role = node.user?.role ??
Protobuf.Config.Config_DeviceConfig_Role.CLIENT;
if (!filterState.role.includes(role)) return false;
const hwModel: Protobuf.Mesh.HardwareModel = node.user?.hwModel ??
Protobuf.Mesh.HardwareModel.UNSET;
if (!filterState.hwModel.includes(hwModel)) return false;
// All conditions are true
return true;
} }
return true;
};
// deno-lint-ignore no-explicit-any export function useFilterNode() {
function shallowEqualArray(a: any[], b: any[]) { const defaultFilterValues = useMemo<FilterState>(
return a.length === b.length && a.every((v, i) => v === b[i]); () => ({
} nodeName: "",
hopsAway: [0, 7],
lastHeard: [0, 864000], // 0-10 days
isFavorite: undefined,
viaMqtt: undefined,
snr: [-20, 10],
channelUtilization: [0, 100],
airUtilTx: [0, 100],
batteryLevel: [0, 101],
voltage: [0, 5],
role: Object.values(Protobuf.Config.Config_DeviceConfig_Role).filter(
(v): v is Protobuf.Config.Config_DeviceConfig_Role =>
typeof v === "number",
),
hwModel: Object.values(Protobuf.Mesh.HardwareModel).filter(
(v): v is Protobuf.Mesh.HardwareModel => typeof v === "number",
),
}),
[],
);
const nodeFilter = useCallback(
(
node: Protobuf.Mesh.NodeInfo,
filterOverrides?: Partial<FilterState>,
): boolean => {
const filterState: FilterState = {
...defaultFilterValues,
...filterOverrides,
};
if (!node.user) return false;
const nodeName = filterState.nodeName.toLowerCase();
if (
nodeName &&
!(
node.user?.shortName.toLowerCase().includes(nodeName) ||
node.user?.longName.toLowerCase().includes(nodeName) ||
node.num.toString().includes(nodeName) ||
numberToHexUnpadded(node.num).includes(nodeName.replace(/!/g, ""))
)
) {
return false;
}
const hops = node.hopsAway ?? 7;
if (hops < filterState.hopsAway[0] || hops > filterState.hopsAway[1]) {
return false;
}
const secondsAgo = Date.now() / 1000 - (node.lastHeard ?? 0);
if (
secondsAgo < filterState.lastHeard[0] ||
(secondsAgo > filterState.lastHeard[1] &&
filterState.lastHeard[1] !== defaultFilterValues.lastHeard[1])
) {
return false;
}
if (
typeof filterState.isFavorite !== "undefined" &&
node.isFavorite !== filterState.isFavorite
) {
return false;
}
if (
typeof filterState.viaMqtt !== "undefined" &&
node.viaMqtt !== filterState.viaMqtt
) {
return false;
}
const snr = node.snr ?? -20;
if (snr < filterState.snr[0] || snr > filterState.snr[1]) return false;
const channelUtilization = node.deviceMetrics?.channelUtilization ?? 0;
if (
channelUtilization < filterState.channelUtilization[0] ||
channelUtilization > filterState.channelUtilization[1]
) {
return false;
}
const airUtilTx = node.deviceMetrics?.airUtilTx ?? 0;
if (
airUtilTx < filterState.airUtilTx[0] ||
airUtilTx > filterState.airUtilTx[1]
) {
return false;
}
const batt = node.deviceMetrics?.batteryLevel ?? 101;
if (
batt < filterState.batteryLevel[0] ||
batt > filterState.batteryLevel[1]
) {
return false;
}
const voltage = node.deviceMetrics?.voltage ?? 0;
if (
voltage < filterState.voltage[0] ||
voltage > filterState.voltage[1]
) {
return false;
}
const role: Protobuf.Config.Config_DeviceConfig_Role = node.user.role ??
Protobuf.Config.Config_DeviceConfig_Role.CLIENT;
if (!filterState.role.includes(role)) return false;
const hwModel: Protobuf.Mesh.HardwareModel = node.user.hwModel ??
Protobuf.Mesh.HardwareModel.UNSET;
if (!filterState.hwModel.includes(hwModel)) return false;
return true;
},
[defaultFilterValues],
);
const isFilterDirty = useCallback(
(
current: FilterState,
overrides?: Partial<FilterState>,
): boolean => {
const base: FilterState = overrides
? { ...defaultFilterValues, ...overrides }
: defaultFilterValues;
for (const key of Object.keys(base) as (keyof FilterState)[]) {
const currentValue = current[key];
const defaultValue = base[key];
if (Array.isArray(defaultValue) && Array.isArray(currentValue)) {
if (!shallowEqualArray(currentValue, defaultValue)) {
return true;
}
} else if (currentValue !== defaultValue) {
return true;
}
}
function isFilterDirty( return false;
current: FilterState, },
overrides?: Partial<FilterState>, [defaultFilterValues],
): boolean { );
const base: FilterState = overrides
? { ...defaultFilterValues, ...overrides }
: defaultFilterValues;
return (Object.keys(base) as (keyof FilterState)[]).some((key) => {
const curr = current[key];
const def = base[key];
return Array.isArray(def) && Array.isArray(curr)
? !shallowEqualArray(curr, def)
: curr !== def;
});
}
return { nodeFilter, defaultFilterValues, isFilterDirty }; return { nodeFilter, defaultFilterValues, isFilterDirty };
} }

178
src/components/generic/Table/index.test.tsx

@ -1,142 +1,128 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import { Table } from "@components/generic/Table/index.tsx"; import { DataRow, Heading, Table } from "@components/generic/Table/index.tsx";
import { TimeAgo } from "@components/generic/TimeAgo.tsx"; import { TimeAgo } from "@components/generic/TimeAgo.tsx";
import { Mono } from "@components/generic/Mono.tsx"; import { Mono } from "@components/generic/Mono.tsx";
// @ts-types="react" // @ts-types="react"
describe("Generic Table", () => { describe("Generic Table", () => {
it("Can render an empty table.", () => { it("Can render an empty table.", () => {
render( render(<Table headings={[]} rows={[]} />);
<Table
headings={[]}
rows={[]}
/>,
);
expect(screen.getByRole("table")).toBeInTheDocument(); expect(screen.getByRole("table")).toBeInTheDocument();
}); });
it("Can render a table with headers and no rows.", async () => { it("Can render a table with headers and no rows.", async () => {
render( const headings: Heading[] = [
<Table { title: "Short Name", sortable: true },
headings={[ { title: "Last Heard", sortable: true },
{ title: "", type: "blank", sortable: false }, { title: "Connection", sortable: true },
{ title: "Short Name", type: "normal", sortable: true }, ];
{ title: "Long Name", type: "normal", sortable: true }, render(<Table headings={headings} rows={[]} />);
{ title: "Model", type: "normal", sortable: true },
{ title: "MAC Address", type: "normal", sortable: true },
{ title: "Last Heard", type: "normal", sortable: true },
{ title: "SNR", type: "normal", sortable: true },
{ title: "Encryption", type: "normal", sortable: false },
{ title: "Connection", type: "normal", sortable: true },
]}
rows={[]}
/>,
);
await screen.findByRole("table"); await screen.findByRole("table");
expect(screen.getAllByRole("columnheader")).toHaveLength(9); expect(screen.getAllByRole("columnheader")).toHaveLength(3);
}); });
// A simplified version of the rows in pages/Nodes.tsx for testing purposes // Mock data representing devices
const mockDevicesWithShortNameAndConnection = [ const mockDevices = [
{ {
user: { shortName: "TST1" }, id: "TST1",
shortName: "TST1",
hopsAway: 1, hopsAway: 1,
lastHeard: Date.now() + 1000, lastHeard: Date.now() - 3000,
viaMqtt: false, viaMqtt: false,
}, },
{ {
user: { shortName: "TST2" }, id: "TST2",
shortName: "TST2",
hopsAway: 0, hopsAway: 0,
lastHeard: Date.now() + 4000, lastHeard: Date.now() - 1000,
viaMqtt: true, viaMqtt: true,
isFavorite: true, // Favorite device
}, },
{ {
user: { shortName: "TST3" }, id: "TST3",
shortName: "TST3",
hopsAway: 4, hopsAway: 4,
lastHeard: Date.now(), lastHeard: Date.now() - 5000,
viaMqtt: false, viaMqtt: false,
}, },
{ {
user: { shortName: "TST4" }, id: "TST4",
shortName: "TST4",
hopsAway: 3, hopsAway: 3,
lastHeard: Date.now() + 2000, lastHeard: Date.now() - 2000,
viaMqtt: true, viaMqtt: true,
}, },
]; ];
const mockRows = mockDevicesWithShortNameAndConnection.map((node) => [ // Transform mock data into the format expected by the Table component
<h1 data-testshortname key={node.user.shortName}>{node.user.shortName}</h1>, const mockRows: DataRow[] = mockDevices.map((node) => ({
<Mono key="lastHeard" data-testheard> id: node.id,
<TimeAgo timestamp={node.lastHeard * 1000} /> isFavorite: node.isFavorite,
</Mono>, cells: [
<Mono key="hops" data-testhops> {
{node.lastHeard !== 0 content: <b data-testid="short-name">{node.shortName}</b>,
? node.viaMqtt === false && node.hopsAway === 0 sortValue: node.shortName,
? "Direct" },
: `${node.hopsAway?.toString()} ${ {
node.hopsAway ?? 0 > 1 ? "hops" : "hop" content: (
} away` <Mono>
: "-"} <TimeAgo timestamp={node.lastHeard} />
{node.viaMqtt === true ? ", via MQTT" : ""} </Mono>
</Mono>, ),
]); sortValue: node.lastHeard,
},
{
content: (
<Mono>
{node.lastHeard !== 0
? node.viaMqtt === false && node.hopsAway === 0
? "Direct"
: `${node.hopsAway} ${node.hopsAway > 1 ? "hops" : "hop"} away`
: "-"}
{node.viaMqtt ? ", via MQTT" : ""}
</Mono>
),
sortValue: node.hopsAway,
},
],
}));
it("Can sort rows appropriately.", async () => { const headings: Heading[] = [
render( { title: "Short Name", sortable: true },
<Table { title: "Last Heard", sortable: true },
headings={[ { title: "Connection", sortable: true },
{ title: "Short Name", type: "normal", sortable: true }, ];
{ title: "Last Heard", type: "normal", sortable: true },
{ title: "Connection", type: "normal", sortable: true }, it("Can sort rows, keeping favorites at the top", async () => {
]} render(<Table headings={headings} rows={mockRows} />);
rows={mockRows}
/>,
);
const renderedTable = await screen.findByRole("table"); const renderedTable = await screen.findByRole("table");
const columnHeaders = screen.getAllByRole("columnheader"); const columnHeaders = screen.getAllByRole("columnheader");
expect(columnHeaders).toHaveLength(3); expect(columnHeaders).toHaveLength(3);
// Will be sorted "Last heard" "asc" by default const getRenderedOrder = () =>
expect( [...renderedTable.querySelectorAll("[data-testid='short-name']")].map(
[...renderedTable.querySelectorAll("[data-testshortname]")] (el) => el.textContent?.trim(),
.map((el) => el.textContent) );
.map((v) => v?.trim())
.join(","),
)
.toMatch("TST2,TST4,TST1,TST3");
fireEvent.click(columnHeaders[0]);
// Re-sort by Short Name asc // Default sort: "Last Heard" desc. TST2 is favorite, so it's first.
expect( // Then the rest are sorted by lastHeard timestamp (most recent first).
[...renderedTable.querySelectorAll("[data-testshortname]")] // Order of timestamps: TST2 (latest, but favorite), TST4, TST1, TST3 (oldest).
.map((el) => el.textContent) expect(getRenderedOrder()).toEqual(["TST2", "TST4", "TST1", "TST3"]);
.map((v) => v?.trim())
.join(","),
)
.toMatch("TST1,TST2,TST3,TST4");
// Click "Short Name" to sort asc
fireEvent.click(columnHeaders[0]); fireEvent.click(columnHeaders[0]);
// TST2 is favorite, so it's first. Then TST1, TST3, TST4 alphabetically.
expect(getRenderedOrder()).toEqual(["TST2", "TST1", "TST3", "TST4"]);
// Re-sort by Short Name desc // Click "Short Name" again to sort desc
expect( fireEvent.click(columnHeaders[0]);
[...renderedTable.querySelectorAll("[data-testshortname]")] // TST2 is favorite, so it's first. Then TST4, TST3, TST1 reverse alphabetically.
.map((el) => el.textContent) expect(getRenderedOrder()).toEqual(["TST2", "TST4", "TST3", "TST1"]);
.map((v) => v?.trim())
.join(","),
)
.toMatch("TST4,TST3,TST2,TST1");
// Click "Connection" to sort by hops asc
fireEvent.click(columnHeaders[2]); fireEvent.click(columnHeaders[2]);
// TST2 is favorite (and also has 0 hops). Then sorted by hops: TST1 (1), TST4 (3), TST3 (4).
// Re-sort by Hops Away expect(getRenderedOrder()).toEqual(["TST2", "TST1", "TST4", "TST3"]);
expect(
[...renderedTable.querySelectorAll("[data-testshortname]")]
.map((el) => el.textContent)
.map((v) => v?.trim())
.join(","),
)
.toMatch("TST2,TST1,TST4,TST3");
}); });
}); });

206
src/components/generic/Table/index.tsx

@ -1,30 +1,32 @@
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"; import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { useState } from "react"; import { useMemo, useState } from "react";
import React from "react"; import React from "react";
import { cn } from "@core/utils/cn.ts"; import { cn } from "@core/utils/cn.ts";
interface FavoriteIconProps { export interface Heading {
showFavorite: boolean; title: string;
sortable: boolean;
} }
interface AvatarCellProps { interface Cell {
children: React.ReactElement<FavoriteIconProps>; content: React.ReactNode;
sortValue: string | number;
} }
export interface TableProps { export interface DataRow {
headings: Heading[]; id: string | number;
rows: React.ReactElement<AvatarCellProps>[][]; isFavorite?: boolean;
cells: Cell[];
} }
export interface Heading { export interface TableProps {
title: string; headings: Heading[];
type: "blank" | "normal"; rows: DataRow[];
sortable: boolean;
} }
function numericHops(hopsAway: string | unknown): number { function numericHops(hopsAway: string | number): number {
if (typeof hopsAway !== "string") { if (typeof hopsAway === "number") {
return Number.MAX_SAFE_INTEGER; return hopsAway;
} }
if (hopsAway.match(/direct/i)) { if (hopsAway.match(/direct/i)) {
return 0; return 0;
@ -37,7 +39,7 @@ export const Table = ({ headings, rows }: TableProps) => {
const [sortColumn, setSortColumn] = useState<string | null>("Last Heard"); const [sortColumn, setSortColumn] = useState<string | null>("Last Heard");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
const headingSort = (title: string) => { const handleSort = (title: string) => {
if (sortColumn === title) { if (sortColumn === title) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc"); setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else { } else {
@ -46,72 +48,36 @@ export const Table = ({ headings, rows }: TableProps) => {
} }
}; };
const getElement = (cell: React.ReactNode): React.ReactElement | null => { const sortedRows = useMemo(() => {
if (!React.isValidElement(cell)) { if (!sortColumn) return rows;
return null;
}
if (cell.type === React.Fragment) {
const childrenArray = React.Children.toArray(cell.props.children);
const firstElement = childrenArray.find((child) =>
React.isValidElement(child)
);
return (firstElement as React.ReactElement) ?? null;
}
// If not a fragment, return the element itself
return cell;
};
const sortedRows = rows.slice().sort((a, b) => {
if (!sortColumn) return 0;
const columnIndex = headings.findIndex((h) => h.title === sortColumn); const columnIndex = headings.findIndex((h) => h.title === sortColumn);
if (columnIndex === -1) return 0; if (columnIndex === -1) return rows;
const elementA = getElement(a[columnIndex]); return [...rows].sort((a, b) => {
const elementB = getElement(b[columnIndex]); if (a.isFavorite !== b.isFavorite) {
return a.isFavorite ? -1 : 1;
}
// Avatar contains the prop showFavorite which indicates isFavorite const aCell = a.cells[columnIndex];
const favA = a[0]?.props?.children?.props?.showFavorite ?? false; const bCell = b.cells[columnIndex];
const favB = b[0]?.props?.children?.props?.showFavorite ?? false;
// Always put favorites at the top let aValue: string | number;
if (favA !== favB) return favA ? -1 : 1; let bValue: string | number;
if (sortColumn === "Last Heard") { if (sortColumn === "Connection") {
const aTimestamp = elementA?.props?.children?.props?.timestamp ?? 0; aValue = numericHops(aCell.sortValue);
const bTimestamp = elementB?.props?.children?.props?.timestamp ?? 0; bValue = numericHops(bCell.sortValue);
if (aTimestamp < bTimestamp) return sortOrder === "asc" ? -1 : 1; } else {
if (aTimestamp > bTimestamp) return sortOrder === "asc" ? 1 : -1; aValue = aCell.sortValue;
return 0; bValue = bCell.sortValue;
} }
if (sortColumn === "Connection") { if (aValue < bValue) return sortOrder === "asc" ? -1 : 1;
const aHopsStr = elementA?.props?.children[0]; if (aValue > bValue) return sortOrder === "asc" ? 1 : -1;
const bHopsStr = elementB?.props?.children[0];
const aNumHops = numericHops(aHopsStr);
const bNumHops = numericHops(bHopsStr);
if (aNumHops < bNumHops) return sortOrder === "asc" ? -1 : 1;
if (aNumHops > bNumHops) return sortOrder === "asc" ? 1 : -1;
return 0; return 0;
} });
}, [rows, sortColumn, sortOrder, headings]);
const aValue = elementA?.props?.children;
const bValue = elementB?.props?.children;
const valA = aValue ?? "";
const valB = bValue ?? "";
// Ensure consistent comparison for potentially different types
const compareA = typeof valA === "string" || typeof valA === "number"
? valA
: String(valA);
const compareB = typeof valB === "string" || typeof valB === "number"
? valB
: String(valB);
if (compareA < compareB) return sortOrder === "asc" ? -1 : 1;
if (compareA > compareB) return sortOrder === "asc" ? 1 : -1;
return 0;
});
return ( return (
<table className="min-w-full" style={{ contentVisibility: "auto" }}> <table className="min-w-full" style={{ contentVisibility: "auto" }}>
@ -121,17 +87,15 @@ export const Table = ({ headings, rows }: TableProps) => {
<th <th
key={heading.title} key={heading.title}
scope="col" scope="col"
className={`py-2 pr-3 text-left ${ className={cn(
heading.sortable "py-2 pr-3 text-left",
? "cursor-pointer hover:brightness-hover active:brightness-press" heading.sortable &&
: "" "cursor-pointer hover:brightness-hover active:brightness-press",
}`} )}
onClick={() => heading.sortable && headingSort(heading.title)} onClick={() => heading.sortable && handleSort(heading.title)}
onKeyUp={(e) => { onKeyUp={(e) => {
if ( if (heading.sortable && (e.key === "Enter" || e.key === " ")) {
heading.sortable && (e.key === "Enter" || e.key === " ") handleSort(heading.title);
) {
headingSort(heading.title);
} }
}} }}
tabIndex={heading.sortable ? 0 : -1} tabIndex={heading.sortable ? 0 : -1}
@ -153,49 +117,37 @@ export const Table = ({ headings, rows }: TableProps) => {
</tr> </tr>
</thead> </thead>
<tbody className="max-w-fit"> <tbody className="max-w-fit">
{sortedRows.map((row) => { {sortedRows.map((row) => (
const firstCellKey = <tr
(React.isValidElement(row[0]) && row[0].key !== null) key={row.id}
? String(row[0].key) className={cn(
: null; row.isFavorite
const rowKey = firstCellKey ?? Math.random().toString(); // Use random only as last resort ? "bg-yellow-100/30 dark:bg-slate-800 odd:bg-yellow-200/30 dark:odd:bg-slate-600/40"
: "bg-white dark:bg-slate-900 odd:bg-slate-200/40 dark:odd:bg-slate-800/40",
const isFavorite = row[0]?.props?.children?.props?.showFavorite ?? )}
false; >
return ( {row.cells.map((cell, cellIndex) =>
<tr cellIndex === 0
key={rowKey} ? (
className={cn( <th
"", key={`${row.id}_${cellIndex}`}
isFavorite className="whitespace-nowrap px-3 py-2 text-sm text-left text-text-secondary"
? "bg-yellow-100/30 dark:bg-slate-800 odd:bg-yellow-200/30 dark:odd:bg-slate-600/40" scope="row"
: "bg-white dark:bg-slate-900 odd:bg-slate-200/40 dark:odd:bg-slate-800/40", >
)} {cell.content}
> </th>
{row.map((item, cellIndex) => { )
const cellKey = `${rowKey}_${cellIndex}`; : (
return cellIndex === 0 <td
? ( key={`${row.id}_${cellIndex}`}
<th className="whitespace-nowrap px-3 py-2 text-sm text-text-secondary"
key={cellKey} >
className="whitespace-nowrap px-3 py-2 text-sm text-left text-text-secondary" {cell.content}
scope="row" </td>
> )
{item} )}
</th> </tr>
) ))}
: (
<td
key={cellKey}
className="whitespace-nowrap px-3 py-2 text-sm text-text-secondary"
>
{item}
</td>
);
})}
</tr>
);
})}
</tbody> </tbody>
</table> </table>
); );

4
src/core/hooks/useBrowserFeatureDetection.ts

@ -10,8 +10,8 @@ interface BrowserSupport {
export function useBrowserFeatureDetection(): BrowserSupport { export function useBrowserFeatureDetection(): BrowserSupport {
const support = useMemo(() => { const support = useMemo(() => {
const features: [BrowserFeature, boolean][] = [ const features: [BrowserFeature, boolean][] = [
["Web Bluetooth", !!navigator?.bluetooth], ["Web Bluetooth", !!navigator.bluetooth],
["Web Serial", !!navigator?.serial], ["Web Serial", !!navigator.serial],
[ [
"Secure Context", "Secure Context",
globalThis.location.protocol === "https:" || globalThis.location.protocol === "https:" ||

6
src/core/hooks/useCookie.ts

@ -1,9 +1,9 @@
import Cookies, { type CookieAttributes } from "js-cookie"; import Cookies from "js-cookie";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
interface CookieHookResult<T> { interface CookieHookResult<T> {
value: T | undefined; value: T | undefined;
setCookie: (value: T, options?: CookieAttributes) => void; setCookie: (value: T, options?: Cookies.CookieAttributes) => void;
removeCookie: () => void; removeCookie: () => void;
} }
@ -22,7 +22,7 @@ function useCookie<T extends object>(
}); });
const setCookie = useCallback( const setCookie = useCallback(
(value: T, options?: CookieAttributes) => { (value: T, options?: Cookies.CookieAttributes) => {
try { try {
Cookies.set(cookieName, JSON.stringify(value), options); Cookies.set(cookieName, JSON.stringify(value), options);
setCookieValue(value); setCookieValue(value);

68
src/core/hooks/usePinnedItems.test.ts

@ -1,68 +0,0 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { usePinnedItems } from "./usePinnedItems.ts";
const mockSetPinnedItems = vi.fn();
const mockUseLocalStorage = vi.fn();
vi.mock("@core/hooks/useLocalStorage.ts", () => ({
default: (...args) => mockUseLocalStorage(...args),
}));
describe("usePinnedItems", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns default pinnedItems and togglePinnedItem", () => {
mockUseLocalStorage.mockReturnValue([[], mockSetPinnedItems]);
const { result } = renderHook(() =>
usePinnedItems({ storageName: "test-storage" })
);
expect(result.current.pinnedItems).toEqual([]);
expect(typeof result.current.togglePinnedItem).toBe("function");
});
it("adds an item if it's not already pinned", () => {
mockUseLocalStorage.mockReturnValue([["item1"], mockSetPinnedItems]);
const { result } = renderHook(() =>
usePinnedItems({ storageName: "test-storage" })
);
act(() => {
result.current.togglePinnedItem("item2");
});
expect(mockSetPinnedItems).toHaveBeenCalledWith(expect.any(Function));
const updater = mockSetPinnedItems.mock.calls[0][0];
const updated = updater(["item1"]);
expect(updated).toEqual(["item1", "item2"]);
});
it("removes an item if it's already pinned", () => {
mockUseLocalStorage.mockReturnValue([
["item1", "item2"],
mockSetPinnedItems,
]);
const { result } = renderHook(() =>
usePinnedItems({ storageName: "test-storage" })
);
act(() => {
result.current.togglePinnedItem("item1");
});
expect(mockSetPinnedItems).toHaveBeenCalledWith(expect.any(Function));
const updater = mockSetPinnedItems.mock.calls[0][0];
const updated = updater(["item1", "item2"]);
expect(updated).toEqual(["item2"]);
});
});

82
src/core/stores/deviceStore.mock.ts

@ -0,0 +1,82 @@
import { vi } from "vitest";
import { type Device } from "./deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
/**
* You can spread this base mock in your tests and override only the
* properties relevant to a specific test case.
*
* @example
* vi.mocked(useDevice).mockReturnValue({
* ...mockDeviceStore,
* getNode: (nodeNum) => mockNodes.get(nodeNum),
* });
*/
export const mockDeviceStore: Device = {
id: 0,
status: 5 as const,
channels: new Map(),
config: {} as Protobuf.LocalOnly.LocalConfig,
moduleConfig: {} as Protobuf.LocalOnly.LocalModuleConfig,
workingConfig: [],
workingModuleConfig: [],
hardware: {} as Protobuf.Mesh.MyNodeInfo,
metadata: new Map(),
traceroutes: new Map(),
nodeErrors: new Map(),
connection: undefined,
activeNode: 0,
waypoints: [],
pendingSettingsChanges: false,
messageDraft: "",
unreadCounts: new Map(),
nodesMap: new Map(),
dialog: {
import: false,
QR: false,
shutdown: false,
reboot: false,
rebootOTA: false,
deviceName: false,
nodeRemoval: false,
pkiBackup: false,
nodeDetails: false,
unsafeRoles: false,
refreshKeys: false,
deleteMessages: false,
},
setStatus: vi.fn(),
setConfig: vi.fn(),
setModuleConfig: vi.fn(),
setWorkingConfig: vi.fn(),
setWorkingModuleConfig: vi.fn(),
setHardware: vi.fn(),
setActiveNode: vi.fn(),
setPendingSettingsChanges: vi.fn(),
addChannel: vi.fn(),
addWaypoint: vi.fn(),
addNodeInfo: vi.fn(),
addUser: vi.fn(),
addPosition: vi.fn(),
addConnection: vi.fn(),
addTraceRoute: vi.fn(),
addMetadata: vi.fn(),
removeNode: vi.fn(),
setDialogOpen: vi.fn(),
getDialogOpen: vi.fn().mockReturnValue(false),
processPacket: vi.fn(),
setMessageDraft: vi.fn(),
setNodeError: vi.fn(),
clearNodeError: vi.fn(),
getNodeError: vi.fn().mockReturnValue(undefined),
hasNodeError: vi.fn().mockReturnValue(false),
incrementUnread: vi.fn(),
resetUnread: vi.fn(),
getNodes: vi.fn().mockReturnValue([]),
getNodesLength: vi.fn().mockReturnValue(0),
getNode: vi.fn().mockReturnValue(undefined),
getMyNode: vi.fn(),
sendAdminMessage: vi.fn(),
updateFavorite: vi.fn(),
updateIgnored: vi.fn(),
};

68
src/core/stores/deviceStore.ts

@ -144,7 +144,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
addDevice: (id: number) => { addDevice: (id: number) => {
set( set(
produce<DeviceState>((draft) => { produce<PrivateDeviceState>((draft) => {
draft.devices.set(id, { draft.devices.set(id, {
id, id,
status: Types.DeviceStatusEnum.DeviceDisconnected, status: Types.DeviceStatusEnum.DeviceDisconnected,
@ -181,7 +181,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
setStatus: (status: Types.DeviceStatusEnum) => { setStatus: (status: Types.DeviceStatusEnum) => {
set( set(
produce<DeviceState>((draft) => { produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (device) { if (device) {
device.status = status; device.status = status;
@ -191,7 +191,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
}, },
setConfig: (config: Protobuf.Config.Config) => { setConfig: (config: Protobuf.Config.Config) => {
set( set(
produce<DeviceState>((draft) => { produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (device) { if (device) {
switch (config.payloadVariant.case) { switch (config.payloadVariant.case) {
@ -233,7 +233,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
}, },
setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => { setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => {
set( set(
produce<DeviceState>((draft) => { produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (device) { if (device) {
switch (config.payloadVariant.case) { switch (config.payloadVariant.case) {
@ -301,7 +301,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
}, },
setWorkingConfig: (config: Protobuf.Config.Config) => { setWorkingConfig: (config: Protobuf.Config.Config) => {
set( set(
produce<DeviceState>((draft) => { produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (!device) return; if (!device) return;
const index = device.workingConfig.findIndex( const index = device.workingConfig.findIndex(
@ -320,7 +320,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
moduleConfig: Protobuf.ModuleConfig.ModuleConfig, moduleConfig: Protobuf.ModuleConfig.ModuleConfig,
) => { ) => {
set( set(
produce<DeviceState>((draft) => { produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (!device) return; if (!device) return;
const index = device.workingModuleConfig.findIndex( const index = device.workingModuleConfig.findIndex(
@ -432,7 +432,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => { setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => {
set( set(
produce<DeviceState>((draft) => { produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (device) { if (device) {
device.hardware = hardware; device.hardware = hardware;
@ -442,7 +442,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
}, },
setPendingSettingsChanges: (state) => { setPendingSettingsChanges: (state) => {
set( set(
produce<DeviceState>((draft) => { produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (device) { if (device) {
device.pendingSettingsChanges = state; device.pendingSettingsChanges = state;
@ -452,7 +452,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
}, },
addChannel: (channel: Protobuf.Channel.Channel) => { addChannel: (channel: Protobuf.Channel.Channel) => {
set( set(
produce<DeviceState>((draft) => { produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (device) { if (device) {
device.channels.set(channel.index, channel); device.channels.set(channel.index, channel);
@ -462,7 +462,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
}, },
addWaypoint: (waypoint: Protobuf.Mesh.Waypoint) => { addWaypoint: (waypoint: Protobuf.Mesh.Waypoint) => {
set( set(
produce<DeviceState>((draft) => { produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (device) { if (device) {
const index = device.waypoints.findIndex((wp) => const index = device.waypoints.findIndex((wp) =>
@ -479,7 +479,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
}, },
addNodeInfo: (nodeInfo) => { addNodeInfo: (nodeInfo) => {
set( set(
produce<DeviceState>((draft) => { produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (!device) return; if (!device) return;
@ -489,7 +489,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
}, },
setActiveNode: (node) => { setActiveNode: (node) => {
set( set(
produce<DeviceState>((draft) => { produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (device) { if (device) {
device.activeNode = node; device.activeNode = node;
@ -499,7 +499,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
}, },
addUser: (user) => { addUser: (user) => {
set( set(
produce<DeviceState>((draft) => { produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (!device) { if (!device) {
return; return;
@ -514,7 +514,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
}, },
addPosition: (position) => { addPosition: (position) => {
set( set(
produce<DeviceState>((draft) => { produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (!device) { if (!device) {
return; return;
@ -529,7 +529,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
}, },
addConnection: (connection) => { addConnection: (connection) => {
set( set(
produce<DeviceState>((draft) => { produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (device) { if (device) {
device.connection = connection; device.connection = connection;
@ -539,7 +539,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
}, },
addMetadata: (from, metadata) => { addMetadata: (from, metadata) => {
set( set(
produce<DeviceState>((draft) => { produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (device) { if (device) {
device.metadata.set(from, metadata); device.metadata.set(from, metadata);
@ -549,7 +549,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
}, },
addTraceRoute: (traceroute) => { addTraceRoute: (traceroute) => {
set( set(
produce<DeviceState>((draft) => { produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (!device) return; if (!device) return;
const routes = device.traceroutes.get(traceroute.from) ?? []; const routes = device.traceroutes.get(traceroute.from) ?? [];
@ -558,9 +558,9 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
}), }),
); );
}, },
removeNode: (nodeNum) => { removeNode: (nodeNum: number) => {
set( set(
produce<DeviceState>((draft) => { produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (!device) { if (!device) {
return; return;
@ -571,7 +571,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
}, },
setDialogOpen: (dialog: DialogVariant, open: boolean) => { setDialogOpen: (dialog: DialogVariant, open: boolean) => {
set( set(
produce<DeviceState>((draft) => { produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (device) { if (device) {
device.dialog[dialog] = open; device.dialog[dialog] = open;
@ -586,7 +586,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
}, },
processPacket(data: ProcessPacketParams) { processPacket(data: ProcessPacketParams) {
set( set(
produce<DeviceState>((draft) => { produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (!device) return; if (!device) return;
const node = device.nodesMap.get(data.from); const node = device.nodesMap.get(data.from);
@ -609,7 +609,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
}, },
setMessageDraft: (message: string) => { setMessageDraft: (message: string) => {
set( set(
produce<DeviceState>((draft) => { produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (device) { if (device) {
device.messageDraft = message; device.messageDraft = message;
@ -617,9 +617,9 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
}), }),
); );
}, },
setNodeError: (nodeNum, error) => { setNodeError: (nodeNum: number, error: string) => {
set( set(
produce<DeviceState>((draft) => { produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (device) { if (device) {
device.nodeErrors.set(nodeNum, { node: nodeNum, error }); device.nodeErrors.set(nodeNum, { node: nodeNum, error });
@ -629,7 +629,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
}, },
clearNodeError: (nodeNum: number) => { clearNodeError: (nodeNum: number) => {
set( set(
produce<DeviceState>((draft) => { produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (device) { if (device) {
device.nodeErrors.delete(nodeNum); device.nodeErrors.delete(nodeNum);
@ -649,7 +649,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
}, },
incrementUnread: (nodeNum: number) => { incrementUnread: (nodeNum: number) => {
set( set(
produce<DeviceState>((draft) => { produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (!device) return; if (!device) return;
const currentCount = device.unreadCounts.get(nodeNum) ?? 0; const currentCount = device.unreadCounts.get(nodeNum) ?? 0;
@ -659,7 +659,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
}, },
resetUnread: (nodeNum: number) => { resetUnread: (nodeNum: number) => {
set( set(
produce<DeviceState>((draft) => { produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (!device) return; if (!device) return;
device.unreadCounts.set(nodeNum, 0); device.unreadCounts.set(nodeNum, 0);
@ -735,10 +735,12 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
})); }));
set( set(
produce<DeviceState>((draft) => { produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
const node = device?.nodesMap.get(nodeNum); const node = device?.nodesMap.get(nodeNum);
node.isFavorite = isFavorite; if (node) {
node.isFavorite = isFavorite;
}
}), }),
); );
}, },
@ -756,10 +758,12 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
})); }));
set( set(
produce<DeviceState>((draft) => { produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
const node = device?.nodesMap.get(nodeNum); const node = device?.nodesMap.get(nodeNum);
node.isIgnored = isIgnored; if (node) {
node.isIgnored = isIgnored;
}
}), }),
); );
}, },
@ -776,7 +780,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
removeDevice: (id) => { removeDevice: (id) => {
set( set(
produce<DeviceState>((draft) => { produce<PrivateDeviceState>((draft) => {
draft.devices.delete(id); draft.devices.delete(id);
}), }),
); );

15
src/core/stores/storage/indexDB.ts

@ -1,4 +1,4 @@
import { PersistStorage, StateStorage } from "zustand/middleware"; import { PersistStorage, StateStorage, StorageValue } from "zustand/middleware";
import { del, get, set } from "idb-keyval"; import { del, get, set } from "idb-keyval";
import { ChannelId, MessageLogMap } from "@core/stores/messageStore/types.ts"; import { ChannelId, MessageLogMap } from "@core/stores/messageStore/types.ts";
@ -56,18 +56,25 @@ const reviver: JsonReviver = (_, value) => {
}; };
export const storageWithMapSupport: PersistStorage<PersistedMessageState> = { export const storageWithMapSupport: PersistStorage<PersistedMessageState> = {
getItem: async (name): Promise<PersistedMessageState | null> => { getItem: async (
name,
): Promise<StorageValue<PersistedMessageState> | null> => {
const str = await zustandIndexDBStorage.getItem(name); const str = await zustandIndexDBStorage.getItem(name);
if (!str) return null; if (!str) return null;
try { try {
const parsed = JSON.parse(str, reviver) as PersistedMessageState; const parsed = JSON.parse(str, reviver) as StorageValue<
PersistedMessageState
>;
return parsed; return parsed;
} catch (error) { } catch (error) {
console.error(`Error parsing persisted state (${name}):`, error); console.error(`Error parsing persisted state (${name}):`, error);
return null; return null;
} }
}, },
setItem: async (name, newValue: PersistedMessageState): Promise<void> => { setItem: async (
name,
newValue: StorageValue<PersistedMessageState>,
): Promise<void> => {
try { try {
const str = JSON.stringify(newValue, replacer); const str = JSON.stringify(newValue, replacer);
await zustandIndexDBStorage.setItem(name, str); await zustandIndexDBStorage.setItem(name, str);

2
src/core/utils/eventBus.test.ts

@ -4,7 +4,7 @@ import { eventBus } from "@core/utils/eventBus.ts";
describe("EventBus", () => { describe("EventBus", () => {
beforeEach(() => { beforeEach(() => {
// Reset event listeners before each test // Reset event listeners before each test
eventBus.listeners = {}; eventBus.offAll();
}); });
it("should register an event listener and trigger it on emit", () => { it("should register an event listener and trigger it on emit", () => {

8
src/core/utils/eventBus.ts

@ -34,6 +34,14 @@ class EventBus {
} }
} }
public offAll<T extends EventName>(event?: T): void {
if (event) {
this.listeners[event] = [];
} else {
this.listeners = {};
}
}
public emit<T extends EventName>(event: T, data: EventMap[T]): void { public emit<T extends EventName>(event: T, data: EventMap[T]): void {
if (!this.listeners[event]) return; if (!this.listeners[event]) return;

2
src/core/utils/ip.test.ts

@ -60,7 +60,7 @@ describe("IP Address Conversion Functions", () => {
for (const ip of testIps) { for (const ip of testIps) {
const int = convertIpAddressToInt(ip); const int = convertIpAddressToInt(ip);
expect(int).not.toBeNull(); expect(int).not.toBeNull();
if (int !== null) { if (int !== null && typeof int === "number") {
const convertedBack = convertIntToIpAddress(int); const convertedBack = convertIntToIpAddress(int);
expect(convertedBack).toBe(ip); expect(convertedBack).toBe(ip);
} }

18
src/core/utils/sort.ts

@ -0,0 +1,18 @@
export function intlSort<T extends PropertyKey>(
arr: T[],
order: "asc" | "desc" = "asc",
locale: Intl.Locale,
): T[] {
const collator = new Intl.Collator(locale, { sensitivity: "base" });
return arr.sort((a, b) => {
const stringA = String(a);
const stringB = String(b);
if (order === "asc") {
return collator.compare(stringA, stringB);
} else {
return collator.compare(stringB, stringA);
}
});
}

80
src/core/utils/test.tsx

@ -1,80 +0,0 @@
import {
createMemoryHistory,
createRouter,
Outlet,
RootRoute,
Route,
RouterProvider,
} from "@tanstack/react-router";
import { render as rtlRender, RenderOptions } from "@testing-library/react";
import type { FunctionComponent, ReactElement, ReactNode } from "react";
// a root route for the test router.
const rootRoute = new RootRoute({
component: () => (
<>
<Outlet />
</>
),
});
interface CustomRenderOptions extends Omit<RenderOptions, "wrapper"> {
initialEntries?: string[];
ui?: ReactElement;
}
let currentRouter: ReturnType<typeof createRouter> | null = null;
/**
* Custom render function for testing components that need TanStack Router context.
* @param ui The main ReactElement to render (your component under test).
* @param options Custom render options including initialEntries for the router.
* @returns An object containing the testing-library render result and the router instance.
*/
const customRender = (
ui: ReactElement,
options: CustomRenderOptions = {},
) => {
const { initialEntries = ["/"], ...renderOptions } = options;
// A specific route that renders the component under test (ui).
// It defaults to the first path in initialEntries or '/'.
const testComponentRoute = new Route({
getParentRoute: () => rootRoute,
path: initialEntries[0] || "/",
component: () => ui, // The component passed to render will be the element for this route
});
const routeTree = rootRoute.addChildren([testComponentRoute]);
const router = createRouter({
history: createMemoryHistory({ initialEntries }),
routeTree,
// You can add default error components or other router options if needed for tests.
// defaultErrorComponent: ({ error }) => <div>Test Error: {error.message}</div>,
});
currentRouter = router; // Store the router instance for access in tests
const Wrapper: FunctionComponent<{ children?: ReactNode }> = (
{ children },
) => {
return (
<>
<RouterProvider router={router} />
{children}
</>
);
};
const renderResult = rtlRender(ui, { wrapper: Wrapper, ...renderOptions });
return {
...renderResult,
router,
};
};
export * from "@testing-library/react";
export { customRender as render };
export const getTestRouter = () => currentRouter;

2
src/i18n/config.ts

@ -35,7 +35,7 @@ i18next
"default": ["en"], "default": ["en"],
}, },
fallbackNS: ["common", "ui", "dialog"], fallbackNS: ["common", "ui", "dialog"],
debug: import.meta.env.DEV, debug: import.meta.env.MODE === "development",
supportedLngs: supportedLanguages?.map((lang) => lang.code), supportedLngs: supportedLanguages?.map((lang) => lang.code),
ns: [ ns: [
"channels", "channels",

2
src/i18n/locales/en/commandPalette.json

@ -1,7 +1,7 @@
{ {
"emptyState": "No results found.", "emptyState": "No results found.",
"page": { "page": {
"title": "Command Palette" "title": "Command Menu"
}, },
"pinGroup": { "pinGroup": {
"label": "Pin command group" "label": "Pin command group"

6
src/i18n/locales/en/moduleConfig.json

@ -253,12 +253,12 @@
"description": "MQTT root topic to use for default/custom servers" "description": "MQTT root topic to use for default/custom servers"
}, },
"proxyToClientEnabled": { "proxyToClientEnabled": {
"label": "Proxy to Client Enabled", "label": "MQTT Client Proxy Enabled",
"description": "Use the client's internet connection for MQTT (feature only active in mobile apps)" "description": "Utilizes the network connection to proxy MQTT messages to the client."
}, },
"mapReportingEnabled": { "mapReportingEnabled": {
"label": "Map Reporting Enabled", "label": "Map Reporting Enabled",
"description": "Enable or disable map reporting" "description": "Your node will periodically send an unencrypted map report packet to the configured MQTT server, this includes id, short and long name, approximate location, hardware model, role, firmware version, LoRa region, modem preset and primary channel name."
}, },
"mapReportSettings": { "mapReportSettings": {
"publishIntervalSecs": { "publishIntervalSecs": {

6
src/i18n/locales/en/ui.json

@ -80,6 +80,12 @@
}, },
"showPassword": { "showPassword": {
"label": "Show password" "label": "Show password"
},
"deliveryStatus": {
"delivered": "Delivered",
"failed": "Delivery Failed",
"waiting": "Waiting",
"unknown": "Unknown"
} }
}, },
"general": { "general": {

4
src/index.css

@ -3,6 +3,10 @@
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
@view-transition {
navigation: auto;
}
@theme { @theme {
--font-mono: --font-mono:
Cascadia Code, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Cascadia Code, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,

36
src/pages/Dashboard/index.tsx

@ -4,14 +4,7 @@ import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { Button } from "@components/UI/Button.tsx"; import { Button } from "@components/UI/Button.tsx";
import { Separator } from "@components/UI/Seperator.tsx"; import { Separator } from "@components/UI/Seperator.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx"; import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { import { ListPlusIcon, PlusIcon, UsersIcon } from "lucide-react";
BluetoothIcon,
ListPlusIcon,
NetworkIcon,
PlusIcon,
UsbIcon,
UsersIcon,
} from "lucide-react";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import LanguageSwitcher from "@components/LanguageSwitcher.tsx"; import LanguageSwitcher from "@components/LanguageSwitcher.tsx";
@ -60,32 +53,6 @@ export const Dashboard = () => {
?.longName ?? ?.longName ??
t("unknown.shortName")} t("unknown.shortName")}
</p> </p>
<div className="inline-flex w-24 justify-center gap-2 rounded-full bg-slate-100 py-1 text-xs font-semibold text-slate-900 transition-colors hover:bg-slate-700 hover:text-slate-50">
{device.connection?.connType === "ble" && (
<>
<BluetoothIcon size={16} />
{t(
"dashboard.connectionType_ble",
)}
</>
)}
{device.connection?.connType === "serial" && (
<>
<UsbIcon size={16} />
{t(
"dashboard.connectionType_serial",
)}
</>
)}
{device.connection?.connType === "http" && (
<>
<NetworkIcon size={16} />
{t(
"dashboard.connectionType_network",
)}
</>
)}
</div>
<div className="mt-2 sm:flex sm:justify-between"> <div className="mt-2 sm:flex sm:justify-between">
<div className="flex gap-2 text-sm text-slate-500"> <div className="flex gap-2 text-sm text-slate-500">
<UsersIcon <UsersIcon
@ -114,7 +81,6 @@ export const Dashboard = () => {
<Heading as="h3"> <Heading as="h3">
{t("dashboard.noDevicesTitle")} {t("dashboard.noDevicesTitle")}
</Heading> </Heading>
{/* <LanguageSwitcher /> */}
<Subtle> <Subtle>
{t("dashboard.noDevicesDescription")} {t("dashboard.noDevicesDescription")}
</Subtle> </Subtle>

81
src/pages/Messages.test.tsx

@ -1,81 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { MessagesPage } from "./Messages.tsx";
import { useDevice } from "../core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
vi.mock("../core/stores/deviceStore", () => ({
useDevice: vi.fn(),
}));
const mockUseDevice = {
channels: new Map([
[0, {
index: 0,
settings: { name: "Primary" },
role: Protobuf.Channel.Channel_Role.PRIMARY,
}],
]),
nodes: new Map([
[0, {
num: 0,
user: { longName: "Test Node 0", shortName: "TN0", publicKey: "0000" },
}],
[1111, {
num: 1111,
user: { longName: "Test Node 1", shortName: "TN1", publicKey: "12345" },
}],
[2222, {
num: 2222,
user: { longName: "Test Node 2", shortName: "TN2", publicKey: "67890" },
}],
[3333, {
num: 3333,
user: { longName: "Test Node 3", shortName: "TN3", publicKey: "11111" },
}],
]),
hardware: { myNodeNum: 1 },
messages: { broadcast: new Map(), direct: new Map() },
metadata: new Map(),
unreadCounts: new Map([[1111, 3], [2222, 10]]),
resetUnread: vi.fn(),
hasNodeError: vi.fn(),
};
describe.skip("Messages Page", () => {
beforeEach(() => {
vi.mocked(useDevice).mockReturnValue(mockUseDevice);
});
it("sorts unreads to the top", () => {
render(<MessagesPage />);
const buttonOrder = screen.getAllByRole("button").filter((b) =>
b.textContent.includes("Test Node")
);
expect(buttonOrder[0].textContent).toContain("TN2Test Node 210");
expect(buttonOrder[1].textContent).toContain("TN1Test Node 13");
expect(buttonOrder[2].textContent).toContain("TN0Test Node 0");
expect(buttonOrder[3].textContent).toContain("TN3Test Node 3");
});
it("updates unread when active chat changes", () => {
render(<MessagesPage />);
const nodeButton =
screen.getAllByRole("button").filter((b) =>
b.textContent.includes("TN1Test Node 13")
)[0];
fireEvent.click(nodeButton);
expect(mockUseDevice.resetUnread).toHaveBeenCalledWith(1111, 0);
});
it("does not update the incorrect node", () => {
render(<MessagesPage />);
const nodeButton =
screen.getAllByRole("button").filter((b) =>
b.textContent.includes("TN1Test Node 1")
)[0];
fireEvent.click(nodeButton);
expect(mockUseDevice.resetUnread).toHaveBeenCalledWith(1111, 0);
expect(mockUseDevice.unreadCounts.get(2222)).toBe(10);
});
});

107
src/pages/Messages.tsx

@ -28,9 +28,19 @@ import { Input } from "@components/UI/Input.tsx";
import { randId } from "@core/utils/randId.ts"; import { randId } from "@core/utils/randId.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "@tanstack/react-router"; import { useNavigate, useParams } from "@tanstack/react-router";
import { messagesWithParamsRoute } from "@app/routes.tsx";
type NodeInfoWithUnread = Protobuf.Mesh.NodeInfo & { unreadCount: number }; type NodeInfoWithUnread = Protobuf.Mesh.NodeInfo & { unreadCount: number };
function SelectMessageChat() {
const { t } = useTranslation("messages");
return (
<div className="flex-1 flex items-center justify-center text-slate-500 p-4">
{t("selectChatPrompt.text", { ns: "messages" })}
</div>
);
}
export const MessagesPage = () => { export const MessagesPage = () => {
const { const {
channels, channels,
@ -46,7 +56,9 @@ export const MessagesPage = () => {
getMessages, getMessages,
setMessageState, setMessageState,
} = useMessageStore(); } = useMessageStore();
const params = useParams({ from: "", shouldThrow: false });
const { type, chatId } = useParams({ from: messagesWithParamsRoute.id });
const navigate = useNavigate(); const navigate = useNavigate();
const { toast } = useToast(); const { toast } = useToast();
const { isCollapsed } = useSidebar(); const { isCollapsed } = useSidebar();
@ -54,34 +66,33 @@ export const MessagesPage = () => {
const { t } = useTranslation(["messages", "channels", "ui"]); const { t } = useTranslation(["messages", "channels", "ui"]);
const deferredSearch = useDeferredValue(searchTerm); const deferredSearch = useDeferredValue(searchTerm);
const chatType = params.type === "direct" const navigateToChat = useCallback((type: MessageType, id: string) => {
const typeParam = type === MessageType.Direct ? "direct" : "broadcast";
navigate({ to: `/messages/${typeParam}/${id}` });
}, [navigate]);
const chatType = type === "direct"
? MessageType.Direct ? MessageType.Direct
: params.type === "broadcast" : MessageType.Broadcast;
? MessageType.Broadcast const numericChatId = Number(chatId);
: undefined;
const activeChat = params.chatId ? Number(params.chatId) : undefined;
const allChannels = Array.from(channels.values()); const allChannels = Array.from(channels.values());
const filteredChannels = allChannels.filter( const filteredChannels = allChannels.filter(
(ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED, (ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED,
); );
const currentChannel = channels.get(activeChat);
const otherNode = getNode(activeChat);
const isDirect = chatType === MessageType.Direct;
const isBroadcast = chatType === MessageType.Broadcast;
const navigateToChat = useCallback((type: MessageType, chatId: number) => {
const typeParam = type === MessageType.Direct ? "direct" : "broadcast";
navigate({ to: `/messages/${typeParam}/${chatId}` });
}, [navigate]);
useEffect(() => { useEffect(() => {
if (!params.type && !params.chatId && filteredChannels.length > 0) { if (!type && !chatId && filteredChannels.length > 0) {
const defaultChannel = filteredChannels[0]; const defaultChannel = filteredChannels[0];
navigateToChat(MessageType.Broadcast, defaultChannel.index); navigateToChat(MessageType.Broadcast, defaultChannel.index.toString());
} }
}, [params.type, params.chatId, filteredChannels, navigateToChat]); }, [type, chatId, filteredChannels, navigateToChat]);
const currentChannel = channels.get(numericChatId);
const otherNode = getNode(numericChatId);
const isDirect = chatType === MessageType.Direct;
const isBroadcast = chatType === MessageType.Broadcast;
const filteredNodes = (): NodeInfoWithUnread[] => { const filteredNodes = (): NodeInfoWithUnread[] => {
const lowerCaseSearchTerm = deferredSearch.toLowerCase(); const lowerCaseSearchTerm = deferredSearch.toLowerCase();
@ -104,12 +115,8 @@ export const MessagesPage = () => {
}; };
const sendText = useCallback(async (message: string) => { const sendText = useCallback(async (message: string) => {
const isDirect = chatType === MessageType.Direct; const toValue = isDirect ? numericChatId : MessageType.Broadcast;
const toValue = isDirect ? activeChat : MessageType.Broadcast; const channelValue = isDirect ? Types.ChannelNumber.Primary : numericChatId;
const channelValue = isDirect
? Types.ChannelNumber.Primary
: activeChat ?? 0;
let messageId: number | undefined; let messageId: number | undefined;
@ -123,16 +130,16 @@ export const MessagesPage = () => {
if (messageId !== undefined) { if (messageId !== undefined) {
if (chatType === MessageType.Broadcast) { if (chatType === MessageType.Broadcast) {
setMessageState({ setMessageState({
type: chatType, type: MessageType.Broadcast,
channelId: channelValue, channelId: channelValue,
messageId, messageId,
newState: MessageState.Ack, newState: MessageState.Ack,
}); });
} else { } else {
setMessageState({ setMessageState({
type: chatType, type: MessageType.Direct,
nodeA: getMyNodeNum(), nodeA: getMyNodeNum(),
nodeB: activeChat, nodeB: numericChatId,
messageId, messageId,
newState: MessageState.Ack, newState: MessageState.Ack,
}); });
@ -145,23 +152,29 @@ export const MessagesPage = () => {
const failedId = messageId ?? randId(); const failedId = messageId ?? randId();
if (chatType === MessageType.Broadcast) { if (chatType === MessageType.Broadcast) {
setMessageState({ setMessageState({
type: chatType, type: MessageType.Broadcast,
channelId: channelValue, channelId: channelValue,
messageId: failedId, messageId: failedId,
newState: MessageState.Failed, newState: MessageState.Failed,
}); });
} else { // MessageType.Direct } else {
const failedId = messageId ?? randId();
setMessageState({ setMessageState({
type: chatType, type: MessageType.Direct,
nodeA: getMyNodeNum(), nodeA: getMyNodeNum(),
nodeB: activeChat, nodeB: numericChatId,
messageId: failedId, messageId: failedId,
newState: MessageState.Failed, newState: MessageState.Failed,
}); });
} }
} }
}, [activeChat, chatType, connection, getMyNodeNum, setMessageState]); }, [
numericChatId,
chatId,
chatType,
connection,
getMyNodeNum,
setMessageState,
]);
const renderChatContent = () => { const renderChatContent = () => {
switch (chatType) { switch (chatType) {
@ -170,7 +183,7 @@ export const MessagesPage = () => {
<ChannelChat <ChannelChat
messages={getMessages({ messages={getMessages({
type: MessageType.Broadcast, type: MessageType.Broadcast,
channelId: activeChat ?? 0, channelId: numericChatId,
}).reverse()} }).reverse()}
/> />
); );
@ -180,16 +193,12 @@ export const MessagesPage = () => {
messages={getMessages({ messages={getMessages({
type: MessageType.Direct, type: MessageType.Direct,
nodeA: getMyNodeNum(), nodeA: getMyNodeNum(),
nodeB: activeChat, nodeB: numericChatId,
}).reverse()} }).reverse()}
/> />
); );
default: default:
return ( return <SelectMessageChat />;
<div className="flex-1 flex items-center justify-center text-slate-500 p-4">
{t("selectChatPrompt.text", { ns: "messages" })}
</div>
);
} }
}; };
@ -210,10 +219,10 @@ export const MessagesPage = () => {
index: channel.index, index: channel.index,
ns: "channels", ns: "channels",
}))} }))}
active={activeChat === channel.index && active={numericChatId === channel.index &&
chatType === MessageType.Broadcast} chatType === MessageType.Broadcast}
onClick={() => { onClick={() => {
navigateToChat(MessageType.Broadcast, channel.index); navigateToChat(MessageType.Broadcast, channel.index.toString());
resetUnread(channel.index); resetUnread(channel.index);
}} }}
> >
@ -228,11 +237,12 @@ export const MessagesPage = () => {
), [ ), [
filteredChannels, filteredChannels,
unreadCounts, unreadCounts,
activeChat, numericChatId,
chatType, chatType,
isCollapsed, isCollapsed,
navigateToChat, navigateToChat,
resetUnread, resetUnread,
t,
]); ]);
const rightSidebar = useMemo( const rightSidebar = useMemo(
@ -262,10 +272,10 @@ export const MessagesPage = () => {
label={node.user?.longName ?? label={node.user?.longName ??
t("unknown.shortName")} t("unknown.shortName")}
count={node.unreadCount > 0 ? node.unreadCount : undefined} count={node.unreadCount > 0 ? node.unreadCount : undefined}
active={activeChat === node.num && active={numericChatId === node.num &&
chatType === MessageType.Direct} chatType === MessageType.Direct}
onClick={() => { onClick={() => {
navigateToChat(MessageType.Direct, node.num); navigateToChat(MessageType.Direct, node.num.toString());
resetUnread(node.num); resetUnread(node.num);
}} }}
> >
@ -285,11 +295,12 @@ export const MessagesPage = () => {
[ [
filteredNodes, filteredNodes,
searchTerm, searchTerm,
activeChat, numericChatId,
chatType, chatType,
navigateToChat, navigateToChat,
resetUnread, resetUnread,
hasNodeError, hasNodeError,
t,
], ],
); );
@ -330,7 +341,7 @@ export const MessagesPage = () => {
{(isBroadcast || isDirect) {(isBroadcast || isDirect)
? ( ? (
<MessageInput <MessageInput
to={isDirect ? activeChat : MessageType.Broadcast} to={isDirect ? numericChatId : MessageType.Broadcast}
onSend={sendText} onSend={sendText}
maxBytes={200} maxBytes={200}
/> />

227
src/pages/Nodes.tsx → src/pages/Nodes/index.tsx

@ -3,7 +3,11 @@ import { TracerouteResponseDialog } from "@app/components/Dialog/TracerouteRespo
import { Sidebar } from "@components/Sidebar.tsx"; import { Sidebar } from "@components/Sidebar.tsx";
import { Avatar } from "@components/UI/Avatar.tsx"; import { Avatar } from "@components/UI/Avatar.tsx";
import { Mono } from "@components/generic/Mono.tsx"; import { Mono } from "@components/generic/Mono.tsx";
import { Table } from "@components/generic/Table/index.tsx"; import {
type DataRow,
type Heading,
Table,
} from "@components/generic/Table/index.tsx";
import { TimeAgo } from "@components/generic/TimeAgo.tsx"; import { TimeAgo } from "@components/generic/TimeAgo.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { useAppStore } from "@core/stores/appStore.ts"; import { useAppStore } from "@core/stores/appStore.ts";
@ -93,6 +97,123 @@ const NodesPage = (): JSX.Element => {
setDialogOpen("nodeDetails", true); setDialogOpen("nodeDetails", true);
} }
const tableHeadings: Heading[] = [
{ title: "", sortable: false },
{ title: t("nodesTable.headings.longName"), sortable: true },
{ title: t("nodesTable.headings.connection"), sortable: true },
{ title: t("nodesTable.headings.lastHeard"), sortable: true },
{ title: t("nodesTable.headings.encryption"), sortable: false },
{ title: t("unit.snr"), sortable: true },
{ title: t("nodesTable.headings.model"), sortable: true },
{ title: t("nodesTable.headings.macAddress"), sortable: true },
];
const tableRows: DataRow[] = filteredNodes.map((node) => {
const macAddress = base16
.stringify(node.user?.macaddr ?? [])
.match(/.{1,2}/g)
?.join(":") ?? t("unknown.shortName");
return {
id: node.num,
isFavorite: node.isFavorite,
cells: [
{
content: (
<Avatar
text={node.user?.shortName ?? t("unknown.shortName")}
showFavorite={node.isFavorite}
showError={hasNodeError(node.num)}
/>
),
sortValue: node.user?.shortName ?? "", // Non-sortable column
},
{
content: (
<h1
onMouseDown={() => handleNodeInfoDialog(node.num)}
onKeyUp={(evt) => {
evt.key === "Enter" && handleNodeInfoDialog(node.num);
}}
className="cursor-pointer underline ml-2 whitespace-break-spaces"
tabIndex={0}
role="button"
>
{node.user?.longName ?? numberToHexUnpadded(node.num)}
</h1>
),
sortValue: node.user?.longName ?? numberToHexUnpadded(node.num),
},
{
content: (
<Mono className="w-16">
{node.hopsAway !== undefined
? node?.viaMqtt === false && node.hopsAway === 0
? t("nodesTable.connectionStatus.direct")
: `${node.hopsAway?.toString()} ${
node.hopsAway ?? 0 > 1
? t("unit.hop.plural")
: t("unit.hops_one")
} ${t("nodesTable.connectionStatus.away")}`
: t("nodesTable.connectionStatus.unknown")}
{node?.viaMqtt === true
? t("nodesTable.connectionStatus.viaMqtt")
: ""}
</Mono>
),
sortValue: node.hopsAway ?? Number.MAX_SAFE_INTEGER,
},
{
content: (
<Mono>
{node.lastHeard === 0
? <p>{t("nodesTable.lastHeardStatus.never")}</p>
: <TimeAgo timestamp={node.lastHeard * 1000} />}
</Mono>
),
sortValue: node.lastHeard,
},
{
content: (
<Mono>
{node.user?.publicKey && node.user?.publicKey.length > 0
? <LockIcon className="text-green-600 mx-auto" />
: <LockOpenIcon className="text-yellow-300 mx-auto" />}
</Mono>
),
sortValue: "", // Non-sortable column
},
{
content: (
<Mono>
{node.snr}
{t("unit.dbm")}/
{Math.min(
Math.max((node.snr + 10) * 5, 0),
100,
)}%/{/* Percentage */}
{(node.snr + 10) * 5}
{t("unit.raw")}
</Mono>
),
sortValue: node.snr,
},
{
content: (
<Mono>
{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}
</Mono>
),
sortValue: Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0],
},
{
content: <Mono>{macAddress}</Mono>,
sortValue: macAddress,
},
],
};
});
return ( return (
<> <>
<PageLayout <PageLayout
@ -133,108 +254,8 @@ const NodesPage = (): JSX.Element => {
</div> </div>
<div className="overflow-y-auto"> <div className="overflow-y-auto">
<Table <Table
headings={[ headings={tableHeadings}
{ title: "", type: "blank", sortable: false }, rows={tableRows}
{
title: t("nodesTable.headings.longName"),
type: "normal",
sortable: true,
},
{
title: t("nodesTable.headings.connection"),
type: "normal",
sortable: true,
},
{
title: t("nodesTable.headings.lastHeard"),
type: "normal",
sortable: true,
},
{
title: t("nodesTable.headings.encryption"),
type: "normal",
sortable: false,
},
{
title: t("unit.snr"),
type: "normal",
sortable: true,
},
{
title: t("nodesTable.headings.model"),
type: "normal",
sortable: true,
},
{
title: t("nodesTable.headings.macAddress"),
type: "normal",
sortable: true,
},
]}
rows={filteredNodes.map((node) => [
<div key={node.num}>
<Avatar
text={node.user?.shortName ?? t("unknown.shortName")}
showFavorite={node.isFavorite}
showError={hasNodeError(node.num)}
/>
</div>,
<h1
key="longName"
onMouseDown={() => handleNodeInfoDialog(node.num)}
onKeyUp={(evt) => {
evt.key === "Enter" && handleNodeInfoDialog(node.num);
}}
className="cursor-pointer underline ml-2 whitespace-break-spaces"
tabIndex={0}
role="button"
>
{node.user?.longName ?? numberToHexUnpadded(node.num)}
</h1>,
<Mono key="hops" className="w-16">
{node.hopsAway !== undefined
? node?.viaMqtt === false && node.hopsAway === 0
? t("nodesTable.connectionStatus.direct")
: `${node.hopsAway?.toString()} ${
node.hopsAway ?? 0 > 1
? t("unit.hop.plural")
: t("unit.hops_one")
} ${t("nodesTable.connectionStatus.away")}`
: t("nodesTable.connectionStatus.unknown")}
{node?.viaMqtt === true
? t("nodesTable.connectionStatus.viaMqtt")
: ""}
</Mono>,
<Mono key="lastHeard">
{node.lastHeard === 0
? <p>{t("nodesTable.lastHeardStatus.never")}</p>
: <TimeAgo timestamp={node.lastHeard * 1000} />}
</Mono>,
<Mono key="pki">
{node.user?.publicKey && node.user?.publicKey.length > 0
? <LockIcon className="text-green-600 mx-auto" />
: <LockOpenIcon className="text-yellow-300 mx-auto" />}
</Mono>,
<Mono key="snr">
{node.snr}
{t("unit.dbm")}/
{Math.min(
Math.max((node.snr + 10) * 5, 0),
100,
)}%/{/* Percentage */}
{(node.snr + 10) * 5}
{t("unit.raw")}
</Mono>,
<Mono key="model">
{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}
</Mono>,
<Mono key="addr">
{base16
.stringify(node.user?.macaddr ?? [])
.match(/.{1,2}/g)
?.join(":") ?? t("unknown.shortName")}
</Mono>,
])}
/> />
<TracerouteResponseDialog <TracerouteResponseDialog
traceroute={selectedTraceroute} traceroute={selectedTraceroute}

59
src/routeTree.gen.ts

@ -1,59 +0,0 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
// Import Routes
import { Route as rootRoute } from './routes/__root'
// Create/Update Routes
// Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' {
interface FileRoutesByPath {}
}
// Create and export the route tree
export interface FileRoutesByFullPath {}
export interface FileRoutesByTo {}
export interface FileRoutesById {
__root__: typeof rootRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: never
fileRoutesByTo: FileRoutesByTo
to: never
id: '__root__'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {}
const rootRouteChildren: RootRouteChildren = {}
export const routeTree = rootRoute
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
/* ROUTE_MANIFEST_START
{
"routes": {
"__root__": {
"filePath": "__root.tsx",
"children": []
}
}
}
ROUTE_MANIFEST_END */

35
src/routes.tsx

@ -4,10 +4,11 @@ import MessagesPage from "@pages/Messages.tsx";
import MapPage from "@pages/Map/index.tsx"; import MapPage from "@pages/Map/index.tsx";
import ConfigPage from "@pages/Config/index.tsx"; import ConfigPage from "@pages/Config/index.tsx";
import ChannelsPage from "@pages/Channels.tsx"; import ChannelsPage from "@pages/Channels.tsx";
import NodesPage from "@pages/Nodes.tsx"; import NodesPage from "@pages/Nodes/index.tsx";
import { createRootRoute } from "@tanstack/react-router"; import { createRootRoute } from "@tanstack/react-router";
import { App } from "./App.tsx"; import { App } from "./App.tsx";
import { DialogManager } from "@components/Dialog/DialogManager.tsx"; import { DialogManager } from "@components/Dialog/DialogManager.tsx";
import { z } from "zod";
const rootRoute = createRootRoute({ const rootRoute = createRootRoute({
component: App, component: App,
@ -27,12 +28,42 @@ const messagesRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: "/messages", path: "/messages",
component: MessagesPage, component: MessagesPage,
beforeLoad: ({ params }) => {
const DEFAULT_CHANNEL = 0;
if (Object.values(params).length === 0) {
throw redirect({
to: `/messages/broadcast/${DEFAULT_CHANNEL}`,
replace: true,
});
}
},
});
const chatIdSchema = z.string().refine((val) => {
const num = Number(val);
if (isNaN(num) || !Number.isInteger(num)) {
return false;
}
const isChannelId = num >= 0 && num <= 10;
const isNodeId = num >= 1000000000 && num <= 9999999999;
return isChannelId || isNodeId;
}, {
message: "Chat ID must be a channel (0-10) or a valid node ID.",
}); });
const messagesWithParamsRoute = createRoute({ export const messagesWithParamsRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: "/messages/$type/$chatId", path: "/messages/$type/$chatId",
component: MessagesPage, component: MessagesPage,
parseParams: (params) => ({
type: z.enum(["direct", "broadcast"], {
errorMap: () => ({ message: 'Type must be "direct" or "broadcast".' }),
}).parse(params.type),
chatId: chatIdSchema.parse(params.chatId),
}),
}); });
const mapRoute = createRoute({ const mapRoute = createRoute({

0
src/tests/setupTests.ts → src/tests/setup.ts

37
src/tests/test-utils.tsx

@ -0,0 +1,37 @@
import { ReactElement } from "react";
import { render, RenderOptions } from "@testing-library/react";
import {
createMemoryHistory,
createRouter,
RouterProvider,
} from "@tanstack/react-router";
import "../i18n/config.ts";
import { routeTree } from "../routeTree.gen.ts";
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
const Providers = () => {
const memoryHistory = createMemoryHistory({
initialEntries: ["/"],
});
const router = createRouter({
routeTree,
history: memoryHistory,
});
return (
<DeviceWrapper>
<RouterProvider router={router} />
</DeviceWrapper>
);
};
const renderWithProviders = (
ui: ReactElement,
options?: Omit<RenderOptions, "wrapper">,
) => render(ui, { wrapper: Providers, ...options });
export * from "@testing-library/react";
export { renderWithProviders as render };

11
vite-env.d.ts

@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly env: {
readonly VITE_COMMIT_HASH: string;
};
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

6
vite.config.ts

@ -32,9 +32,9 @@ export default defineConfig({
targets: [ targets: [
{ {
src: "src/i18n/locales/**/*", src: "src/i18n/locales/**/*",
dest: "src/i18n/locales" dest: "src/i18n/locales",
} },
] ],
}), }),
], ],
define: { define: {

4
vitest.config.ts

@ -3,6 +3,8 @@ import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config"; import { defineConfig } from "vitest/config";
import { enableMapSet } from "immer"; import { enableMapSet } from "immer";
import process from "node:process";
enableMapSet(); enableMapSet();
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
@ -25,6 +27,6 @@ export default defineConfig({
restoreMocks: true, restoreMocks: true,
root: path.resolve(process.cwd(), "./src"), root: path.resolve(process.cwd(), "./src"),
include: ["**/*.{test,spec}.{ts,tsx}"], include: ["**/*.{test,spec}.{ts,tsx}"],
setupFiles: ["./src/tests/setupTests.ts", "./src/core/utils/test.tsx"], setupFiles: ["./src/tests/setup.ts"],
}, },
}); });

Loading…
Cancel
Save