Merge branch 'master' into unread-counts
This commit is contained in:
@@ -4,3 +4,4 @@ stats.html
|
|||||||
.vercel
|
.vercel
|
||||||
.vite/deps
|
.vite/deps
|
||||||
dev-dist
|
dev-dist
|
||||||
|
__screenshots__*
|
||||||
+6
-4
@@ -14,7 +14,6 @@
|
|||||||
"dev:ui": "deno run -A npm:vite dev",
|
"dev:ui": "deno run -A npm:vite dev",
|
||||||
"dev:scan": "VITE_DEBUG_SCAN=true deno task dev:ui",
|
"dev:scan": "VITE_DEBUG_SCAN=true deno task dev:ui",
|
||||||
"test": "deno run -A npm:vitest",
|
"test": "deno run -A npm:vitest",
|
||||||
"test:ui": "deno task test --ui",
|
|
||||||
"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/ ."
|
||||||
},
|
},
|
||||||
@@ -76,12 +75,14 @@
|
|||||||
"react-scan": "^0.2.8",
|
"react-scan": "^0.2.8",
|
||||||
"rfc4648": "^1.5.4",
|
"rfc4648": "^1.5.4",
|
||||||
"vite-plugin-node-polyfills": "^0.23.0",
|
"vite-plugin-node-polyfills": "^0.23.0",
|
||||||
|
|
||||||
"zustand": "5.0.3"
|
"zustand": "5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.0.9",
|
"@tailwindcss/postcss": "^4.0.9",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.2.0",
|
"@testing-library/react": "^16.2.0",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/chrome": "^0.0.307",
|
"@types/chrome": "^0.0.307",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/node": "^22.13.7",
|
"@types/node": "^22.13.7",
|
||||||
@@ -93,16 +94,17 @@
|
|||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"gzipper": "^8.2.0",
|
"gzipper": "^8.2.0",
|
||||||
|
"happy-dom": "^17.2.2",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"jsdom": "^26.0.0",
|
|
||||||
"simple-git-hooks": "^2.11.1",
|
"simple-git-hooks": "^2.11.1",
|
||||||
"tailwind-merge": "^3.0.2",
|
"tailwind-merge": "^3.0.2",
|
||||||
"tailwindcss": "^4.0.9",
|
"tailwindcss": "^4.0.9",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tar": "^7.4.3",
|
"tar": "^7.4.3",
|
||||||
|
"testing-library": "^0.0.2",
|
||||||
"typescript": "^5.8.2",
|
"typescript": "^5.8.2",
|
||||||
"vite": "^6.2.0",
|
"vite": "^6.2.0",
|
||||||
"vite-plugin-pwa": "^0.21.1",
|
"vitest": "^3.0.7",
|
||||||
"vitest": "^3.0.7"
|
"vite-plugin-pwa": "^0.21.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Mocks Directory
|
||||||
|
|
||||||
|
This directory contains mock implementations used by Vitest for testing.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
The directory structure mirrors the actual project structure to make mocking
|
||||||
|
more intuitive:
|
||||||
|
|
||||||
|
```
|
||||||
|
__mocks__/
|
||||||
|
├── components/
|
||||||
|
│ └── UI/
|
||||||
|
│ ├── Dialog.tsx
|
||||||
|
│ ├── Button.tsx
|
||||||
|
│ ├── Checkbox.tsx
|
||||||
|
│ └── ...
|
||||||
|
├── core/
|
||||||
|
│ └── ...
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auto-mocking
|
||||||
|
|
||||||
|
Vitest will automatically use the mock files in this directory when the
|
||||||
|
corresponding module is imported in tests. For example, when a test imports
|
||||||
|
`@components/UI/Dialog.tsx`, Vitest will use
|
||||||
|
`__mocks__/components/UI/Dialog.tsx` instead.
|
||||||
|
|
||||||
|
## Creating New Mocks
|
||||||
|
|
||||||
|
To create a new mock:
|
||||||
|
|
||||||
|
1. Create a file in the same relative path as the original module
|
||||||
|
2. Export the mocked functionality with the same names as the original
|
||||||
|
3. Add a `vi.mock()` statement to `vitest.setup.ts` if needed
|
||||||
|
|
||||||
|
## Mock Guidelines
|
||||||
|
|
||||||
|
- Keep mocks as simple as possible
|
||||||
|
- Use `data-testid` attributes for easy querying in tests
|
||||||
|
- Implement just enough functionality to test the component
|
||||||
|
- Use TypeScript types to ensure compatibility with the original module
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('@components/UI/Button.tsx', () => ({
|
||||||
|
Button: ({ children, name, disabled, onClick }: {
|
||||||
|
children: React.ReactNode,
|
||||||
|
variant: string,
|
||||||
|
name: string,
|
||||||
|
disabled?: boolean,
|
||||||
|
onClick: () => void
|
||||||
|
}) =>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
name={name}
|
||||||
|
data-testid={`button-${name}`}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
}));
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('@components/UI/Checkbox.tsx', () => ({
|
||||||
|
Checkbox: ({ id, checked, onChange }: { id: string, checked: boolean, onChange: () => void }) =>
|
||||||
|
<input data-testid="checkbox" type="checkbox" id={id} checked={checked} onChange={onChange} />
|
||||||
|
}));
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export const Dialog = ({ children, open }: {
|
||||||
|
children: React.ReactNode,
|
||||||
|
open: boolean,
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
|
}) => open ? <div data-testid="dialog">{children}</div> : null;
|
||||||
|
|
||||||
|
export const DialogContent = ({
|
||||||
|
children,
|
||||||
|
className
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode,
|
||||||
|
className?: string
|
||||||
|
}) => <div data-testid="dialog-content" className={className}>{children}</div>;
|
||||||
|
|
||||||
|
export const DialogHeader = ({
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) => <div data-testid="dialog-header">{children}</div>;
|
||||||
|
|
||||||
|
export const DialogTitle = ({
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) => <div data-testid="dialog-title">{children}</div>;
|
||||||
|
|
||||||
|
export const DialogDescription = ({
|
||||||
|
children,
|
||||||
|
className
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode,
|
||||||
|
className?: string
|
||||||
|
}) => <div data-testid="dialog-description" className={className}>{children}</div>;
|
||||||
|
|
||||||
|
export const DialogFooter = ({
|
||||||
|
children,
|
||||||
|
className
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode,
|
||||||
|
className?: string
|
||||||
|
}) => <div data-testid="dialog-footer" className={className}>{children}</div>;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('@components/UI/Label.tsx', () => ({
|
||||||
|
Label: ({ children, htmlFor, className }: { children: React.ReactNode, htmlFor: string, className?: string }) =>
|
||||||
|
<label data-testid="label" htmlFor={htmlFor} className={className}>{children}</label>
|
||||||
|
}));
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock('@components/UI/Typography/Link.tsx', () => ({
|
||||||
|
Link: ({ children, href, className }: { children: React.ReactNode, href: string, className?: string }) =>
|
||||||
|
<a data-testid="link" href={href} className={className}>{children}</a>
|
||||||
|
}));
|
||||||
|
|
||||||
@@ -3,6 +3,7 @@ import { create } from "@bufbuild/protobuf";
|
|||||||
import { Button } from "@components/UI/Button.tsx";
|
import { Button } from "@components/UI/Button.tsx";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
@@ -52,6 +53,7 @@ export const DeviceNameDialog = ({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
<DialogClose />
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Change Device Name</DialogTitle>
|
<DialogTitle>Change Device Name</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { RemoveNodeDialog } from "@components/Dialog/RemoveNodeDialog.tsx";
|
import { RemoveNodeDialog } from "@components/Dialog/RemoveNodeDialog.tsx";
|
||||||
import { DeviceNameDialog } from "@components/Dialog/DeviceNameDialog.tsx";
|
import { DeviceNameDialog } from "@components/Dialog/DeviceNameDialog.tsx";
|
||||||
import { ImportDialog } from "@components/Dialog/ImportDialog.tsx";
|
import { ImportDialog } from "@components/Dialog/ImportDialog.tsx";
|
||||||
import { PkiBackupDialog } from "./PKIBackupDialog.tsx";
|
import { PkiBackupDialog } from "@components/Dialog/PKIBackupDialog.tsx";
|
||||||
import { QRDialog } from "@components/Dialog/QRDialog.tsx";
|
import { QRDialog } from "@components/Dialog/QRDialog.tsx";
|
||||||
import { RebootDialog } from "@components/Dialog/RebootDialog.tsx";
|
import { RebootDialog } from "@components/Dialog/RebootDialog.tsx";
|
||||||
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx";
|
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog.tsx";
|
||||||
|
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
|
||||||
import { NodeDetailsDialog } from "./NodeDetailsDialog.tsx";
|
|
||||||
|
|
||||||
export const DialogManager = () => {
|
export const DialogManager = () => {
|
||||||
const { channels, config, dialog, setDialogOpen } = useDevice();
|
const { channels, config, dialog, setDialogOpen } = useDevice();
|
||||||
@@ -64,6 +64,12 @@ export const DialogManager = () => {
|
|||||||
setDialogOpen("nodeDetails", open);
|
setDialogOpen("nodeDetails", open);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<UnsafeRolesDialog
|
||||||
|
open={dialog.unsafeRoles}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setDialogOpen("unsafeRoles", open);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { create, fromBinary } from "@bufbuild/protobuf";
|
import { create, fromBinary } from "@bufbuild/protobuf";
|
||||||
import { Button } from "@components/UI/Button.tsx";
|
import { Button } from "@components/UI/Button.tsx";
|
||||||
import { Checkbox } from "@components/UI/Checkbox.tsx";
|
import { Checkbox } from "../UI/Checkbox/index.tsx";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
@@ -50,7 +51,7 @@ export const ImportDialog = ({
|
|||||||
const paddedString = encodedChannelConfig
|
const paddedString = encodedChannelConfig
|
||||||
.padEnd(
|
.padEnd(
|
||||||
encodedChannelConfig.length +
|
encodedChannelConfig.length +
|
||||||
((4 - (encodedChannelConfig.length % 4)) % 4),
|
((4 - (encodedChannelConfig.length % 4)) % 4),
|
||||||
"=",
|
"=",
|
||||||
)
|
)
|
||||||
.replace(/-/g, "+")
|
.replace(/-/g, "+")
|
||||||
@@ -96,6 +97,7 @@ export const ImportDialog = ({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
<DialogClose />
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Import Channel Set</DialogTitle>
|
<DialogTitle>Import Channel Set</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useDevice } from "../../core/stores/deviceStore.ts";
|
import { useDevice } from "../../core/stores/deviceStore.ts";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
@@ -31,6 +32,7 @@ export const LocationResponseDialog = ({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
<DialogClose />
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{`Location: ${longName} (${shortName})`}</DialogTitle>
|
<DialogTitle>{`Location: ${longName} (${shortName})`}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -41,9 +43,8 @@ export const LocationResponseDialog = ({
|
|||||||
Coordinates:{" "}
|
Coordinates:{" "}
|
||||||
<a
|
<a
|
||||||
className="text-blue-500 dark:text-blue-400"
|
className="text-blue-500 dark:text-blue-400"
|
||||||
href={`https://www.openstreetmap.org/?mlat=${
|
href={`https://www.openstreetmap.org/?mlat=${location?.data.latitudeI / 1e7
|
||||||
location?.data.latitudeI / 1e7
|
}&mlon=${location?.data.longitudeI / 1e7}&layers=N`}
|
||||||
}&mlon=${location?.data.longitudeI / 1e7}&layers=N`}
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx";
|
|||||||
import { Serial } from "@components/PageComponents/Connect/Serial.tsx";
|
import { Serial } from "@components/PageComponents/Connect/Serial.tsx";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
@@ -135,6 +136,7 @@ export const NewDeviceDialog = ({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
<DialogClose />
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Connect New Device</DialogTitle>
|
<DialogTitle>Connect New Device</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from "../UI/Accordion.tsx";
|
} from "../UI/Accordion.tsx";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
@@ -36,6 +37,7 @@ export const NodeDetailsDialog = ({
|
|||||||
? (
|
? (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
<DialogClose />
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
Node Details for {device.user?.longName ?? "UNKNOWN"} (
|
Node Details for {device.user?.longName ?? "UNKNOWN"} (
|
||||||
@@ -85,11 +87,9 @@ export const NodeDetailsDialog = ({
|
|||||||
Coordinates:{" "}
|
Coordinates:{" "}
|
||||||
<a
|
<a
|
||||||
className="text-blue-500 dark:text-blue-400"
|
className="text-blue-500 dark:text-blue-400"
|
||||||
href={`https://www.openstreetmap.org/?mlat=${
|
href={`https://www.openstreetmap.org/?mlat=${device.position.latitudeI / 1e7
|
||||||
device.position.latitudeI / 1e7
|
}&mlon=${device.position.longitudeI / 1e7
|
||||||
}&mlon=${
|
}&layers=N`}
|
||||||
device.position.longitudeI / 1e7
|
|
||||||
}&layers=N`}
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
@@ -173,7 +173,7 @@ export const NodeDetailsDialog = ({
|
|||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="overflow-x-scroll">
|
<AccordionContent className="overflow-x-scroll">
|
||||||
<pre className="text-xs w-full">
|
<pre className="text-xs w-full">
|
||||||
{JSON.stringify(device, null, 2)}
|
{JSON.stringify(device, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useAppStore } from "../../core/stores/appStore.ts";
|
|||||||
import { useDevice } from "../../core/stores/deviceStore.ts";
|
import { useDevice } from "../../core/stores/deviceStore.ts";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
@@ -72,6 +73,7 @@ export const NodeOptionsDialog = ({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
<DialogClose />
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{`${longName} (${shortName})`}</DialogTitle>
|
<DialogTitle>{`${longName} (${shortName})`}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useDevice } from "../../core/stores/deviceStore.ts";
|
|||||||
import { Button } from "../UI/Button.tsx";
|
import { Button } from "../UI/Button.tsx";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
@@ -102,6 +103,7 @@ export const PkiBackupDialog = ({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
<DialogClose />
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Backup Keys</DialogTitle>
|
<DialogTitle>Backup Keys</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Button } from "@components/UI/Button.tsx";
|
import { Button } from "@components/UI/Button.tsx";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
@@ -22,6 +23,7 @@ export const PkiRegenerateDialog = ({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
<DialogClose />
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Regenerate Key pair?</DialogTitle>
|
<DialogTitle>Regenerate Key pair?</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { create, toBinary } from "@bufbuild/protobuf";
|
import { create, toBinary } from "@bufbuild/protobuf";
|
||||||
import { Checkbox } from "@components/UI/Checkbox.tsx";
|
import { Checkbox } from "../UI/Checkbox/index.tsx";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
@@ -62,6 +63,7 @@ export const QRDialog = ({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
<DialogClose />
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Generate QR Code</DialogTitle>
|
<DialogTitle>Generate QR Code</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
@@ -77,8 +79,8 @@ export const QRDialog = ({
|
|||||||
{channel.settings?.name.length
|
{channel.settings?.name.length
|
||||||
? channel.settings.name
|
? channel.settings.name
|
||||||
: channel.role === Protobuf.Channel.Channel_Role.PRIMARY
|
: channel.role === Protobuf.Channel.Channel_Role.PRIMARY
|
||||||
? "Primary"
|
? "Primary"
|
||||||
: `Channel: ${channel.index}`}
|
: `Channel: ${channel.index}`}
|
||||||
</Label>
|
</Label>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
key={channel.index}
|
key={channel.index}
|
||||||
@@ -106,22 +108,20 @@ export const QRDialog = ({
|
|||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`border-slate-900 border-t border-l border-b rounded-l h-10 px-7 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${
|
className={`border-slate-900 border-t border-l border-b rounded-l h-10 px-7 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${qrCodeAdd
|
||||||
qrCodeAdd
|
? "focus:ring-green-800 bg-green-800 text-white"
|
||||||
? "focus:ring-green-800 bg-green-800 text-white"
|
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
|
||||||
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
|
}`}
|
||||||
}`}
|
|
||||||
onClick={() => setQrCodeAdd(true)}
|
onClick={() => setQrCodeAdd(true)}
|
||||||
>
|
>
|
||||||
Add Channels
|
Add Channels
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`border-slate-900 border-t border-r border-b rounded-r h-10 px-4 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${
|
className={`border-slate-900 border-t border-r border-b rounded-r h-10 px-4 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${!qrCodeAdd
|
||||||
!qrCodeAdd
|
? "focus:ring-green-800 bg-green-800 text-white"
|
||||||
? "focus:ring-green-800 bg-green-800 text-white"
|
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
|
||||||
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
|
}`}
|
||||||
}`}
|
|
||||||
onClick={() => setQrCodeAdd(false)}
|
onClick={() => setQrCodeAdd(false)}
|
||||||
>
|
>
|
||||||
Replace Channels
|
Replace Channels
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Button } from "@components/UI/Button.tsx";
|
import { Button } from "@components/UI/Button.tsx";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
@@ -27,6 +28,7 @@ export const RebootDialog = ({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
<DialogClose />
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Schedule Reboot</DialogTitle>
|
<DialogTitle>Schedule Reboot</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useDevice } from "@core/stores/deviceStore.ts";
|
|||||||
import { Button } from "@components/UI/Button.tsx";
|
import { Button } from "@components/UI/Button.tsx";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
@@ -32,6 +33,7 @@ export const RemoveNodeDialog = ({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
<DialogClose />
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Remove Node?</DialogTitle>
|
<DialogTitle>Remove Node?</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Button } from "@components/UI/Button.tsx";
|
import { Button } from "@components/UI/Button.tsx";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
@@ -27,6 +28,7 @@ export const ShutdownDialog = ({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
<DialogClose />
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Schedule Shutdown</DialogTitle>
|
<DialogTitle>Schedule Shutdown</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useDevice } from "../../core/stores/deviceStore.ts";
|
import { useDevice } from "../../core/stores/deviceStore.ts";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
@@ -36,6 +37,7 @@ export const TracerouteResponseDialog = ({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
<DialogClose />
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{`Traceroute: ${longName} (${shortName})`}</DialogTitle>
|
<DialogTitle>{`Traceroute: ${longName} (${shortName})`}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// deno-lint-ignore-file
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
|
||||||
|
import { eventBus } from "@core/utils/eventBus.ts";
|
||||||
|
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
|
||||||
|
|
||||||
|
describe("UnsafeRolesDialog", () => {
|
||||||
|
const mockDevice = {
|
||||||
|
setDialogOpen: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderWithDeviceContext = (ui: any) => {
|
||||||
|
return render(
|
||||||
|
<DeviceWrapper device={mockDevice}>
|
||||||
|
{ui}
|
||||||
|
</DeviceWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("renders the dialog when open is true", () => {
|
||||||
|
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
|
||||||
|
|
||||||
|
const dialog = screen.getByRole('dialog');
|
||||||
|
expect(dialog).toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(screen.getByText(/I have read the/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/understand the implications/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
const links = screen.getAllByRole('link');
|
||||||
|
expect(links).toHaveLength(2);
|
||||||
|
expect(links[0]).toHaveTextContent('Device Role Documentation');
|
||||||
|
expect(links[1]).toHaveTextContent('Choosing The Right Device Role');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays the correct links", () => {
|
||||||
|
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
|
||||||
|
|
||||||
|
const docLink = screen.getByRole("link", { name: /Device Role Documentation/i });
|
||||||
|
const blogLink = screen.getByRole("link", { name: /Choosing The Right Device Role/i });
|
||||||
|
|
||||||
|
expect(docLink).toHaveAttribute("href", "https://meshtastic.org/docs/configuration/radio/device/");
|
||||||
|
expect(blogLink).toHaveAttribute("href", "https://meshtastic.org/blog/choosing-the-right-device-role/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not allow confirmation until checkbox is checked", () => {
|
||||||
|
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
|
||||||
|
|
||||||
|
const confirmButton = screen.getByRole("button", { name: /confirm/i });
|
||||||
|
|
||||||
|
expect(confirmButton).toBeDisabled();
|
||||||
|
|
||||||
|
const checkbox = screen.getByRole("checkbox");
|
||||||
|
fireEvent.click(checkbox);
|
||||||
|
|
||||||
|
expect(confirmButton).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits the correct event when closing via close button", () => {
|
||||||
|
const eventSpy = vi.spyOn(eventBus, "emit");
|
||||||
|
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
|
||||||
|
|
||||||
|
const dismissButton = screen.getByRole("button", { name: /close/i });
|
||||||
|
fireEvent.click(dismissButton);
|
||||||
|
|
||||||
|
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", { action: "dismiss" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits the correct event when dismissing", () => {
|
||||||
|
const eventSpy = vi.spyOn(eventBus, "emit");
|
||||||
|
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
|
||||||
|
|
||||||
|
const dismissButton = screen.getByRole("button", { name: /dismiss/i });
|
||||||
|
fireEvent.click(dismissButton);
|
||||||
|
|
||||||
|
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", { action: "dismiss" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits the correct event when confirming", () => {
|
||||||
|
const eventSpy = vi.spyOn(eventBus, "emit");
|
||||||
|
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
|
||||||
|
|
||||||
|
const checkbox = screen.getByRole("checkbox");
|
||||||
|
const confirmButton = screen.getByRole("button", { name: /confirm/i });
|
||||||
|
|
||||||
|
fireEvent.click(checkbox);
|
||||||
|
fireEvent.click(confirmButton);
|
||||||
|
|
||||||
|
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", { action: "confirm" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@components/UI/Dialog.tsx";
|
||||||
|
import { Link } from "@components/UI/Typography/Link.tsx";
|
||||||
|
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
|
||||||
|
import { Button } from "@components/UI/Button.tsx";
|
||||||
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { eventBus } from "@core/utils/eventBus.ts";
|
||||||
|
|
||||||
|
export interface RouterRoleDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UnsafeRolesDialog = ({ open, onOpenChange }: RouterRoleDialogProps) => {
|
||||||
|
const [confirmState, setConfirmState] = useState(false);
|
||||||
|
const { setDialogOpen } = useDevice();
|
||||||
|
|
||||||
|
const deviceRoleLink = "https://meshtastic.org/docs/configuration/radio/device/";
|
||||||
|
const choosingTheRightDeviceRoleLink = "https://meshtastic.org/blog/choosing-the-right-device-role/";
|
||||||
|
|
||||||
|
const handleCloseDialog = (action: 'confirm' | 'dismiss') => {
|
||||||
|
setDialogOpen('unsafeRoles', false);
|
||||||
|
setConfirmState(false);
|
||||||
|
eventBus.emit('dialog:unsafeRoles', { action });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-8 flex flex-col">
|
||||||
|
<DialogClose onClick={() => handleCloseDialog('dismiss')} />
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Are you sure?</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogDescription className="text-md">
|
||||||
|
I have read the <Link href={deviceRoleLink} className="">Device Role Documentation</Link>{" "}
|
||||||
|
and the blog post about <Link href={choosingTheRightDeviceRoleLink}>Choosing The Right Device Role</Link> and understand the implications of changing the role.
|
||||||
|
</DialogDescription>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="routerRole"
|
||||||
|
checked={confirmState}
|
||||||
|
onChange={() => setConfirmState(!confirmState)}
|
||||||
|
>
|
||||||
|
Yes, I know what I'm doing
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="mt-6">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
name="dismiss"
|
||||||
|
onClick={() => handleCloseDialog('dismiss')}> Dismiss
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
name="confirm"
|
||||||
|
disabled={!confirmState}
|
||||||
|
onClick={() => handleCloseDialog('confirm')}> Confirm
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog >
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
|
||||||
|
import { renderHook } from '@testing-library/react';
|
||||||
|
import { useUnsafeRolesDialog, UNSAFE_ROLES } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog";
|
||||||
|
import { eventBus } from "@core/utils/eventBus";
|
||||||
|
|
||||||
|
vi.mock('@core/utils/eventBus', () => ({
|
||||||
|
eventBus: {
|
||||||
|
on: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
emit: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockDevice = {
|
||||||
|
setDialogOpen: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('@core/stores/deviceStore', () => ({
|
||||||
|
useDevice: () => ({
|
||||||
|
setDialogOpen: mockDevice.setDialogOpen,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('useUnsafeRolesDialog', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderUnsafeRolesHook = () => {
|
||||||
|
return renderHook(() => useUnsafeRolesDialog());
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('handleCloseDialog', () => {
|
||||||
|
it('should call setDialogOpen with correct parameters when dialog is closed', () => {
|
||||||
|
const { result } = renderUnsafeRolesHook();
|
||||||
|
|
||||||
|
result.current.handleCloseDialog();
|
||||||
|
|
||||||
|
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith('unsafeRoles', false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateRoleSelection', () => {
|
||||||
|
it('should resolve with true for safe roles without opening dialog', async () => {
|
||||||
|
const { result } = renderUnsafeRolesHook();
|
||||||
|
const safeRole = 'SAFE_ROLE';
|
||||||
|
|
||||||
|
const validationResult = await result.current.validateRoleSelection(safeRole);
|
||||||
|
|
||||||
|
expect(validationResult).toBe(true);
|
||||||
|
expect(mockDevice.setDialogOpen).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should open dialog for unsafe roles and resolve with true when confirmed', async () => {
|
||||||
|
const { result } = renderUnsafeRolesHook();
|
||||||
|
|
||||||
|
const validationPromise = result.current.validateRoleSelection(UNSAFE_ROLES[0]);
|
||||||
|
|
||||||
|
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith('unsafeRoles', true);
|
||||||
|
expect(eventBus.on).toHaveBeenCalledWith('dialog:unsafeRoles', expect.any(Function));
|
||||||
|
|
||||||
|
const onHandler = (eventBus.on as Mock).mock.calls[0][1];
|
||||||
|
onHandler({ action: 'confirm' });
|
||||||
|
const validationResult = await validationPromise;
|
||||||
|
|
||||||
|
expect(validationResult).toBe(true);
|
||||||
|
expect(eventBus.off).toHaveBeenCalledWith('dialog:unsafeRoles', onHandler);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should resolve with false when user dismisses the dialog', async () => {
|
||||||
|
const { result } = renderUnsafeRolesHook();
|
||||||
|
const validationPromise = result.current.validateRoleSelection(UNSAFE_ROLES[0]);
|
||||||
|
const onHandler = (eventBus.on as Mock).mock.calls[0][1];
|
||||||
|
onHandler({ action: 'dismiss' });
|
||||||
|
|
||||||
|
const validationResult = await validationPromise;
|
||||||
|
expect(validationResult).toBe(false);
|
||||||
|
expect(eventBus.off).toHaveBeenCalledWith('dialog:unsafeRoles', onHandler);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clean up event listener after response', async () => {
|
||||||
|
const { result } = renderUnsafeRolesHook();
|
||||||
|
|
||||||
|
const validationPromise = result.current.validateRoleSelection(UNSAFE_ROLES[1]);
|
||||||
|
const onHandler = (eventBus.on as Mock).mock.calls[0][1];
|
||||||
|
|
||||||
|
onHandler({ action: 'confirm' });
|
||||||
|
await validationPromise;
|
||||||
|
|
||||||
|
expect(eventBus.off).toHaveBeenCalledWith('dialog:unsafeRoles', onHandler);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with all unsafe roles', async () => {
|
||||||
|
const { result } = renderUnsafeRolesHook();
|
||||||
|
|
||||||
|
for (const unsafeRole of UNSAFE_ROLES) {
|
||||||
|
mockDevice.setDialogOpen.mockClear();
|
||||||
|
(eventBus.on as Mock).mockClear();
|
||||||
|
|
||||||
|
const validationPromise = result.current.validateRoleSelection(unsafeRole);
|
||||||
|
|
||||||
|
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith('unsafeRoles', true);
|
||||||
|
|
||||||
|
const onHandler = (eventBus.on as Mock).mock.calls[0][1];
|
||||||
|
onHandler({ action: 'confirm' });
|
||||||
|
|
||||||
|
const validationResult = await validationPromise;
|
||||||
|
|
||||||
|
expect(validationResult).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { eventBus } from "@core/utils/eventBus.ts";
|
||||||
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
|
|
||||||
|
export const UNSAFE_ROLES = ["ROUTER", "REPEATER"];
|
||||||
|
export type UnsafeRole = typeof UNSAFE_ROLES[number];
|
||||||
|
|
||||||
|
export const useUnsafeRolesDialog = () => {
|
||||||
|
const { setDialogOpen } = useDevice();
|
||||||
|
|
||||||
|
const handleCloseDialog = useCallback(() => {
|
||||||
|
setDialogOpen("unsafeRoles", false);
|
||||||
|
}, [setDialogOpen]);
|
||||||
|
|
||||||
|
const validateRoleSelection = useCallback(
|
||||||
|
(newRoleKey: string): Promise<boolean> => {
|
||||||
|
if (!UNSAFE_ROLES.includes(newRoleKey as UnsafeRole)) {
|
||||||
|
return Promise.resolve(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDialogOpen("unsafeRoles", true);
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const handleResponse = ({ action }: { action: "confirm" | "dismiss" }) => {
|
||||||
|
eventBus.off("dialog:unsafeRoles", handleResponse);
|
||||||
|
resolve(action === "confirm");
|
||||||
|
};
|
||||||
|
|
||||||
|
eventBus.on("dialog:unsafeRoles", handleResponse);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setDialogOpen]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleCloseDialog,
|
||||||
|
validateRoleSelection,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -19,28 +19,32 @@ export interface MultiSelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatEnumDisplay = (name: string): string => {
|
||||||
|
return name
|
||||||
|
.replace(/_/g, " ")
|
||||||
|
.toLowerCase()
|
||||||
|
.split(" ")
|
||||||
|
.map((s) => s.charAt(0).toUpperCase() + s.substring(1))
|
||||||
|
.join(" ");
|
||||||
|
};
|
||||||
|
|
||||||
export function MultiSelectInput<T extends FieldValues>({
|
export function MultiSelectInput<T extends FieldValues>({
|
||||||
field,
|
field,
|
||||||
}: GenericFormElementProps<T, MultiSelectFieldProps<T>>) {
|
}: GenericFormElementProps<T, MultiSelectFieldProps<T>>) {
|
||||||
const { enumValue, formatEnumName, ...remainingProperties } =
|
const { enumValue, formatEnumName, ...remainingProperties } =
|
||||||
field.properties;
|
field.properties;
|
||||||
|
|
||||||
// Make sure to filter out the UNSET value, as it shouldn't be shown in the UI
|
const valueToKeyMap: Record<string, string> = {};
|
||||||
const optionsEnumValues = enumValue
|
const optionsEnumValues: [string, number][] = [];
|
||||||
? Object.entries(enumValue)
|
|
||||||
.filter((value) => typeof value[1] === "number")
|
|
||||||
.filter((value) => value[0] !== "UNSET")
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const formatName = (name: string) => {
|
if (enumValue) {
|
||||||
if (!formatEnumName) return name;
|
Object.entries(enumValue).forEach(([key, val]) => {
|
||||||
return name
|
if (typeof val === "number" && key !== "UNSET") {
|
||||||
.replace(/_/g, " ")
|
valueToKeyMap[val.toString()] = key;
|
||||||
.toLowerCase()
|
optionsEnumValues.push([key, val as number]);
|
||||||
.split(" ")
|
}
|
||||||
.map((s) => s.charAt(0).toUpperCase() + s.substring(1))
|
});
|
||||||
.join(" ");
|
}
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MultiSelect {...remainingProperties}>
|
<MultiSelect {...remainingProperties}>
|
||||||
@@ -52,7 +56,7 @@ export function MultiSelectInput<T extends FieldValues>({
|
|||||||
checked={field.isChecked(name)}
|
checked={field.isChecked(name)}
|
||||||
onCheckedChange={() => field.onValueChange(name)}
|
onCheckedChange={() => field.onValueChange(name)}
|
||||||
>
|
>
|
||||||
{formatEnumName ? formatName(name) : name}
|
{formatEnumName ? formatEnumDisplay(name) : name}
|
||||||
</MultiSelectItem>
|
</MultiSelectItem>
|
||||||
))}
|
))}
|
||||||
</MultiSelect>
|
</MultiSelect>
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@components/UI/Select.tsx";
|
} from "@components/UI/Select.tsx";
|
||||||
import { Controller, type FieldValues } from "react-hook-form";
|
import { useController, type FieldValues } from "react-hook-form";
|
||||||
|
import { computeHeadingLevel } from "@core/utils/test.tsx";
|
||||||
|
|
||||||
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||||
type: "select";
|
type: "select";
|
||||||
selectChange?: (e: string) => void;
|
selectChange?: (e: string, name: string) => void;
|
||||||
|
validate?: (newValue: string) => Promise<boolean>;
|
||||||
properties: BaseFormBuilderProps<T>["properties"] & {
|
properties: BaseFormBuilderProps<T>["properties"] & {
|
||||||
enumValue: {
|
enumValue: {
|
||||||
[s: string]: string | number;
|
[s: string]: string | number;
|
||||||
@@ -22,56 +24,71 @@ export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatEnumDisplay = (name: string): string => {
|
||||||
|
return name
|
||||||
|
.replace(/_/g, " ")
|
||||||
|
.toLowerCase()
|
||||||
|
.split(" ")
|
||||||
|
.map((s) => s.charAt(0).toUpperCase() + s.substring(1))
|
||||||
|
.join(" ");
|
||||||
|
};
|
||||||
|
|
||||||
export function SelectInput<T extends FieldValues>({
|
export function SelectInput<T extends FieldValues>({
|
||||||
control,
|
control,
|
||||||
disabled,
|
disabled,
|
||||||
field,
|
field,
|
||||||
}: GenericFormElementProps<T, SelectFieldProps<T>>) {
|
}: GenericFormElementProps<T, SelectFieldProps<T>>) {
|
||||||
|
const {
|
||||||
|
field: { value, onChange, ...rest },
|
||||||
|
} = useController({
|
||||||
|
name: field.name,
|
||||||
|
control,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { enumValue, formatEnumName, ...remainingProperties } = field.properties;
|
||||||
|
const valueToKeyMap: Record<string, string> = {};
|
||||||
|
const optionsEnumValues: [string, number][] = [];
|
||||||
|
|
||||||
|
if (enumValue) {
|
||||||
|
Object.entries(enumValue).forEach(([key, val]) => {
|
||||||
|
if (typeof val === "number") {
|
||||||
|
valueToKeyMap[val.toString()] = key;
|
||||||
|
optionsEnumValues.push([key, val]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleValueChange = async (newValue: string) => {
|
||||||
|
const selectedKey = valueToKeyMap[newValue];
|
||||||
|
|
||||||
|
if (field.validate) {
|
||||||
|
const isValid = await field.validate(selectedKey);
|
||||||
|
if (!isValid) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.selectChange) field.selectChange(newValue, selectedKey);
|
||||||
|
onChange(Number.parseInt(newValue));
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Controller
|
<Select
|
||||||
name={field.name}
|
onValueChange={handleValueChange}
|
||||||
control={control}
|
disabled={disabled}
|
||||||
render={({ field: { value, onChange, ...rest } }) => {
|
value={value?.toString()}
|
||||||
const { enumValue, formatEnumName, ...remainingProperties } =
|
{...remainingProperties}
|
||||||
field.properties;
|
{...rest}
|
||||||
const optionsEnumValues = enumValue
|
>
|
||||||
? Object.entries(enumValue).filter(
|
<SelectTrigger id={field.name}>
|
||||||
(value) => typeof value[1] === "number",
|
<SelectValue />
|
||||||
)
|
</SelectTrigger>
|
||||||
: [];
|
<SelectContent>
|
||||||
return (
|
{optionsEnumValues.map(([name, val]) => (
|
||||||
<Select
|
<SelectItem key={name} value={val.toString()}>
|
||||||
onValueChange={(e) => {
|
{formatEnumName ? formatEnumDisplay(name) : name}
|
||||||
if (field.selectChange) field.selectChange(e);
|
</SelectItem>
|
||||||
onChange(Number.parseInt(e));
|
))}
|
||||||
}}
|
</SelectContent>
|
||||||
disabled={disabled}
|
</Select>
|
||||||
value={value?.toString()}
|
|
||||||
{...remainingProperties}
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
<SelectTrigger id={field.name}>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{optionsEnumValues.map(([name, value]) => (
|
|
||||||
<SelectItem key={name} value={value.toString()}>
|
|
||||||
{formatEnumName
|
|
||||||
? name
|
|
||||||
.replace(/_/g, " ")
|
|
||||||
.toLowerCase()
|
|
||||||
.split(" ")
|
|
||||||
.map((s) =>
|
|
||||||
s.charAt(0).toUpperCase() + s.substring(1)
|
|
||||||
)
|
|
||||||
.join(" ")
|
|
||||||
: name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,13 +102,13 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
|
|||||||
psk: pass,
|
psk: pass,
|
||||||
positionEnabled:
|
positionEnabled:
|
||||||
channel?.settings?.moduleSettings?.positionPrecision !==
|
channel?.settings?.moduleSettings?.positionPrecision !==
|
||||||
undefined &&
|
undefined &&
|
||||||
channel?.settings?.moduleSettings?.positionPrecision > 0,
|
channel?.settings?.moduleSettings?.positionPrecision > 0,
|
||||||
preciseLocation:
|
preciseLocation:
|
||||||
channel?.settings?.moduleSettings?.positionPrecision === 32,
|
channel?.settings?.moduleSettings?.positionPrecision === 32,
|
||||||
positionPrecision:
|
positionPrecision:
|
||||||
channel?.settings?.moduleSettings?.positionPrecision ===
|
channel?.settings?.moduleSettings?.positionPrecision ===
|
||||||
undefined
|
undefined
|
||||||
? 10
|
? 10
|
||||||
: channel?.settings?.moduleSettings?.positionPrecision,
|
: channel?.settings?.moduleSettings?.positionPrecision,
|
||||||
},
|
},
|
||||||
@@ -135,6 +135,7 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
|
|||||||
{
|
{
|
||||||
type: "passwordGenerator",
|
type: "passwordGenerator",
|
||||||
name: "settings.psk",
|
name: "settings.psk",
|
||||||
|
id: 'channel-psk',
|
||||||
label: "Pre-Shared Key",
|
label: "Pre-Shared Key",
|
||||||
description:
|
description:
|
||||||
"Supported PSK lengths: 256-bit, 128-bit, 8-bit, Empty (0-bit)",
|
"Supported PSK lengths: 256-bit, 128-bit, 8-bit, Empty (0-bit)",
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, 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', () => ({
|
||||||
|
useDevice: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog', () => ({
|
||||||
|
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 as any).mockReturnValue({
|
||||||
|
config: {
|
||||||
|
device: mockDeviceConfig
|
||||||
|
},
|
||||||
|
setWorkingConfig: setWorkingConfigMock
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock the useUnsafeRolesDialog hook
|
||||||
|
validateRoleSelectionMock.mockResolvedValue(true);
|
||||||
|
(useUnsafeRolesDialog as any).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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+6
-19
@@ -1,11 +1,13 @@
|
|||||||
import type { DeviceValidation } from "@app/validation/config/device.tsx";
|
import type { DeviceValidation } from "@app/validation/config/device.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
import { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
|
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
|
||||||
|
|
||||||
export const Device = () => {
|
export const Device = () => {
|
||||||
const { config, setWorkingConfig } = useDevice();
|
const { config, setWorkingConfig } = useDevice();
|
||||||
|
const { validateRoleSelection } = useUnsafeRolesDialog();
|
||||||
|
|
||||||
const onSubmit = (data: DeviceValidation) => {
|
const onSubmit = (data: DeviceValidation) => {
|
||||||
setWorkingConfig(
|
setWorkingConfig(
|
||||||
@@ -14,10 +16,9 @@ export const Device = () => {
|
|||||||
case: "device",
|
case: "device",
|
||||||
value: data,
|
value: data,
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DynamicForm<DeviceValidation>
|
<DynamicForm<DeviceValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
@@ -32,23 +33,9 @@ export const Device = () => {
|
|||||||
name: "role",
|
name: "role",
|
||||||
label: "Role",
|
label: "Role",
|
||||||
description: "What role the device performs on the mesh",
|
description: "What role the device performs on the mesh",
|
||||||
|
validate: validateRoleSelection,
|
||||||
properties: {
|
properties: {
|
||||||
enumValue: {
|
enumValue: Protobuf.Config.Config_DeviceConfig_Role,
|
||||||
Client: Protobuf.Config.Config_DeviceConfig_Role.CLIENT,
|
|
||||||
"Client Mute":
|
|
||||||
Protobuf.Config.Config_DeviceConfig_Role.CLIENT_MUTE,
|
|
||||||
Router: Protobuf.Config.Config_DeviceConfig_Role.ROUTER,
|
|
||||||
Repeater: Protobuf.Config.Config_DeviceConfig_Role.REPEATER,
|
|
||||||
Tracker: Protobuf.Config.Config_DeviceConfig_Role.TRACKER,
|
|
||||||
Sensor: Protobuf.Config.Config_DeviceConfig_Role.SENSOR,
|
|
||||||
TAK: Protobuf.Config.Config_DeviceConfig_Role.TAK,
|
|
||||||
"Client Hidden":
|
|
||||||
Protobuf.Config.Config_DeviceConfig_Role.CLIENT_HIDDEN,
|
|
||||||
"Lost and Found":
|
|
||||||
Protobuf.Config.Config_DeviceConfig_Role.LOST_AND_FOUND,
|
|
||||||
"TAK Tracker":
|
|
||||||
Protobuf.Config.Config_DeviceConfig_Role.TAK_TRACKER,
|
|
||||||
},
|
|
||||||
formatEnumName: true,
|
formatEnumName: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -12,7 +12,7 @@ import { useCallback } from "react";
|
|||||||
export const Position = () => {
|
export const Position = () => {
|
||||||
const { config, setWorkingConfig } = useDevice();
|
const { config, setWorkingConfig } = useDevice();
|
||||||
const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags(
|
const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags(
|
||||||
config.position.positionFlags ?? 0,
|
config?.position.positionFlags ?? 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
const onSubmit = (data: PositionValidation) => {
|
const onSubmit = (data: PositionValidation) => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useAppStore } from "@core/stores/appStore.ts";
|
|||||||
import { useDeviceStore } from "@core/stores/deviceStore.ts";
|
import { useDeviceStore } from "@core/stores/deviceStore.ts";
|
||||||
import { subscribeAll } from "@core/subscriptions.ts";
|
import { subscribeAll } from "@core/subscriptions.ts";
|
||||||
import { randId } from "@core/utils/randId.ts";
|
import { randId } from "@core/utils/randId.ts";
|
||||||
import { BleConnection, Constants } from "@meshtastic/js";
|
import { BleConnection, ServiceUuid } from "@meshtastic/js";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
export const BLE = ({ closeDialog }: TabElementProps) => {
|
export const BLE = ({ closeDialog }: TabElementProps) => {
|
||||||
@@ -58,7 +58,7 @@ export const BLE = ({ closeDialog }: TabElementProps) => {
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await navigator.bluetooth
|
await navigator.bluetooth
|
||||||
.requestDevice({
|
.requestDevice({
|
||||||
filters: [{ services: [Constants.ServiceUuid] }],
|
filters: [{ services: [ServiceUuid] }],
|
||||||
})
|
})
|
||||||
.then((device) => {
|
.then((device) => {
|
||||||
const exists = bleDevices.findIndex((d) => d.id === device.id);
|
const exists = bleDevices.findIndex((d) => d.id === device.id);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { describe, it, vi, expect } from "vitest";
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|
||||||
import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx";
|
import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx";
|
||||||
import { TransportHTTP } from "@meshtastic/transport-http";
|
|
||||||
import { MeshDevice } from "@meshtastic/core";
|
import { MeshDevice } from "@meshtastic/core";
|
||||||
|
import { TransportHTTP } from "@meshtastic/transport-http";
|
||||||
|
import { vi, describe, it, expect } from "vitest";
|
||||||
|
|
||||||
vi.mock("@core/stores/appStore.ts", () => ({
|
vi.mock("@core/stores/appStore.ts", () => ({
|
||||||
useAppStore: vi.fn(() => ({ setSelectedDevice: vi.fn() })),
|
useAppStore: vi.fn(() => ({ setSelectedDevice: vi.fn() })),
|
||||||
@@ -41,25 +41,27 @@ describe("HTTP Component", () => {
|
|||||||
|
|
||||||
it("allows input field to be updated", () => {
|
it("allows input field to be updated", () => {
|
||||||
render(<HTTP closeDialog={vi.fn()} />);
|
render(<HTTP closeDialog={vi.fn()} />);
|
||||||
const input = screen.getByRole("textbox");
|
const inputField = screen.getByRole("textbox");
|
||||||
fireEvent.change(input, { target: { value: "meshtastic.local" } });
|
fireEvent.change(inputField, { target: { value: 'meshtastic.local' } })
|
||||||
expect(input).toHaveValue("meshtastic.local");
|
expect(screen.getByPlaceholderText("000.000.000.000 / meshtastic.local")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("toggles HTTPS switch and updates prefix", () => {
|
it("toggles HTTPS switch and updates prefix", () => {
|
||||||
render(<HTTP closeDialog={vi.fn()} />);
|
render(<HTTP closeDialog={vi.fn()} />);
|
||||||
|
|
||||||
const switchInput = screen.getByRole("switch");
|
const switchInput = screen.getByRole("switch");
|
||||||
expect(screen.getByText("http://")).toBeInTheDocument();
|
expect(screen.getByText("http://")).toBeInTheDocument();
|
||||||
fireEvent.click(switchInput);
|
|
||||||
|
fireEvent.click(switchInput)
|
||||||
expect(screen.getByText("https://")).toBeInTheDocument();
|
expect(screen.getByText("https://")).toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.click(switchInput);
|
fireEvent.click(switchInput)
|
||||||
expect(switchInput).not.toBeChecked();
|
expect(switchInput).not.toBeChecked();
|
||||||
expect(screen.getByText("http://")).toBeInTheDocument();
|
expect(screen.getByText("http://")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("enables HTTPS toggle when location protocol is https", () => {
|
it("enables HTTPS toggle when location protocol is https", () => {
|
||||||
Object.defineProperty(globalThis, "location", {
|
Object.defineProperty(window, "location", {
|
||||||
value: { protocol: "https:" },
|
value: { protocol: "https:" },
|
||||||
writable: true,
|
writable: true,
|
||||||
});
|
});
|
||||||
@@ -68,24 +70,27 @@ describe("HTTP Component", () => {
|
|||||||
|
|
||||||
const switchInput = screen.getByRole("switch");
|
const switchInput = screen.getByRole("switch");
|
||||||
expect(switchInput).toBeChecked();
|
expect(switchInput).toBeChecked();
|
||||||
|
|
||||||
|
expect(screen.getByText("https://")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.skip("submits form and triggers connection process", () => {
|
it.skip("submits form and triggers connection process", async () => {
|
||||||
// This will need further work to test, as it involves a lot of other plumbing mocking
|
|
||||||
const closeDialog = vi.fn();
|
const closeDialog = vi.fn();
|
||||||
render(<HTTP closeDialog={closeDialog} />);
|
render(<HTTP closeDialog={closeDialog} />);
|
||||||
|
|
||||||
const button = screen.getByRole("button", { name: "Connect" });
|
const button = screen.getByRole("button", { name: "Connect" });
|
||||||
expect(button).not.toBeDisabled();
|
expect(button).not.toBeDisabled();
|
||||||
|
|
||||||
fireEvent.click(button);
|
try {
|
||||||
|
fireEvent.click(button);
|
||||||
waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(button).toBeDisabled();
|
expect(button).toBeDisabled();
|
||||||
expect(closeDialog).toHaveBeenCalled();
|
expect(closeDialog).toBeCalled();
|
||||||
expect(TransportHTTP.create).toHaveBeenCalled();
|
expect(TransportHTTP.create).toBeCalled();
|
||||||
expect(MeshDevice).toHaveBeenCalled();
|
expect(MeshDevice).toBeCalled();
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
import { type MessageWithState, useDevice } from "@core/stores/deviceStore.ts";
|
import { type MessageWithState, useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Message } from "@components/PageComponents/Messages/Message.tsx";
|
import { Message } from "@components/PageComponents/Messages/Message.tsx";
|
||||||
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
|
|
||||||
import type { Types } from "@meshtastic/core";
|
|
||||||
import { InboxIcon } from "lucide-react";
|
import { InboxIcon } from "lucide-react";
|
||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
|
||||||
export interface ChannelChatProps {
|
export interface ChannelChatProps {
|
||||||
messages?: MessageWithState[];
|
messages?: MessageWithState[];
|
||||||
channel: Types.ChannelNumber;
|
|
||||||
to: Types.Destination;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const EmptyState = () => (
|
const EmptyState = () => (
|
||||||
@@ -20,8 +16,6 @@ const EmptyState = () => (
|
|||||||
|
|
||||||
export const ChannelChat = ({
|
export const ChannelChat = ({
|
||||||
messages,
|
messages,
|
||||||
channel,
|
|
||||||
to,
|
|
||||||
}: ChannelChatProps) => {
|
}: ChannelChatProps) => {
|
||||||
const { nodes } = useDevice();
|
const { nodes } = useDevice();
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -30,10 +24,12 @@ export const ChannelChat = ({
|
|||||||
const scrollToBottom = useCallback(() => {
|
const scrollToBottom = useCallback(() => {
|
||||||
const scrollContainer = scrollContainerRef.current;
|
const scrollContainer = scrollContainerRef.current;
|
||||||
if (scrollContainer) {
|
if (scrollContainer) {
|
||||||
const isNearBottom = scrollContainer.scrollHeight -
|
const isNearBottom =
|
||||||
scrollContainer.scrollTop -
|
scrollContainer.scrollHeight -
|
||||||
scrollContainer.clientHeight <
|
scrollContainer.scrollTop -
|
||||||
|
scrollContainer.clientHeight <
|
||||||
100;
|
100;
|
||||||
|
|
||||||
if (isNearBottom) {
|
if (isNearBottom) {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
}
|
}
|
||||||
@@ -42,7 +38,7 @@ export const ChannelChat = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}, [scrollToBottom]);
|
}, [scrollToBottom, messages]);
|
||||||
|
|
||||||
if (!messages?.length) {
|
if (!messages?.length) {
|
||||||
return (
|
return (
|
||||||
@@ -50,34 +46,31 @@ export const ChannelChat = ({
|
|||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<EmptyState />
|
<EmptyState />
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 p-4 w-full dark:bg-slate-900">
|
|
||||||
<MessageInput to={to} channel={channel} maxBytes={200} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full container mx-auto">
|
<div className="flex flex-col h-full container mx-auto">
|
||||||
<div className="flex-1 overflow-y-auto" ref={scrollContainerRef}>
|
<div
|
||||||
<div className="w-full h-full flex flex-col justify-end pl-4 pr-44">
|
ref={scrollContainerRef}
|
||||||
{messages.map((message, index) => {
|
className="flex-1 overflow-y-auto pl-4 pr-4 md:pr-44"
|
||||||
return (
|
>
|
||||||
<Message
|
<div className="flex flex-col justify-end min-h-full">
|
||||||
key={message.id}
|
{messages.map((message, index) => (
|
||||||
message={message}
|
<Message
|
||||||
sender={nodes.get(message.from)}
|
key={message.id}
|
||||||
lastMsgSameUser={index > 0 &&
|
message={message}
|
||||||
messages[index - 1].from === message.from}
|
sender={nodes.get(message.from)}
|
||||||
/>
|
lastMsgSameUser={
|
||||||
);
|
index > 0 && messages[index - 1].from === message.from
|
||||||
})}
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
<div ref={messagesEndRef} className="w-full" />
|
<div ref={messagesEndRef} className="w-full" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 mt-2 p-4 w-full dark:bg-slate-900">
|
|
||||||
<MessageInput to={to} channel={channel} maxBytes={200} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { MessageInput } from '@components/PageComponents/Messages/MessageInput.tsx';
|
||||||
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
|
import { vi, describe, it, expect, beforeEach, Mock } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
vi.mock("@core/stores/deviceStore.ts", () => ({
|
||||||
|
useDevice: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@core/utils/debounce.ts", () => ({
|
||||||
|
debounce: (fn: () => void) => fn,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@components/UI/Button.tsx", () => ({
|
||||||
|
Button: ({ children, ...props }: { children: React.ReactNode }) => <button {...props}>{children}</button>
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@components/UI/Input.tsx", () => ({
|
||||||
|
Input: (props: any) => <input {...props} />
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("lucide-react", () => ({
|
||||||
|
SendIcon: () => <div data-testid="send-icon">Send</div>
|
||||||
|
}));
|
||||||
|
|
||||||
|
// TODO: getting an error with this test
|
||||||
|
describe('MessageInput Component', () => {
|
||||||
|
const mockProps = {
|
||||||
|
to: "broadcast" as const,
|
||||||
|
channel: 0 as const,
|
||||||
|
maxBytes: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSetMessageDraft = vi.fn();
|
||||||
|
const mockSetMessageState = vi.fn();
|
||||||
|
const mockSendText = vi.fn().mockResolvedValue(123);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
(useDevice as Mock).mockReturnValue({
|
||||||
|
connection: {
|
||||||
|
sendText: mockSendText,
|
||||||
|
},
|
||||||
|
setMessageState: mockSetMessageState,
|
||||||
|
messageDraft: "",
|
||||||
|
setMessageDraft: mockSetMessageDraft,
|
||||||
|
hardware: {
|
||||||
|
myNodeNum: 1234567890,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders correctly with initial state', () => {
|
||||||
|
render(<MessageInput {...mockProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByPlaceholderText('Enter Message')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('send-icon')).toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(screen.getByText('0/100')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates local draft and byte count when typing', () => {
|
||||||
|
render(<MessageInput {...mockProps} />);
|
||||||
|
|
||||||
|
const inputField = screen.getByPlaceholderText('Enter Message');
|
||||||
|
fireEvent.change(inputField, { target: { value: 'Hello' } })
|
||||||
|
|
||||||
|
expect(screen.getByText('5/100')).toBeInTheDocument();
|
||||||
|
expect(inputField).toHaveValue('Hello');
|
||||||
|
expect(mockSetMessageDraft).toHaveBeenCalledWith('Hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skip('does not allow input exceeding max bytes', () => {
|
||||||
|
render(<MessageInput {...mockProps} maxBytes={5} />);
|
||||||
|
|
||||||
|
const inputField = screen.getByPlaceholderText('Enter Message');
|
||||||
|
|
||||||
|
expect(screen.getByText('0/100')).toBeInTheDocument();
|
||||||
|
|
||||||
|
userEvent.type(inputField, 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis p')
|
||||||
|
|
||||||
|
expect(screen.getByText('100/100')).toBeInTheDocument();
|
||||||
|
expect(inputField).toHaveValue('Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean m');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skip('sends message and resets form when submitting', async () => {
|
||||||
|
try {
|
||||||
|
render(<MessageInput {...mockProps} />);
|
||||||
|
|
||||||
|
const inputField = screen.getByPlaceholderText('Enter Message');
|
||||||
|
const submitButton = screen.getByText('Send');
|
||||||
|
|
||||||
|
fireEvent.change(inputField, { target: { value: 'Test Message' } });
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
|
||||||
|
const form = screen.getByRole('form');
|
||||||
|
fireEvent.submit(form);
|
||||||
|
|
||||||
|
expect(mockSendText).toHaveBeenCalledWith('Test message', 'broadcast', true, 0);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSetMessageState).toHaveBeenCalledWith(
|
||||||
|
'broadcast',
|
||||||
|
0,
|
||||||
|
'broadcast',
|
||||||
|
1234567890,
|
||||||
|
123,
|
||||||
|
'ack'
|
||||||
|
);
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(inputField).toHaveValue('');
|
||||||
|
expect(screen.getByText('0/100')).toBeInTheDocument();
|
||||||
|
expect(mockSetMessageDraft).toHaveBeenCalledWith('');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it('prevents sending empty messages', () => {
|
||||||
|
render(<MessageInput {...mockProps} />);
|
||||||
|
|
||||||
|
const form = screen.getByPlaceholderText('Enter Message')
|
||||||
|
fireEvent.submit(form);
|
||||||
|
|
||||||
|
expect(mockSendText).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initializes with existing message draft', () => {
|
||||||
|
(useDevice as Mock).mockReturnValue({
|
||||||
|
connection: {
|
||||||
|
sendText: mockSendText,
|
||||||
|
},
|
||||||
|
setMessageState: mockSetMessageState,
|
||||||
|
messageDraft: "Existing draft",
|
||||||
|
setMessageDraft: mockSetMessageDraft,
|
||||||
|
isQueueingMessages: false,
|
||||||
|
queueStatus: { free: 10 },
|
||||||
|
hardware: {
|
||||||
|
myNodeNum: 1234567890,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<MessageInput {...mockProps} />);
|
||||||
|
|
||||||
|
const inputField = screen.getByRole('textbox');
|
||||||
|
|
||||||
|
expect(inputField).toHaveValue('Existing draft');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { debounce } from "../../../core/utils/debounce.ts";
|
import { debounce } from "@core/utils/debounce.ts";
|
||||||
import { Button } from "@components/UI/Button.tsx";
|
import { Button } from "@components/UI/Button.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";
|
||||||
@@ -22,6 +22,8 @@ export const MessageInput = ({
|
|||||||
setMessageState,
|
setMessageState,
|
||||||
messageDraft,
|
messageDraft,
|
||||||
setMessageDraft,
|
setMessageDraft,
|
||||||
|
isQueueingMessages,
|
||||||
|
queueStatus,
|
||||||
hardware,
|
hardware,
|
||||||
} = useDevice();
|
} = useDevice();
|
||||||
const myNodeNum = hardware.myNodeNum;
|
const myNodeNum = hardware.myNodeNum;
|
||||||
@@ -33,8 +35,10 @@ export const MessageInput = ({
|
|||||||
[setMessageDraft],
|
[setMessageDraft],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// sends the message to the selected destination
|
||||||
const sendText = useCallback(
|
const sendText = useCallback(
|
||||||
async (message: string) => {
|
async (message: string) => {
|
||||||
|
|
||||||
await connection
|
await connection
|
||||||
?.sendText(message, to, true, channel)
|
?.sendText(message, to, true, channel)
|
||||||
.then((id: number) =>
|
.then((id: number) =>
|
||||||
@@ -58,7 +62,7 @@ export const MessageInput = ({
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[channel, connection, myNodeNum, setMessageState, to],
|
[channel, connection, myNodeNum, setMessageState, to, queueStatus],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -81,15 +85,18 @@ export const MessageInput = ({
|
|||||||
if (localDraft === "") return;
|
if (localDraft === "") return;
|
||||||
const message = formData.get("messageInput") as string;
|
const message = formData.get("messageInput") as string;
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
sendText(message);
|
if (!isQueueingMessages) {
|
||||||
setLocalDraft("");
|
sendText(message);
|
||||||
setMessageDraft("");
|
setLocalDraft("");
|
||||||
setMessageBytes(0);
|
setMessageDraft("");
|
||||||
|
setMessageBytes(0);
|
||||||
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex grow gap-2">
|
<div className="flex grow gap-2 ">
|
||||||
<span className="w-full">
|
<label className="w-full">
|
||||||
<Input
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
minLength={1}
|
minLength={1}
|
||||||
@@ -98,12 +105,12 @@ export const MessageInput = ({
|
|||||||
value={localDraft}
|
value={localDraft}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
/>
|
/>
|
||||||
</span>
|
</label>
|
||||||
<div className="flex items-center w-24 p-2 place-content-end">
|
<label data-testid="byte-counter" className="flex items-center w-24 p-2 place-content-end">
|
||||||
{messageBytes}/{maxBytes}
|
{messageBytes}/{maxBytes}
|
||||||
</div>
|
</label>
|
||||||
|
|
||||||
<Button type="submit">
|
<Button type="submit" className="dark:bg-white dark:text-slate-900 dark:hover:bg-slate-400 dark:hover:text-white">
|
||||||
<SendIcon size={16} />
|
<SendIcon size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface PageLayoutProps {
|
|||||||
label: string;
|
label: string;
|
||||||
noPadding?: boolean;
|
noPadding?: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
actions?: {
|
actions?: {
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
iconClasses?: string;
|
iconClasses?: string;
|
||||||
@@ -23,6 +24,7 @@ export const PageLayout = ({
|
|||||||
label,
|
label,
|
||||||
noPadding,
|
noPadding,
|
||||||
actions,
|
actions,
|
||||||
|
className,
|
||||||
children,
|
children,
|
||||||
}: PageLayoutProps) => {
|
}: PageLayoutProps) => {
|
||||||
return (
|
return (
|
||||||
@@ -63,6 +65,7 @@ export const PageLayout = ({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full w-full flex-col overflow-y-auto",
|
"flex h-full w-full flex-col overflow-y-auto",
|
||||||
!noPadding && "pl-3 pr-3 ",
|
!noPadding && "pl-3 pr-3 ",
|
||||||
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export const Sidebar = ({ children }: SidebarProps) => {
|
|||||||
|
|
||||||
return showSidebar
|
return showSidebar
|
||||||
? (
|
? (
|
||||||
<div className="min-w-[280px] max-w-min flex-col overflow-y-auto border-r-[0.5px] bg-background-primary border-slate-300 dark:border-slate-700">
|
<div className="min-w-[280px] max-w-min flex-col overflow-y-auto border-r-[0.5px] bg-background-primary border-slate-300 dark:border-slate-400">
|
||||||
<div className="flex justify-between px-8 pt-6">
|
<div className="flex justify-between px-8 pt-6">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-lg font-medium">
|
<span className="text-lg font-medium">
|
||||||
|
|||||||
@@ -40,16 +40,20 @@ export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
|
|||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
extends
|
extends
|
||||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
VariantProps<typeof buttonVariants> {}
|
VariantProps<typeof buttonVariants> { }
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
({ className, variant, size, ...props }, ref) => {
|
({ className, variant, size, disabled, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(
|
||||||
|
buttonVariants({ variant, size, className }),
|
||||||
|
{ "cursor-not-allowed": disabled }
|
||||||
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
disabled={disabled}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
|
||||||
import { Check } from "lucide-react";
|
|
||||||
import * as React from "react";
|
|
||||||
|
|
||||||
import { cn } from "@core/utils/cn.ts";
|
|
||||||
|
|
||||||
const Checkbox = React.forwardRef<
|
|
||||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<CheckboxPrimitive.Root
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"peer h-4 w-4 shrink-0 rounded-xs border border-slate-300 focus:outline-hidden focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:text-slate-50 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<CheckboxPrimitive.Indicator
|
|
||||||
className={cn("flex items-center justify-center")}
|
|
||||||
>
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
</CheckboxPrimitive.Indicator>
|
|
||||||
</CheckboxPrimitive.Root>
|
|
||||||
));
|
|
||||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
|
||||||
|
|
||||||
export { Checkbox };
|
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||||
|
import { Checkbox } from '@components/UI/Checkbox/index.tsx';
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
vi.mock('@components/UI/Label.tsx', () => ({
|
||||||
|
Label: ({ children, className, htmlFor, id }: { children: React.ReactNode; className: string; htmlFor: string; id: string }) => (
|
||||||
|
<label data-testid="label-component" className={className} htmlFor={htmlFor} id={id}>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@core/utils/cn.ts', () => ({
|
||||||
|
cn: (...args: any) => args.filter(Boolean).join(' '),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('react', async () => {
|
||||||
|
const actual = await vi.importActual('react');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useId: () => 'test-id',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Checkbox', () => {
|
||||||
|
beforeEach(cleanup);
|
||||||
|
|
||||||
|
it('renders unchecked by default', () => {
|
||||||
|
render(<Checkbox />);
|
||||||
|
const checkbox = screen.getByRole('checkbox');
|
||||||
|
expect(checkbox).not.toBeChecked();
|
||||||
|
expect(screen.queryByText('Check')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders checked when checked prop is true', () => {
|
||||||
|
render(<Checkbox checked={true} />);
|
||||||
|
expect(screen.getByRole('checkbox')).toBeChecked();
|
||||||
|
expect(screen.getByRole('presentation')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onChange when clicked', () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<Checkbox onChange={onChange} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('presentation'));
|
||||||
|
expect(onChange).toHaveBeenCalledWith(true);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('presentation'));
|
||||||
|
expect(onChange).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses provided id', () => {
|
||||||
|
render(<Checkbox id="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', () => {
|
||||||
|
render(<Checkbox>Test Label</Checkbox>);
|
||||||
|
expect(screen.getByTestId('label-component')).toHaveTextContent('Test Label');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies custom className', () => {
|
||||||
|
const { container } = render(<Checkbox className="custom-class" />);
|
||||||
|
expect(container.firstChild).toHaveClass('custom-class');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies labelClassName to Label', () => {
|
||||||
|
render(<Checkbox labelClassName="label-class">Test</Checkbox>);
|
||||||
|
expect(screen.getByTestId('label-component')).toHaveClass('label-class');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables checkbox when disabled prop is true', () => {
|
||||||
|
render(<Checkbox disabled />);
|
||||||
|
expect(screen.getByRole('checkbox')).toBeDisabled();
|
||||||
|
expect(screen.getByRole('presentation')).toHaveClass('opacity-50');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call onChange when disabled', () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<Checkbox onChange={onChange} disabled />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('presentation'));
|
||||||
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets required attribute when required prop is true', () => {
|
||||||
|
render(<Checkbox required />);
|
||||||
|
expect(screen.getByRole('checkbox')).toHaveAttribute('required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets name attribute when name prop is provided', () => {
|
||||||
|
render(<Checkbox name="test-name" />);
|
||||||
|
expect(screen.getByRole('checkbox')).toHaveAttribute('name', 'test-name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through additional props', () => {
|
||||||
|
render(<Checkbox data-testid="extra-prop" />);
|
||||||
|
expect(screen.getByRole('checkbox')).toHaveAttribute('data-testid', 'extra-prop');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles checked state correctly', () => {
|
||||||
|
render(<Checkbox />);
|
||||||
|
const checkbox = screen.getByRole('checkbox');
|
||||||
|
const presentation = screen.getByRole('presentation');
|
||||||
|
|
||||||
|
expect(checkbox).not.toBeChecked();
|
||||||
|
|
||||||
|
fireEvent.click(presentation);
|
||||||
|
expect(checkbox).toBeChecked();
|
||||||
|
|
||||||
|
fireEvent.click(presentation);
|
||||||
|
expect(checkbox).not.toBeChecked();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useState, useId } from "react";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
import { Label } from "@components/UI/Label.tsx";
|
||||||
|
import { cn } from "@core/utils/cn.ts";
|
||||||
|
|
||||||
|
interface CheckboxProps {
|
||||||
|
checked?: boolean;
|
||||||
|
onChange?: (checked: boolean) => void;
|
||||||
|
className?: string;
|
||||||
|
labelClassName?: string;
|
||||||
|
id?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Checkbox({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
labelClassName,
|
||||||
|
id: propId,
|
||||||
|
children,
|
||||||
|
disabled = false,
|
||||||
|
required = false,
|
||||||
|
name,
|
||||||
|
...rest
|
||||||
|
}: CheckboxProps) {
|
||||||
|
const generatedId = useId();
|
||||||
|
const id = propId || generatedId;
|
||||||
|
|
||||||
|
const [isChecked, setIsChecked] = useState(checked || false);
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
const newChecked = !isChecked;
|
||||||
|
setIsChecked(newChecked);
|
||||||
|
onChange?.(newChecked);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex items-center", className)}>
|
||||||
|
<div className="relative flex items-start">
|
||||||
|
<div className="flex items-center h-5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={id}
|
||||||
|
checked={isChecked}
|
||||||
|
onChange={handleToggle}
|
||||||
|
disabled={disabled}
|
||||||
|
required={required}
|
||||||
|
name={name}
|
||||||
|
className="sr-only"
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
onClick={handleToggle}
|
||||||
|
role="presentation"
|
||||||
|
className={cn(
|
||||||
|
"w-6 h-6 border-2 border-gray-500 rounded-md flex items-center justify-center",
|
||||||
|
disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2",
|
||||||
|
isChecked ? "" : ""
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isChecked && (
|
||||||
|
<div className="animate-fade-in scale-100 opacity-100">
|
||||||
|
<Check className="w-4 h-4 text-slate-900 dark:text-slate-900" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{children && (
|
||||||
|
<div className="ml-3 text-sm">
|
||||||
|
<Label
|
||||||
|
htmlFor={id}
|
||||||
|
id={`${id}-label`}
|
||||||
|
className={cn(
|
||||||
|
"text-gray-900 dark:text-gray-900",
|
||||||
|
disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
|
||||||
|
labelClassName
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -50,15 +50,28 @@ const DialogContent = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<DialogPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-slate-100 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900">
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</DialogPrimitive.Close>
|
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
));
|
));
|
||||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DialogClose = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.DialogCloseProps & React.RefAttributes<HTMLButtonElement> & { className?: string }) => (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
name="close"
|
||||||
|
className={cn(
|
||||||
|
"absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-slate-100 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
);
|
||||||
|
|
||||||
const DialogHeader = ({
|
const DialogHeader = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
@@ -119,4 +132,5 @@ export {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
|
DialogClose,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import newGithubIssueUrl from "../../core/utils/github.ts";
|
import newGithubIssueUrl from "@core/utils/github.ts";
|
||||||
import { ExternalLink } from "lucide-react";
|
import { ExternalLink } from "lucide-react";
|
||||||
import { Heading } from "./Typography/Heading.tsx";
|
import { Heading } from "@components/UI/Typography/Heading.tsx";
|
||||||
import { Link } from "./Typography/Link.tsx";
|
import { Link } from "@components/UI/Typography/Link.tsx";
|
||||||
import { P } from "./Typography/P.tsx";
|
import { P } from "@components/UI/Typography/P.tsx";
|
||||||
|
|
||||||
|
|
||||||
export function ErrorPage({ error }: { error: Error }) {
|
export function ErrorPage({ error }: { error: Error }) {
|
||||||
@@ -11,8 +11,8 @@ export function ErrorPage({ error }: { error: Error }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="w-full overflow-y-auto">
|
<article className="w-full h-screen overflow-y-auto dark:bg-background-primary dark:text-text-primary">
|
||||||
<section className="flex shrink md:flex-row gap-16 mt-20 px-4 md:px-8 text-lg md:text-xl space-y-2 place-items-center">
|
<section className="flex shrink md:flex-row gap-16 mt-20 px-4 md:px-8 text-lg md:text-xl space-y-2 place-items-center dark:bg-background-primary text-slate-900 dark:text-text-primary">
|
||||||
<div>
|
<div>
|
||||||
<Heading as="h2" className="text-text-primary">
|
<Heading as="h2" className="text-text-primary">
|
||||||
This is a little embarrassing...
|
This is a little embarrassing...
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export const Link = ({ href, children, className }: LinkProps) => (
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-medium text-slate-900 underline underline-offset-4 dark:text-slate-50",
|
"font-medium text-slate-900 underline underline-offset-4 dark:text-slate-900",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { Table } from "@components/generic/Table/index.tsx";
|
||||||
|
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
|
||||||
|
import { Mono } from "@components/generic/Mono.tsx";
|
||||||
|
|
||||||
|
|
||||||
|
describe("Generic Table", () => {
|
||||||
|
it("Can render an empty table.", () => {
|
||||||
|
render(
|
||||||
|
<Table
|
||||||
|
headings={[]}
|
||||||
|
rows={[]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("table")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Can render a table with headers and no rows.", async () => {
|
||||||
|
render(
|
||||||
|
<Table
|
||||||
|
headings={[
|
||||||
|
{ title: "", type: "blank", sortable: false },
|
||||||
|
{ title: "Short Name", type: "normal", sortable: true },
|
||||||
|
{ title: "Long Name", type: "normal", sortable: true },
|
||||||
|
{ 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');
|
||||||
|
expect(screen.getAllByRole("columnheader")).toHaveLength(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A simplified version of the rows in pages/Nodes.tsx for testing purposes
|
||||||
|
const mockDevicesWithShortNameAndConnection = [
|
||||||
|
{user: {shortName: "TST1"}, hopsAway: 1, lastHeard: Date.now() + 1000 },
|
||||||
|
{user: {shortName: "TST2"}, hopsAway: 0, lastHeard: Date.now() + 4000 },
|
||||||
|
{user: {shortName: "TST3"}, hopsAway: 4, lastHeard: Date.now() },
|
||||||
|
{user: {shortName: "TST4"}, hopsAway: 3, lastHeard: Date.now() + 2000 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockRows = mockDevicesWithShortNameAndConnection.map(node => [
|
||||||
|
<h1 data-testshortname> { node.user.shortName } </h1>,
|
||||||
|
<><TimeAgo timestamp={node.lastHeard * 1000} /></>,
|
||||||
|
<Mono key="hops" data-testhops>
|
||||||
|
{node.lastHeard !== 0
|
||||||
|
? node.hopsAway === 0
|
||||||
|
? "Direct"
|
||||||
|
: `${node.hopsAway?.toString()} ${
|
||||||
|
node.hopsAway > 1 ? "hops" : "hop"
|
||||||
|
} away`
|
||||||
|
: "-"}
|
||||||
|
</Mono>
|
||||||
|
])
|
||||||
|
|
||||||
|
it("Can sort rows appropriately.", async () => {
|
||||||
|
render(
|
||||||
|
<Table
|
||||||
|
headings={[
|
||||||
|
{ title: "Short Name", type: "normal", sortable: true },
|
||||||
|
{ title: "Last Heard", type: "normal", sortable: true },
|
||||||
|
{ title: "Connection", type: "normal", sortable: true },
|
||||||
|
]}
|
||||||
|
rows={mockRows}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const renderedTable = await screen.findByRole('table');
|
||||||
|
const columnHeaders = screen.getAllByRole("columnheader");
|
||||||
|
expect(columnHeaders).toHaveLength(3);
|
||||||
|
|
||||||
|
// Will be sorted "Last heard" "asc" by default
|
||||||
|
expect( [...renderedTable.querySelectorAll('[data-testshortname]')]
|
||||||
|
.map(el=>el.textContent)
|
||||||
|
.map(v=>v?.trim())
|
||||||
|
.join(','))
|
||||||
|
.toMatch('TST2,TST4,TST1,TST3');
|
||||||
|
|
||||||
|
fireEvent.click(columnHeaders[0]);
|
||||||
|
|
||||||
|
// Re-sort by Short Name asc
|
||||||
|
expect( [...renderedTable.querySelectorAll('[data-testshortname]')]
|
||||||
|
.map(el=>el.textContent)
|
||||||
|
.map(v=>v?.trim())
|
||||||
|
.join(','))
|
||||||
|
.toMatch('TST1,TST2,TST3,TST4');
|
||||||
|
|
||||||
|
fireEvent.click(columnHeaders[0]);
|
||||||
|
|
||||||
|
// Re-sort by Short Name desc
|
||||||
|
expect( [...renderedTable.querySelectorAll('[data-testshortname]')]
|
||||||
|
.map(el=>el.textContent)
|
||||||
|
.map(v=>v?.trim())
|
||||||
|
.join(','))
|
||||||
|
.toMatch('TST4,TST3,TST2,TST1');
|
||||||
|
|
||||||
|
fireEvent.click(columnHeaders[2]);
|
||||||
|
|
||||||
|
// Re-sort by Hops Away
|
||||||
|
expect( [...renderedTable.querySelectorAll('[data-testshortname]')]
|
||||||
|
.map(el=>el.textContent)
|
||||||
|
.map(v=>v?.trim())
|
||||||
|
.join(','))
|
||||||
|
.toMatch('TST2,TST1,TST4,TST3');
|
||||||
|
});
|
||||||
|
})
|
||||||
@@ -12,6 +12,20 @@ export interface Heading {
|
|||||||
sortable: boolean;
|
sortable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param hopsAway String describing the number of hops away the node is from the current node
|
||||||
|
* @returns number of hopsAway or `0` if hopsAway is 'Direct'
|
||||||
|
*/
|
||||||
|
function numericHops(hopsAway: string): number {
|
||||||
|
if(hopsAway.match(/direct/i)){
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if ( hopsAway.match(/\d+\s+hop/gi) ) {
|
||||||
|
return Number( hopsAway.match(/(\d+)\s+hop/i)?.[1] );
|
||||||
|
}
|
||||||
|
return Number.MAX_SAFE_INTEGER;
|
||||||
|
}
|
||||||
|
|
||||||
export const Table = ({ headings, rows }: TableProps) => {
|
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");
|
||||||
@@ -46,6 +60,20 @@ export const Table = ({ headings, rows }: TableProps) => {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Custom comparison for 'Connection' column
|
||||||
|
if (sortColumn === "Connection") {
|
||||||
|
const aNumHops = numericHops(aValue instanceof Array ? aValue[0] : aValue);
|
||||||
|
const bNumHops = numericHops(bValue instanceof Array ? bValue[0] : bValue);
|
||||||
|
|
||||||
|
if (aNumHops < bNumHops) {
|
||||||
|
return sortOrder === "asc" ? -1 : 1;
|
||||||
|
}
|
||||||
|
if (aNumHops > bNumHops) {
|
||||||
|
return sortOrder === "asc" ? 1 : -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Default comparison for other columns
|
// Default comparison for other columns
|
||||||
if (aValue < bValue) {
|
if (aValue < bValue) {
|
||||||
return sortOrder === "asc" ? -1 : 1;
|
return sortOrder === "asc" ? -1 : 1;
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
// taken from https://react-hooked.vercel.app/docs/useLocalStorage/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||||
|
interface WindowEventMap {
|
||||||
|
"local-storage": CustomEvent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type UseLocalStorageOptions<T> = {
|
||||||
|
serializer?: (value: T) => string;
|
||||||
|
deserializer?: (value: string) => T;
|
||||||
|
initializeWithValue?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const IS_SERVER = typeof window === "undefined";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for persisting state to localStorage.
|
||||||
|
*
|
||||||
|
* @param {string} key - The key to use for localStorage.
|
||||||
|
* @param {T | (() => T)} initialValue - The initial value to use, if not found in localStorage.
|
||||||
|
* @param {UseLocalStorageOptions<T>} options - Options for the hook.
|
||||||
|
* @returns A tuple of [storedValue, setValue, removeValue].
|
||||||
|
*/
|
||||||
|
export default function useLocalStorage<T>(
|
||||||
|
key: string,
|
||||||
|
initialValue: T | (() => T),
|
||||||
|
options: UseLocalStorageOptions<T> = {},
|
||||||
|
): [T, Dispatch<SetStateAction<T>>, () => void] {
|
||||||
|
const { initializeWithValue = true } = options;
|
||||||
|
|
||||||
|
const serializer = useCallback<(value: T) => string>(
|
||||||
|
(value) => {
|
||||||
|
if (options.serializer) {
|
||||||
|
return options.serializer(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(value);
|
||||||
|
},
|
||||||
|
[options],
|
||||||
|
);
|
||||||
|
|
||||||
|
const deserializer = useCallback<(value: string) => T>(
|
||||||
|
(value) => {
|
||||||
|
if (options.deserializer) {
|
||||||
|
return options.deserializer(value);
|
||||||
|
}
|
||||||
|
// Support 'undefined' as a value
|
||||||
|
if (value === "undefined") {
|
||||||
|
return undefined as unknown as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultValue =
|
||||||
|
initialValue instanceof Function ? initialValue() : initialValue;
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(value);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error parsing JSON:", error);
|
||||||
|
return defaultValue; // Return initialValue if parsing fails
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed as T;
|
||||||
|
},
|
||||||
|
[options, initialValue],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get from local storage then
|
||||||
|
// parse stored json or return initialValue
|
||||||
|
const readValue = useCallback((): T => {
|
||||||
|
const initialValueToUse =
|
||||||
|
initialValue instanceof Function ? initialValue() : initialValue;
|
||||||
|
|
||||||
|
// Prevent build error "window is undefined" but keep working
|
||||||
|
if (IS_SERVER) {
|
||||||
|
return initialValueToUse;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(key);
|
||||||
|
return raw ? deserializer(raw) : initialValueToUse;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Error reading localStorage key “${key}”:`, error);
|
||||||
|
return initialValueToUse;
|
||||||
|
}
|
||||||
|
}, [initialValue, key, deserializer]);
|
||||||
|
|
||||||
|
const [storedValue, setStoredValue] = useState(() => {
|
||||||
|
if (initializeWithValue) {
|
||||||
|
return readValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
return initialValue instanceof Function ? initialValue() : initialValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return a wrapped version of useState's setter function that ...
|
||||||
|
// ... persists the new value to localStorage.
|
||||||
|
const setValue: Dispatch<SetStateAction<T>> = useCallback(
|
||||||
|
(value) => {
|
||||||
|
// Prevent build error "window is undefined" but keeps working
|
||||||
|
if (IS_SERVER) {
|
||||||
|
console.warn(
|
||||||
|
`Tried setting localStorage key “${key}” even though environment is not a client`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Allow value to be a function so we have the same API as useState
|
||||||
|
const newValue = value instanceof Function ? value(readValue()) : value;
|
||||||
|
|
||||||
|
// Save to local storage
|
||||||
|
window.localStorage.setItem(key, serializer(newValue));
|
||||||
|
|
||||||
|
// Save state
|
||||||
|
setStoredValue(newValue);
|
||||||
|
|
||||||
|
// We dispatch a custom event so every similar useLocalStorage hook is notified
|
||||||
|
window.dispatchEvent(new StorageEvent("local-storage", { key }));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Error setting localStorage key “${key}”:`, error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[key, serializer, readValue],
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeValue = useCallback(() => {
|
||||||
|
// Prevent build error "window is undefined" but keeps working
|
||||||
|
if (IS_SERVER) {
|
||||||
|
console.warn(
|
||||||
|
`Tried removing localStorage key “${key}” even though environment is not a client`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultValue =
|
||||||
|
initialValue instanceof Function ? initialValue() : initialValue;
|
||||||
|
|
||||||
|
// Remove the key from local storage
|
||||||
|
window.localStorage.removeItem(key);
|
||||||
|
|
||||||
|
// Save state with default value
|
||||||
|
setStoredValue(defaultValue);
|
||||||
|
|
||||||
|
// We dispatch a custom event so every similar useLocalStorage hook is notified
|
||||||
|
window.dispatchEvent(new StorageEvent("local-storage", { key }));
|
||||||
|
}, [key]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setStoredValue(readValue());
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [key]);
|
||||||
|
|
||||||
|
const handleStorageChange = useCallback(
|
||||||
|
(event: StorageEvent | CustomEvent) => {
|
||||||
|
if ((event as StorageEvent).key && (event as StorageEvent).key !== key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStoredValue(readValue());
|
||||||
|
},
|
||||||
|
[key, readValue],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
addEventListener("storage", handleStorageChange);
|
||||||
|
// this is a custom event, triggered in writeValueToLocalStorage
|
||||||
|
addEventListener("local-storage", handleStorageChange);
|
||||||
|
return () => {
|
||||||
|
removeEventListener("storage", handleStorageChange);
|
||||||
|
removeEventListener("local-storage", handleStorageChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return [storedValue, setValue, removeValue];
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create } from "@bufbuild/protobuf";
|
import { create } from "@bufbuild/protobuf";
|
||||||
import { Protobuf, Types } from "@meshtastic/core";
|
import { MeshDevice, Protobuf, Types } from "@meshtastic/core";
|
||||||
import { produce } from "immer";
|
import { produce } from "immer";
|
||||||
import { createContext, useContext } from "react";
|
import { createContext, useContext } from "react";
|
||||||
import { create as createStore } from "zustand";
|
import { create as createStore } from "zustand";
|
||||||
@@ -26,7 +26,12 @@ export type DialogVariant =
|
|||||||
| "deviceName"
|
| "deviceName"
|
||||||
| "nodeRemoval"
|
| "nodeRemoval"
|
||||||
| "pkiBackup"
|
| "pkiBackup"
|
||||||
| "nodeDetails";
|
| "nodeDetails"
|
||||||
|
| "unsafeRoles";
|
||||||
|
|
||||||
|
type QueueStatus = {
|
||||||
|
res: number, free: number, maxlen: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface Device {
|
export interface Device {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -47,13 +52,15 @@ export interface Device {
|
|||||||
number,
|
number,
|
||||||
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[]
|
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[]
|
||||||
>;
|
>;
|
||||||
connection?: Types.ConnectionType;
|
connection?: MeshDevice;
|
||||||
activePage: Page;
|
activePage: Page;
|
||||||
activeNode: number;
|
activeNode: number;
|
||||||
waypoints: Protobuf.Mesh.Waypoint[];
|
waypoints: Protobuf.Mesh.Waypoint[];
|
||||||
// currentMetrics: Protobuf.DeviceMetrics;
|
// currentMetrics: Protobuf.DeviceMetrics;
|
||||||
pendingSettingsChanges: boolean;
|
pendingSettingsChanges: boolean;
|
||||||
messageDraft: string;
|
messageDraft: string;
|
||||||
|
queueStatus: QueueStatus,
|
||||||
|
isQueueingMessages: boolean,
|
||||||
dialog: {
|
dialog: {
|
||||||
import: boolean;
|
import: boolean;
|
||||||
QR: boolean;
|
QR: boolean;
|
||||||
@@ -63,9 +70,11 @@ export interface Device {
|
|||||||
nodeRemoval: boolean;
|
nodeRemoval: boolean;
|
||||||
pkiBackup: boolean;
|
pkiBackup: boolean;
|
||||||
nodeDetails: boolean;
|
nodeDetails: boolean;
|
||||||
|
unsafeRoles: boolean;
|
||||||
};
|
};
|
||||||
unreadCounts: Map<number, number>;
|
unreadCounts: Map<number, number>;
|
||||||
|
|
||||||
|
|
||||||
setStatus: (status: Types.DeviceStatusEnum) => void;
|
setStatus: (status: Types.DeviceStatusEnum) => void;
|
||||||
setConfig: (config: Protobuf.Config.Config) => void;
|
setConfig: (config: Protobuf.Config.Config) => void;
|
||||||
setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
|
setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
|
||||||
@@ -81,7 +90,7 @@ export interface Device {
|
|||||||
addNodeInfo: (nodeInfo: Protobuf.Mesh.NodeInfo) => void;
|
addNodeInfo: (nodeInfo: Protobuf.Mesh.NodeInfo) => void;
|
||||||
addUser: (user: Types.PacketMetadata<Protobuf.Mesh.User>) => void;
|
addUser: (user: Types.PacketMetadata<Protobuf.Mesh.User>) => void;
|
||||||
addPosition: (position: Types.PacketMetadata<Protobuf.Mesh.Position>) => void;
|
addPosition: (position: Types.PacketMetadata<Protobuf.Mesh.Position>) => void;
|
||||||
addConnection: (connection: Types.ConnectionType) => void;
|
addConnection: (connection: MeshDevice) => void;
|
||||||
addMessage: (message: MessageWithState) => void;
|
addMessage: (message: MessageWithState) => void;
|
||||||
addTraceRoute: (
|
addTraceRoute: (
|
||||||
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>,
|
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>,
|
||||||
@@ -97,9 +106,11 @@ export interface Device {
|
|||||||
state: MessageState,
|
state: MessageState,
|
||||||
) => void;
|
) => void;
|
||||||
setDialogOpen: (dialog: DialogVariant, open: boolean) => void;
|
setDialogOpen: (dialog: DialogVariant, open: boolean) => void;
|
||||||
|
getDialogOpen: (dialog: DialogVariant) => boolean;
|
||||||
processPacket: (data: ProcessPacketParams) => void;
|
processPacket: (data: ProcessPacketParams) => void;
|
||||||
setMessageDraft: (message: string) => void;
|
setMessageDraft: (message: string) => void;
|
||||||
setUnread: (id: number, count: number) => void;
|
setUnread: (id: number, count: number) => void;
|
||||||
|
setQueueStatus: (status: QueueStatus) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeviceState {
|
export interface DeviceState {
|
||||||
@@ -139,6 +150,10 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
|||||||
activePage: "messages",
|
activePage: "messages",
|
||||||
activeNode: 0,
|
activeNode: 0,
|
||||||
waypoints: [],
|
waypoints: [],
|
||||||
|
queueStatus: {
|
||||||
|
res: 0, free: 0, maxlen: 0
|
||||||
|
},
|
||||||
|
isQueueingMessages: false,
|
||||||
dialog: {
|
dialog: {
|
||||||
import: false,
|
import: false,
|
||||||
QR: false,
|
QR: false,
|
||||||
@@ -148,6 +163,7 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
|||||||
nodeRemoval: false,
|
nodeRemoval: false,
|
||||||
pkiBackup: false,
|
pkiBackup: false,
|
||||||
nodeDetails: false,
|
nodeDetails: false,
|
||||||
|
unsafeRoles: false,
|
||||||
},
|
},
|
||||||
pendingSettingsChanges: false,
|
pendingSettingsChanges: false,
|
||||||
messageDraft: "",
|
messageDraft: "",
|
||||||
@@ -306,7 +322,7 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
|||||||
.findIndex(
|
.findIndex(
|
||||||
(wmc) =>
|
(wmc) =>
|
||||||
wmc.payloadVariant.case ===
|
wmc.payloadVariant.case ===
|
||||||
moduleConfig.payloadVariant.case,
|
moduleConfig.payloadVariant.case,
|
||||||
);
|
);
|
||||||
if (workingModuleConfigIndex !== -1) {
|
if (workingModuleConfigIndex !== -1) {
|
||||||
device.workingModuleConfig[workingModuleConfigIndex] =
|
device.workingModuleConfig[workingModuleConfigIndex] =
|
||||||
@@ -519,7 +535,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
|||||||
set(
|
set(
|
||||||
produce<DeviceState>((draft) => {
|
produce<DeviceState>((draft) => {
|
||||||
console.log("addTraceRoute called");
|
console.log("addTraceRoute called");
|
||||||
console.log(traceroute);
|
|
||||||
const device = draft.devices.get(id);
|
const device = draft.devices.get(id);
|
||||||
if (!device) {
|
if (!device) {
|
||||||
return;
|
return;
|
||||||
@@ -597,6 +612,13 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
getDialogOpen: (dialog: DialogVariant) => {
|
||||||
|
const device = get().devices.get(id);
|
||||||
|
if (!device) {
|
||||||
|
throw new Error("Device not found");
|
||||||
|
}
|
||||||
|
return device.dialog[dialog];
|
||||||
|
},
|
||||||
processPacket(data: ProcessPacketParams) {
|
processPacket(data: ProcessPacketParams) {
|
||||||
set(
|
set(
|
||||||
produce<DeviceState>((draft) => {
|
produce<DeviceState>((draft) => {
|
||||||
@@ -647,7 +669,18 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
setQueueStatus: (status: QueueStatus) => {
|
||||||
|
set(
|
||||||
|
produce<DeviceState>((draft) => {
|
||||||
|
const device = draft.devices.get(id);
|
||||||
|
if (device) {
|
||||||
|
device.queueStatus = status;
|
||||||
|
device.queueStatus.free >= 10 ? true : false
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { Device } from "@core/stores/deviceStore.ts";
|
import type { Device } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf, type Types } from "@meshtastic/core";
|
import { MeshDevice, Protobuf } from "@meshtastic/core";
|
||||||
|
|
||||||
|
|
||||||
export const subscribeAll = (
|
export const subscribeAll = (
|
||||||
device: Device,
|
device: Device,
|
||||||
connection: Types.ConnectionType,
|
connection: MeshDevice,
|
||||||
) => {
|
) => {
|
||||||
let myNodeNum = 0;
|
let myNodeNum = 0;
|
||||||
|
|
||||||
@@ -71,6 +71,8 @@ export const subscribeAll = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
connection.events.onChannelPacket.subscribe((channel) => {
|
connection.events.onChannelPacket.subscribe((channel) => {
|
||||||
|
console.log('channel', channel);
|
||||||
|
|
||||||
device.addChannel(channel);
|
device.addChannel(channel);
|
||||||
});
|
});
|
||||||
connection.events.onConfigPacket.subscribe((config) => {
|
connection.events.onConfigPacket.subscribe((config) => {
|
||||||
@@ -81,6 +83,9 @@ export const subscribeAll = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
connection.events.onMessagePacket.subscribe((messagePacket) => {
|
connection.events.onMessagePacket.subscribe((messagePacket) => {
|
||||||
|
|
||||||
|
console.log('messagePacket', messagePacket);
|
||||||
|
|
||||||
device.addMessage({
|
device.addMessage({
|
||||||
...messagePacket,
|
...messagePacket,
|
||||||
state: messagePacket.from !== myNodeNum ? "ack" : "waiting",
|
state: messagePacket.from !== myNodeNum ? "ack" : "waiting",
|
||||||
@@ -105,4 +110,11 @@ export const subscribeAll = (
|
|||||||
time: meshPacket.rxTime,
|
time: meshPacket.rxTime,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
connection.events.onQueueStatus.subscribe((queueStatus) => {
|
||||||
|
device.setQueueStatus(queueStatus);
|
||||||
|
if (queueStatus.free < 10) {
|
||||||
|
// start queueing messages
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { eventBus } from "@core/utils/eventBus.ts";
|
||||||
|
|
||||||
|
describe("EventBus", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset event listeners before each test
|
||||||
|
(eventBus as any).listeners = {};
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should register an event listener and trigger it on emit", () => {
|
||||||
|
const mockCallback = vi.fn();
|
||||||
|
|
||||||
|
eventBus.on("dialog:unsafeRoles", mockCallback);
|
||||||
|
eventBus.emit("dialog:unsafeRoles", { action: "confirm" });
|
||||||
|
|
||||||
|
expect(mockCallback).toHaveBeenCalledWith({ action: "confirm" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove an event listener with off", () => {
|
||||||
|
const mockCallback = vi.fn();
|
||||||
|
|
||||||
|
eventBus.on("dialog:unsafeRoles", mockCallback);
|
||||||
|
eventBus.off("dialog:unsafeRoles", mockCallback);
|
||||||
|
eventBus.emit("dialog:unsafeRoles", { action: "dismiss" });
|
||||||
|
|
||||||
|
expect(mockCallback).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return an unsubscribe function from on", () => {
|
||||||
|
const mockCallback = vi.fn();
|
||||||
|
const unsubscribe = eventBus.on("dialog:unsafeRoles", mockCallback);
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
eventBus.emit("dialog:unsafeRoles", { action: "confirm" });
|
||||||
|
|
||||||
|
expect(mockCallback).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow multiple listeners for the same event", () => {
|
||||||
|
const mockCallback1 = vi.fn();
|
||||||
|
const mockCallback2 = vi.fn();
|
||||||
|
|
||||||
|
eventBus.on("dialog:unsafeRoles", mockCallback1);
|
||||||
|
eventBus.on("dialog:unsafeRoles", mockCallback2);
|
||||||
|
eventBus.emit("dialog:unsafeRoles", { action: "confirm" });
|
||||||
|
|
||||||
|
expect(mockCallback1).toHaveBeenCalledWith({ action: "confirm" });
|
||||||
|
expect(mockCallback2).toHaveBeenCalledWith({ action: "confirm" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should only remove the specific listener when off is called", () => {
|
||||||
|
const mockCallback1 = vi.fn();
|
||||||
|
const mockCallback2 = vi.fn();
|
||||||
|
|
||||||
|
eventBus.on("dialog:unsafeRoles", mockCallback1);
|
||||||
|
eventBus.on("dialog:unsafeRoles", mockCallback2);
|
||||||
|
eventBus.off("dialog:unsafeRoles", mockCallback1);
|
||||||
|
eventBus.emit("dialog:unsafeRoles", { action: "dismiss" });
|
||||||
|
|
||||||
|
expect(mockCallback1).not.toHaveBeenCalled();
|
||||||
|
expect(mockCallback2).toHaveBeenCalledWith({ action: "dismiss" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not fail when calling off on a non-existent listener", () => {
|
||||||
|
const mockCallback = vi.fn();
|
||||||
|
eventBus.off("dialog:unsafeRoles", mockCallback);
|
||||||
|
eventBus.emit("dialog:unsafeRoles", { action: "confirm" });
|
||||||
|
|
||||||
|
expect(mockCallback).not.toHaveBeenCalled(); // No error should occur
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
export type EventMap = {
|
||||||
|
'dialog:unsafeRoles': {
|
||||||
|
action: 'confirm' | 'dismiss';
|
||||||
|
};
|
||||||
|
// add more events as required
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EventName = keyof EventMap;
|
||||||
|
export type EventCallback<T extends EventName> = (data: EventMap[T]) => void;
|
||||||
|
|
||||||
|
class EventBus {
|
||||||
|
private listeners: { [K in EventName]?: Array<EventCallback<K>> } = {};
|
||||||
|
|
||||||
|
public on<T extends EventName>(event: T, callback: EventCallback<T>): () => void {
|
||||||
|
if (!this.listeners[event]) {
|
||||||
|
this.listeners[event] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.listeners[event]?.push(callback as any);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
this.off(event, callback);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public off<T extends EventName>(event: T, callback: EventCallback<T>): void {
|
||||||
|
if (!this.listeners[event]) return;
|
||||||
|
|
||||||
|
const callbackIndex = this.listeners[event]?.indexOf(callback as any);
|
||||||
|
if (callbackIndex !== undefined && callbackIndex > -1) {
|
||||||
|
this.listeners[event]?.splice(callbackIndex, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public emit<T extends EventName>(event: T, data: EventMap[T]): void {
|
||||||
|
if (!this.listeners[event]) return;
|
||||||
|
|
||||||
|
this.listeners[event]?.forEach(callback => {
|
||||||
|
callback(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const eventBus = new EventBus();
|
||||||
@@ -71,6 +71,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
|
||||||
*,
|
*,
|
||||||
::after,
|
::after,
|
||||||
::before,
|
::before,
|
||||||
@@ -96,11 +97,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Prevent image dragging */
|
/* Prevent image dragging */
|
||||||
img {
|
img {
|
||||||
-webkit-user-drag: none;
|
-webkit-user-drag: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@keyframes spin-slower {
|
@keyframes spin-slower {
|
||||||
to {
|
to {
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Bluetooth } from "@components/PageComponents/Config/Bluetooth.tsx";
|
import { Bluetooth } from "@components/PageComponents/Config/Bluetooth.tsx";
|
||||||
import { Device } from "@components/PageComponents/Config/Device.tsx";
|
import { Device } from "../../components/PageComponents/Config/Device/index.tsx";
|
||||||
import { Display } from "@components/PageComponents/Config/Display.tsx";
|
import { Display } from "@components/PageComponents/Config/Display.tsx";
|
||||||
import { LoRa } from "@components/PageComponents/Config/LoRa.tsx";
|
import { LoRa } from "@components/PageComponents/Config/LoRa.tsx";
|
||||||
import { Network } from "@components/PageComponents/Config/Network.tsx";
|
import { Network } from "@components/PageComponents/Config/Network.tsx";
|
||||||
|
|||||||
+48
-32
@@ -12,6 +12,7 @@ import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
|||||||
import { getChannelName } from "@pages/Channels.tsx";
|
import { getChannelName } from "@pages/Channels.tsx";
|
||||||
import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react";
|
import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
|
||||||
|
|
||||||
export const MessagesPage = () => {
|
export const MessagesPage = () => {
|
||||||
const { channels, nodes, hardware, messages, unreadCounts, setUnread } = useDevice();
|
const { channels, nodes, hardware, messages, unreadCounts, setUnread } = useDevice();
|
||||||
@@ -36,6 +37,11 @@ export const MessagesPage = () => {
|
|||||||
const node = nodes.get(activeChat);
|
const node = nodes.get(activeChat);
|
||||||
const nodeHex = node?.num ? numberToHexUnpadded(node.num) : "Unknown";
|
const nodeHex = node?.num ? numberToHexUnpadded(node.num) : "Unknown";
|
||||||
|
|
||||||
|
const messageDestination = chatType === "direct" ? activeChat : "broadcast";
|
||||||
|
const messageChannel = chatType === "direct"
|
||||||
|
? Types.ChannelNumber.Primary
|
||||||
|
: activeChat;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Sidebar>
|
<Sidebar>
|
||||||
@@ -47,9 +53,9 @@ export const MessagesPage = () => {
|
|||||||
label={channel.settings?.name.length
|
label={channel.settings?.name.length
|
||||||
? channel.settings?.name
|
? channel.settings?.name
|
||||||
: channel.index === 0
|
: channel.index === 0
|
||||||
? "Primary"
|
? "Primary"
|
||||||
: `Ch ${channel.index}`}
|
: `Ch ${channel.index}`}
|
||||||
active={activeChat === channel.index}
|
active={activeChat === channel.index && chatType === "broadcast"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setChatType("broadcast");
|
setChatType("broadcast");
|
||||||
setActiveChat(channel.index);
|
setActiveChat(channel.index);
|
||||||
@@ -76,7 +82,7 @@ export const MessagesPage = () => {
|
|||||||
count={unreadCounts.get(node.num)}
|
count={unreadCounts.get(node.num)}
|
||||||
label={node.user?.longName ??
|
label={node.user?.longName ??
|
||||||
`!${numberToHexUnpadded(node.num)}`}
|
`!${numberToHexUnpadded(node.num)}`}
|
||||||
active={activeChat === node.num}
|
active={activeChat === node.num && chatType === "direct"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setChatType("direct");
|
setChatType("direct");
|
||||||
setActiveChat(node.num);
|
setActiveChat(node.num);
|
||||||
@@ -93,15 +99,15 @@ export const MessagesPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
<div className="flex flex-col grow">
|
<div className="flex flex-col w-full h-full container mx-auto">
|
||||||
<PageLayout
|
<PageLayout
|
||||||
label={`Messages: ${
|
className="flex flex-col h-full"
|
||||||
chatType === "broadcast" && currentChannel
|
label={`Messages: ${chatType === "broadcast" && currentChannel
|
||||||
? getChannelName(currentChannel)
|
? getChannelName(currentChannel)
|
||||||
: chatType === "direct" && nodes.get(activeChat)
|
: chatType === "direct" && nodes.get(activeChat)
|
||||||
? (nodes.get(activeChat)?.user?.longName ?? nodeHex)
|
? (nodes.get(activeChat)?.user?.longName ?? nodeHex)
|
||||||
: "Loading..."
|
: "Loading..."
|
||||||
}`}
|
}`}
|
||||||
actions={chatType === "direct"
|
actions={chatType === "direct"
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -124,28 +130,38 @@ export const MessagesPage = () => {
|
|||||||
]
|
]
|
||||||
: []}
|
: []}
|
||||||
>
|
>
|
||||||
{allChannels.map(
|
<div className="flex-1 overflow-y-auto">
|
||||||
(channel) =>
|
{chatType === "broadcast" && currentChannel && (
|
||||||
activeChat === channel.index && (
|
<div className="flex flex-col h-full">
|
||||||
<ChannelChat
|
<div className="flex-1 overflow-y-auto">
|
||||||
key={channel.index}
|
<ChannelChat
|
||||||
to="broadcast"
|
key={currentChannel.index}
|
||||||
messages={messages.broadcast.get(channel.index)}
|
messages={messages.broadcast.get(currentChannel.index)}
|
||||||
channel={channel.index}
|
/>
|
||||||
/>
|
</div>
|
||||||
),
|
</div>
|
||||||
)}
|
)}
|
||||||
{filteredNodes.map(
|
|
||||||
(node) =>
|
{chatType === "direct" && node && (
|
||||||
activeChat === node.num && (
|
<div className="flex flex-col h-full">
|
||||||
<ChannelChat
|
<div className="flex-1 overflow-y-auto">
|
||||||
key={node.num}
|
<ChannelChat
|
||||||
to={activeChat}
|
key={node.num}
|
||||||
messages={messages.direct.get(node.num)}
|
messages={messages.direct.get(node.num)}
|
||||||
channel={Types.ChannelNumber.Primary}
|
/>
|
||||||
/>
|
</div>
|
||||||
),
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Single message input for both chat types */}
|
||||||
|
<div className="shrink-0 p-4 w-full dark:bg-slate-900">
|
||||||
|
<MessageInput
|
||||||
|
to={messageDestination}
|
||||||
|
channel={messageChannel}
|
||||||
|
maxBytes={200}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
+10
-8
@@ -11,7 +11,7 @@ import { useDevice } from "@core/stores/deviceStore.ts";
|
|||||||
import { Protobuf, type Types } from "@meshtastic/core";
|
import { Protobuf, type Types } from "@meshtastic/core";
|
||||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||||
import { LockIcon, LockOpenIcon } from "lucide-react";
|
import { LockIcon, LockOpenIcon } from "lucide-react";
|
||||||
import { Fragment, type JSX, useCallback, useEffect, useState } from "react";
|
import { type JSX, useCallback, useEffect, useState } from "react";
|
||||||
import { base16 } from "rfc4648";
|
import { base16 } from "rfc4648";
|
||||||
|
|
||||||
export interface DeleteNoteDialogProps {
|
export interface DeleteNoteDialogProps {
|
||||||
@@ -21,6 +21,8 @@ export interface DeleteNoteDialogProps {
|
|||||||
|
|
||||||
const NodesPage = (): JSX.Element => {
|
const NodesPage = (): JSX.Element => {
|
||||||
const { nodes, hardware, connection } = useDevice();
|
const { nodes, hardware, connection } = useDevice();
|
||||||
|
console.log(connection);
|
||||||
|
|
||||||
const [selectedNode, setSelectedNode] = useState<
|
const [selectedNode, setSelectedNode] = useState<
|
||||||
Protobuf.Mesh.NodeInfo | undefined
|
Protobuf.Mesh.NodeInfo | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
@@ -61,6 +63,7 @@ const NodesPage = (): JSX.Element => {
|
|||||||
};
|
};
|
||||||
}, [connection]);
|
}, [connection]);
|
||||||
|
|
||||||
|
|
||||||
const handleLocation = useCallback(
|
const handleLocation = useCallback(
|
||||||
(location: Types.PacketMetadata<Protobuf.Mesh.Position>) => {
|
(location: Types.PacketMetadata<Protobuf.Mesh.Position>) => {
|
||||||
if (location.to.valueOf() !== hardware.myNodeNum) return;
|
if (location.to.valueOf() !== hardware.myNodeNum) return;
|
||||||
@@ -108,8 +111,8 @@ const NodesPage = (): JSX.Element => {
|
|||||||
{node.user?.shortName ??
|
{node.user?.shortName ??
|
||||||
(node.user?.macaddr
|
(node.user?.macaddr
|
||||||
? `${base16
|
? `${base16
|
||||||
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
|
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
|
||||||
.toLowerCase()}`
|
.toLowerCase()}`
|
||||||
: `${numberToHexUnpadded(node.num).slice(-4)}`)}
|
: `${numberToHexUnpadded(node.num).slice(-4)}`)}
|
||||||
</h1>,
|
</h1>,
|
||||||
|
|
||||||
@@ -121,8 +124,8 @@ const NodesPage = (): JSX.Element => {
|
|||||||
{node.user?.longName ??
|
{node.user?.longName ??
|
||||||
(node.user?.macaddr
|
(node.user?.macaddr
|
||||||
? `Meshtastic ${base16
|
? `Meshtastic ${base16
|
||||||
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
|
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
|
||||||
.toLowerCase()}`
|
.toLowerCase()}`
|
||||||
: `!${numberToHexUnpadded(node.num)}`)}
|
: `!${numberToHexUnpadded(node.num)}`)}
|
||||||
</h1>,
|
</h1>,
|
||||||
|
|
||||||
@@ -158,9 +161,8 @@ const NodesPage = (): JSX.Element => {
|
|||||||
{node.lastHeard !== 0
|
{node.lastHeard !== 0
|
||||||
? node.viaMqtt === false && node.hopsAway === 0
|
? node.viaMqtt === false && node.hopsAway === 0
|
||||||
? "Direct"
|
? "Direct"
|
||||||
: `${node.hopsAway?.toString()} ${
|
: `${node.hopsAway?.toString()} ${node.hopsAway > 1 ? "hops" : "hop"
|
||||||
node.hopsAway > 1 ? "hops" : "hop"
|
} away`
|
||||||
} away`
|
|
||||||
: "-"}
|
: "-"}
|
||||||
{node.viaMqtt === true ? ", via MQTT" : ""}
|
{node.viaMqtt === true ? ", via MQTT" : ""}
|
||||||
</Mono>,
|
</Mono>,
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
|
import { expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup } from '@testing-library/react';
|
||||||
|
import * as matchers from '@testing-library/jest-dom/matchers';
|
||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
|
|
||||||
|
// Enable auto mocks for our UI components
|
||||||
|
//vi.mock('@components/UI/Dialog.tsx');
|
||||||
|
//vi.mock('@components/UI/Typography/Link.tsx');
|
||||||
|
|
||||||
globalThis.ResizeObserver = class {
|
globalThis.ResizeObserver = class {
|
||||||
observe() { }
|
observe() { }
|
||||||
unobserve() { }
|
unobserve() { }
|
||||||
disconnect() { }
|
disconnect() { }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
+18
-1
@@ -1 +1,18 @@
|
|||||||
{ "github": { "silent": true } }
|
{
|
||||||
|
"github": { "silent": true },
|
||||||
|
"headers": [
|
||||||
|
{
|
||||||
|
"source": "/",
|
||||||
|
"headers": [
|
||||||
|
{
|
||||||
|
"key": "Cross-Origin-Embedder-Policy",
|
||||||
|
"value": "require-corp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "Cross-Origin-Opener-Policy",
|
||||||
|
"value": "same-origin"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
+9
-11
@@ -1,8 +1,10 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import { VitePWA } from 'vite-plugin-pwa';
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
import path from 'node:path';
|
|
||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
|
import process from "node:process";
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
|
||||||
let hash = '';
|
let hash = '';
|
||||||
try {
|
try {
|
||||||
@@ -19,7 +21,7 @@ export default defineConfig({
|
|||||||
registerType: 'autoUpdate',
|
registerType: 'autoUpdate',
|
||||||
strategies: 'generateSW',
|
strategies: 'generateSW',
|
||||||
devOptions: {
|
devOptions: {
|
||||||
enabled: true
|
enabled: false
|
||||||
},
|
},
|
||||||
workbox: {
|
workbox: {
|
||||||
cleanupOutdatedCaches: true,
|
cleanupOutdatedCaches: true,
|
||||||
@@ -32,7 +34,6 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
// Using Node's path and process.cwd() instead of Deno.cwd()
|
|
||||||
'@app': path.resolve(process.cwd(), './src'),
|
'@app': path.resolve(process.cwd(), './src'),
|
||||||
'@pages': path.resolve(process.cwd(), './src/pages'),
|
'@pages': path.resolve(process.cwd(), './src/pages'),
|
||||||
'@components': path.resolve(process.cwd(), './src/components'),
|
'@components': path.resolve(process.cwd(), './src/components'),
|
||||||
@@ -41,16 +42,13 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 3000
|
port: 3000,
|
||||||
|
headers: {
|
||||||
|
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||||
|
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||||
|
}
|
||||||
},
|
},
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
exclude: ['react-scan']
|
exclude: ['react-scan']
|
||||||
},
|
},
|
||||||
test: {
|
|
||||||
environment: 'jsdom',
|
|
||||||
globals: true,
|
|
||||||
include: ['**/*.{test,spec}.{ts,tsx}'],
|
|
||||||
setupFiles: ["./src/tests/setupTests.ts"],
|
|
||||||
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@app': path.resolve(process.cwd(), './src'),
|
||||||
|
'@pages': path.resolve(process.cwd(), './src/pages'),
|
||||||
|
'@components': path.resolve(process.cwd(), './src/components'),
|
||||||
|
'@core': path.resolve(process.cwd(), './src/core'),
|
||||||
|
'@layouts': path.resolve(process.cwd(), './src/layouts'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'happy-dom',
|
||||||
|
globals: true,
|
||||||
|
mockReset: true,
|
||||||
|
clearMocks: true,
|
||||||
|
restoreMocks: true,
|
||||||
|
root: path.resolve(process.cwd(), './src'),
|
||||||
|
include: ['**/*.{test,spec}.{ts,tsx}'],
|
||||||
|
setupFiles: ["./src/tests/setupTests.ts"],
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user