Merge branch 'master' into unread-counts

This commit is contained in:
Hunter Thornsberry
2025-03-11 16:25:58 -04:00
committed by GitHub
62 changed files with 2419 additions and 625 deletions
@@ -3,6 +3,7 @@ import { create } from "@bufbuild/protobuf";
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
@@ -52,6 +53,7 @@ export const DeviceNameDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>Change Device Name</DialogTitle>
<DialogDescription>
+10 -4
View File
@@ -1,13 +1,13 @@
import { useDevice } from "@core/stores/deviceStore.ts";
import { RemoveNodeDialog } from "@components/Dialog/RemoveNodeDialog.tsx";
import { DeviceNameDialog } from "@components/Dialog/DeviceNameDialog.tsx";
import { ImportDialog } from "@components/Dialog/ImportDialog.tsx";
import { PkiBackupDialog } from "./PKIBackupDialog.tsx";
import { PkiBackupDialog } from "@components/Dialog/PKIBackupDialog.tsx";
import { QRDialog } from "@components/Dialog/QRDialog.tsx";
import { RebootDialog } from "@components/Dialog/RebootDialog.tsx";
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { NodeDetailsDialog } from "./NodeDetailsDialog.tsx";
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog.tsx";
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
export const DialogManager = () => {
const { channels, config, dialog, setDialogOpen } = useDevice();
@@ -64,6 +64,12 @@ export const DialogManager = () => {
setDialogOpen("nodeDetails", open);
}}
/>
<UnsafeRolesDialog
open={dialog.unsafeRoles}
onOpenChange={(open) => {
setDialogOpen("unsafeRoles", open);
}}
/>
</>
);
};
+4 -2
View File
@@ -1,8 +1,9 @@
import { create, fromBinary } from "@bufbuild/protobuf";
import { Button } from "@components/UI/Button.tsx";
import { Checkbox } from "@components/UI/Checkbox.tsx";
import { Checkbox } from "../UI/Checkbox/index.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
@@ -50,7 +51,7 @@ export const ImportDialog = ({
const paddedString = encodedChannelConfig
.padEnd(
encodedChannelConfig.length +
((4 - (encodedChannelConfig.length % 4)) % 4),
((4 - (encodedChannelConfig.length % 4)) % 4),
"=",
)
.replace(/-/g, "+")
@@ -96,6 +97,7 @@ export const ImportDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>Import Channel Set</DialogTitle>
<DialogDescription>
@@ -1,6 +1,7 @@
import { useDevice } from "../../core/stores/deviceStore.ts";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
@@ -31,6 +32,7 @@ export const LocationResponseDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>{`Location: ${longName} (${shortName})`}</DialogTitle>
</DialogHeader>
@@ -41,9 +43,8 @@ export const LocationResponseDialog = ({
Coordinates:{" "}
<a
className="text-blue-500 dark:text-blue-400"
href={`https://www.openstreetmap.org/?mlat=${
location?.data.latitudeI / 1e7
}&mlon=${location?.data.longitudeI / 1e7}&layers=N`}
href={`https://www.openstreetmap.org/?mlat=${location?.data.latitudeI / 1e7
}&mlon=${location?.data.longitudeI / 1e7}&layers=N`}
target="_blank"
rel="noreferrer"
>
@@ -7,6 +7,7 @@ import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx";
import { Serial } from "@components/PageComponents/Connect/Serial.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogHeader,
DialogTitle,
@@ -135,6 +136,7 @@ export const NewDeviceDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>Connect New Device</DialogTitle>
</DialogHeader>
+6 -6
View File
@@ -8,6 +8,7 @@ import {
} from "../UI/Accordion.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
@@ -36,6 +37,7 @@ export const NodeDetailsDialog = ({
? (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>
Node Details for {device.user?.longName ?? "UNKNOWN"} (
@@ -85,11 +87,9 @@ export const NodeDetailsDialog = ({
Coordinates:{" "}
<a
className="text-blue-500 dark:text-blue-400"
href={`https://www.openstreetmap.org/?mlat=${
device.position.latitudeI / 1e7
}&mlon=${
device.position.longitudeI / 1e7
}&layers=N`}
href={`https://www.openstreetmap.org/?mlat=${device.position.latitudeI / 1e7
}&mlon=${device.position.longitudeI / 1e7
}&layers=N`}
target="_blank"
rel="noreferrer"
>
@@ -173,7 +173,7 @@ export const NodeDetailsDialog = ({
</AccordionTrigger>
<AccordionContent className="overflow-x-scroll">
<pre className="text-xs w-full">
{JSON.stringify(device, null, 2)}
{JSON.stringify(device, null, 2)}
</pre>
</AccordionContent>
</AccordionItem>
@@ -3,6 +3,7 @@ import { useAppStore } from "../../core/stores/appStore.ts";
import { useDevice } from "../../core/stores/deviceStore.ts";
import {
Dialog,
DialogClose,
DialogContent,
DialogHeader,
DialogTitle,
@@ -72,6 +73,7 @@ export const NodeOptionsDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>{`${longName} (${shortName})`}</DialogTitle>
</DialogHeader>
@@ -2,6 +2,7 @@ import { useDevice } from "../../core/stores/deviceStore.ts";
import { Button } from "../UI/Button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
@@ -102,6 +103,7 @@ export const PkiBackupDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>Backup Keys</DialogTitle>
<DialogDescription>
@@ -1,6 +1,7 @@
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
@@ -22,6 +23,7 @@ export const PkiRegenerateDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>Regenerate Key pair?</DialogTitle>
<DialogDescription>
+13 -13
View File
@@ -1,7 +1,8 @@
import { create, toBinary } from "@bufbuild/protobuf";
import { Checkbox } from "@components/UI/Checkbox.tsx";
import { Checkbox } from "../UI/Checkbox/index.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
@@ -62,6 +63,7 @@ export const QRDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>Generate QR Code</DialogTitle>
<DialogDescription>
@@ -77,8 +79,8 @@ export const QRDialog = ({
{channel.settings?.name.length
? channel.settings.name
: channel.role === Protobuf.Channel.Channel_Role.PRIMARY
? "Primary"
: `Channel: ${channel.index}`}
? "Primary"
: `Channel: ${channel.index}`}
</Label>
<Checkbox
key={channel.index}
@@ -106,22 +108,20 @@ export const QRDialog = ({
<div className="flex justify-center">
<button
type="button"
className={`border-slate-900 border-t border-l border-b rounded-l h-10 px-7 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${
qrCodeAdd
? "focus:ring-green-800 bg-green-800 text-white"
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
}`}
className={`border-slate-900 border-t border-l border-b rounded-l h-10 px-7 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${qrCodeAdd
? "focus:ring-green-800 bg-green-800 text-white"
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
}`}
onClick={() => setQrCodeAdd(true)}
>
Add Channels
</button>
<button
type="button"
className={`border-slate-900 border-t border-r border-b rounded-r h-10 px-4 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${
!qrCodeAdd
? "focus:ring-green-800 bg-green-800 text-white"
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
}`}
className={`border-slate-900 border-t border-r border-b rounded-r h-10 px-4 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${!qrCodeAdd
? "focus:ring-green-800 bg-green-800 text-white"
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
}`}
onClick={() => setQrCodeAdd(false)}
>
Replace Channels
+2
View File
@@ -1,6 +1,7 @@
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
@@ -27,6 +28,7 @@ export const RebootDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>Schedule Reboot</DialogTitle>
<DialogDescription>
@@ -3,6 +3,7 @@ import { useDevice } from "@core/stores/deviceStore.ts";
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
@@ -32,6 +33,7 @@ export const RemoveNodeDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>Remove Node?</DialogTitle>
<DialogDescription>
+2
View File
@@ -1,6 +1,7 @@
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
@@ -27,6 +28,7 @@ export const ShutdownDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>Schedule Shutdown</DialogTitle>
<DialogDescription>
@@ -1,6 +1,7 @@
import { useDevice } from "../../core/stores/deviceStore.ts";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
@@ -36,6 +37,7 @@ export const TracerouteResponseDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>{`Traceroute: ${longName} (${shortName})`}</DialogTitle>
</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,
};
};
+21 -17
View File
@@ -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>({
field,
}: GenericFormElementProps<T, MultiSelectFieldProps<T>>) {
const { enumValue, formatEnumName, ...remainingProperties } =
field.properties;
// Make sure to filter out the UNSET value, as it shouldn't be shown in the UI
const optionsEnumValues = enumValue
? Object.entries(enumValue)
.filter((value) => typeof value[1] === "number")
.filter((value) => value[0] !== "UNSET")
: [];
const valueToKeyMap: Record<string, string> = {};
const optionsEnumValues: [string, number][] = [];
const formatName = (name: string) => {
if (!formatEnumName) return name;
return name
.replace(/_/g, " ")
.toLowerCase()
.split(" ")
.map((s) => s.charAt(0).toUpperCase() + s.substring(1))
.join(" ");
};
if (enumValue) {
Object.entries(enumValue).forEach(([key, val]) => {
if (typeof val === "number" && key !== "UNSET") {
valueToKeyMap[val.toString()] = key;
optionsEnumValues.push([key, val as number]);
}
});
}
return (
<MultiSelect {...remainingProperties}>
@@ -52,9 +56,9 @@ export function MultiSelectInput<T extends FieldValues>({
checked={field.isChecked(name)}
onCheckedChange={() => field.onValueChange(name)}
>
{formatEnumName ? formatName(name) : name}
{formatEnumName ? formatEnumDisplay(name) : name}
</MultiSelectItem>
))}
</MultiSelect>
);
}
}
+64 -47
View File
@@ -9,11 +9,13 @@ import {
SelectTrigger,
SelectValue,
} 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> {
type: "select";
selectChange?: (e: string) => void;
selectChange?: (e: string, name: string) => void;
validate?: (newValue: string) => Promise<boolean>;
properties: BaseFormBuilderProps<T>["properties"] & {
enumValue: {
[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>({
control,
disabled,
field,
}: 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 (
<Controller
name={field.name}
control={control}
render={({ field: { value, onChange, ...rest } }) => {
const { enumValue, formatEnumName, ...remainingProperties } =
field.properties;
const optionsEnumValues = enumValue
? Object.entries(enumValue).filter(
(value) => typeof value[1] === "number",
)
: [];
return (
<Select
onValueChange={(e) => {
if (field.selectChange) field.selectChange(e);
onChange(Number.parseInt(e));
}}
disabled={disabled}
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>
);
}}
/>
<Select
onValueChange={handleValueChange}
disabled={disabled}
value={value?.toString()}
{...remainingProperties}
{...rest}
>
<SelectTrigger id={field.name}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{optionsEnumValues.map(([name, val]) => (
<SelectItem key={name} value={val.toString()}>
{formatEnumName ? formatEnumDisplay(name) : name}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
+3 -2
View File
@@ -102,13 +102,13 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
psk: pass,
positionEnabled:
channel?.settings?.moduleSettings?.positionPrecision !==
undefined &&
undefined &&
channel?.settings?.moduleSettings?.positionPrecision > 0,
preciseLocation:
channel?.settings?.moduleSettings?.positionPrecision === 32,
positionPrecision:
channel?.settings?.moduleSettings?.positionPrecision ===
undefined
undefined
? 10
: channel?.settings?.moduleSettings?.positionPrecision,
},
@@ -135,6 +135,7 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
{
type: "passwordGenerator",
name: "settings.psk",
id: 'channel-psk',
label: "Pre-Shared Key",
description:
"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)
}
})
);
});
});
});
@@ -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 { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
export const Device = () => {
const { config, setWorkingConfig } = useDevice();
const { validateRoleSelection } = useUnsafeRolesDialog();
const onSubmit = (data: DeviceValidation) => {
setWorkingConfig(
@@ -14,10 +16,9 @@ export const Device = () => {
case: "device",
value: data,
},
}),
})
);
};
return (
<DynamicForm<DeviceValidation>
onSubmit={onSubmit}
@@ -32,23 +33,9 @@ export const Device = () => {
name: "role",
label: "Role",
description: "What role the device performs on the mesh",
validate: validateRoleSelection,
properties: {
enumValue: {
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,
},
enumValue: Protobuf.Config.Config_DeviceConfig_Role,
formatEnumName: true,
},
},
@@ -12,7 +12,7 @@ import { useCallback } from "react";
export const Position = () => {
const { config, setWorkingConfig } = useDevice();
const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags(
config.position.positionFlags ?? 0,
config?.position.positionFlags ?? 0,
);
const onSubmit = (data: PositionValidation) => {
@@ -5,7 +5,7 @@ import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { subscribeAll } from "@core/subscriptions.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";
export const BLE = ({ closeDialog }: TabElementProps) => {
@@ -58,7 +58,7 @@ export const BLE = ({ closeDialog }: TabElementProps) => {
onClick={async () => {
await navigator.bluetooth
.requestDevice({
filters: [{ services: [Constants.ServiceUuid] }],
filters: [{ services: [ServiceUuid] }],
})
.then((device) => {
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 { TransportHTTP } from "@meshtastic/transport-http";
import { MeshDevice } from "@meshtastic/core";
import { TransportHTTP } from "@meshtastic/transport-http";
import { vi, describe, it, expect } from "vitest";
vi.mock("@core/stores/appStore.ts", () => ({
useAppStore: vi.fn(() => ({ setSelectedDevice: vi.fn() })),
@@ -41,25 +41,27 @@ describe("HTTP Component", () => {
it("allows input field to be updated", () => {
render(<HTTP closeDialog={vi.fn()} />);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "meshtastic.local" } });
expect(input).toHaveValue("meshtastic.local");
const inputField = screen.getByRole("textbox");
fireEvent.change(inputField, { target: { value: 'meshtastic.local' } })
expect(screen.getByPlaceholderText("000.000.000.000 / meshtastic.local")).toBeInTheDocument();
});
it("toggles HTTPS switch and updates prefix", () => {
render(<HTTP closeDialog={vi.fn()} />);
const switchInput = screen.getByRole("switch");
expect(screen.getByText("http://")).toBeInTheDocument();
fireEvent.click(switchInput);
fireEvent.click(switchInput)
expect(screen.getByText("https://")).toBeInTheDocument();
fireEvent.click(switchInput);
fireEvent.click(switchInput)
expect(switchInput).not.toBeChecked();
expect(screen.getByText("http://")).toBeInTheDocument();
});
it("enables HTTPS toggle when location protocol is https", () => {
Object.defineProperty(globalThis, "location", {
Object.defineProperty(window, "location", {
value: { protocol: "https:" },
writable: true,
});
@@ -68,24 +70,27 @@ describe("HTTP Component", () => {
const switchInput = screen.getByRole("switch");
expect(switchInput).toBeChecked();
expect(screen.getByText("https://")).toBeInTheDocument();
});
it.skip("submits form and triggers connection process", () => {
// This will need further work to test, as it involves a lot of other plumbing mocking
it.skip("submits form and triggers connection process", async () => {
const closeDialog = vi.fn();
render(<HTTP closeDialog={closeDialog} />);
const button = screen.getByRole("button", { name: "Connect" });
expect(button).not.toBeDisabled();
fireEvent.click(button);
waitFor(() => {
expect(button).toBeDisabled();
expect(closeDialog).toHaveBeenCalled();
expect(TransportHTTP.create).toHaveBeenCalled();
expect(MeshDevice).toHaveBeenCalled();
});
try {
fireEvent.click(button);
await waitFor(() => {
expect(button).toBeDisabled();
expect(closeDialog).toBeCalled();
expect(TransportHTTP.create).toBeCalled();
expect(MeshDevice).toBeCalled();
});
} catch (e) {
console.error(e)
}
});
});
@@ -1,14 +1,10 @@
import { type MessageWithState, useDevice } from "@core/stores/deviceStore.ts";
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 { useCallback, useEffect, useRef } from "react";
export interface ChannelChatProps {
messages?: MessageWithState[];
channel: Types.ChannelNumber;
to: Types.Destination;
}
const EmptyState = () => (
@@ -20,8 +16,6 @@ const EmptyState = () => (
export const ChannelChat = ({
messages,
channel,
to,
}: ChannelChatProps) => {
const { nodes } = useDevice();
const messagesEndRef = useRef<HTMLDivElement>(null);
@@ -30,10 +24,12 @@ export const ChannelChat = ({
const scrollToBottom = useCallback(() => {
const scrollContainer = scrollContainerRef.current;
if (scrollContainer) {
const isNearBottom = scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight <
const isNearBottom =
scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight <
100;
if (isNearBottom) {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}
@@ -42,7 +38,7 @@ export const ChannelChat = ({
useEffect(() => {
scrollToBottom();
}, [scrollToBottom]);
}, [scrollToBottom, messages]);
if (!messages?.length) {
return (
@@ -50,34 +46,31 @@ export const ChannelChat = ({
<div className="flex-1 flex items-center justify-center">
<EmptyState />
</div>
<div className="shrink-0 p-4 w-full dark:bg-slate-900">
<MessageInput to={to} channel={channel} maxBytes={200} />
</div>
</div>
);
}
return (
<div className="flex flex-col h-full container mx-auto">
<div className="flex-1 overflow-y-auto" ref={scrollContainerRef}>
<div className="w-full h-full flex flex-col justify-end pl-4 pr-44">
{messages.map((message, index) => {
return (
<Message
key={message.id}
message={message}
sender={nodes.get(message.from)}
lastMsgSameUser={index > 0 &&
messages[index - 1].from === message.from}
/>
);
})}
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto pl-4 pr-4 md:pr-44"
>
<div className="flex flex-col justify-end min-h-full">
{messages.map((message, index) => (
<Message
key={message.id}
message={message}
sender={nodes.get(message.from)}
lastMsgSameUser={
index > 0 && messages[index - 1].from === message.from
}
/>
))}
<div ref={messagesEndRef} className="w-full" />
</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>
);
};
};
@@ -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 { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
@@ -22,6 +22,8 @@ export const MessageInput = ({
setMessageState,
messageDraft,
setMessageDraft,
isQueueingMessages,
queueStatus,
hardware,
} = useDevice();
const myNodeNum = hardware.myNodeNum;
@@ -33,8 +35,10 @@ export const MessageInput = ({
[setMessageDraft],
);
// sends the message to the selected destination
const sendText = useCallback(
async (message: string) => {
await connection
?.sendText(message, to, true, channel)
.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>) => {
@@ -81,15 +85,18 @@ export const MessageInput = ({
if (localDraft === "") return;
const message = formData.get("messageInput") as string;
startTransition(() => {
sendText(message);
setLocalDraft("");
setMessageDraft("");
setMessageBytes(0);
if (!isQueueingMessages) {
sendText(message);
setLocalDraft("");
setMessageDraft("");
setMessageBytes(0);
}
});
}}
>
<div className="flex grow gap-2">
<span className="w-full">
<div className="flex grow gap-2 ">
<label className="w-full">
<Input
autoFocus
minLength={1}
@@ -98,12 +105,12 @@ export const MessageInput = ({
value={localDraft}
onChange={handleInputChange}
/>
</span>
<div className="flex items-center w-24 p-2 place-content-end">
</label>
<label data-testid="byte-counter" className="flex items-center w-24 p-2 place-content-end">
{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} />
</Button>
</div>
+3
View File
@@ -10,6 +10,7 @@ export interface PageLayoutProps {
label: string;
noPadding?: boolean;
children: React.ReactNode;
className?: string;
actions?: {
icon: LucideIcon;
iconClasses?: string;
@@ -23,6 +24,7 @@ export const PageLayout = ({
label,
noPadding,
actions,
className,
children,
}: PageLayoutProps) => {
return (
@@ -63,6 +65,7 @@ export const PageLayout = ({
className={cn(
"flex h-full w-full flex-col overflow-y-auto",
!noPadding && "pl-3 pr-3 ",
className
)}
>
{children}
+1 -1
View File
@@ -66,7 +66,7 @@ export const Sidebar = ({ children }: SidebarProps) => {
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>
<span className="text-lg font-medium">
+8 -4
View File
@@ -40,16 +40,20 @@ export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export interface ButtonProps
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> { }
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
({ className, variant, size, disabled, ...props }, ref) => {
return (
<button
type="button"
className={cn(buttonVariants({ variant, size, className }))}
className={cn(
buttonVariants({ variant, size, className }),
{ "cursor-not-allowed": disabled }
)}
ref={ref}
disabled={disabled}
{...props}
/>
);
-28
View File
@@ -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();
});
});
+93
View File
@@ -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>
);
}
+18 -4
View File
@@ -50,15 +50,28 @@ const DialogContent = React.forwardRef<
{...props}
>
{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>
</DialogPortal>
));
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 = ({
className,
...props
@@ -119,4 +132,5 @@ export {
DialogHeader,
DialogTitle,
DialogTrigger,
DialogClose,
};
+6 -6
View File
@@ -1,8 +1,8 @@
import newGithubIssueUrl from "../../core/utils/github.ts";
import newGithubIssueUrl from "@core/utils/github.ts";
import { ExternalLink } from "lucide-react";
import { Heading } from "./Typography/Heading.tsx";
import { Link } from "./Typography/Link.tsx";
import { P } from "./Typography/P.tsx";
import { Heading } from "@components/UI/Typography/Heading.tsx";
import { Link } from "@components/UI/Typography/Link.tsx";
import { P } from "@components/UI/Typography/P.tsx";
export function ErrorPage({ error }: { error: Error }) {
@@ -11,8 +11,8 @@ export function ErrorPage({ error }: { error: Error }) {
}
return (
<article className="w-full overflow-y-auto">
<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">
<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 dark:bg-background-primary text-slate-900 dark:text-text-primary">
<div>
<Heading as="h2" className="text-text-primary">
This is a little embarrassing...
+1 -1
View File
@@ -12,7 +12,7 @@ export const Link = ({ href, children, className }: LinkProps) => (
target="_blank"
rel="noopener noreferrer"
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,
)}
>
+111
View File
@@ -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');
});
})
+29 -1
View File
@@ -12,6 +12,20 @@ export interface Heading {
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) => {
const [sortColumn, setSortColumn] = useState<string | null>("Last Heard");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
@@ -46,6 +60,20 @@ export const Table = ({ headings, rows }: TableProps) => {
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
if (aValue < bValue) {
return sortOrder === "asc" ? -1 : 1;
@@ -100,4 +128,4 @@ export const Table = ({ headings, rows }: TableProps) => {
</tbody>
</table>
);
};
};