diff --git a/src/__mocks__/README.md b/src/__mocks__/README.md new file mode 100644 index 00000000..6f69ec3a --- /dev/null +++ b/src/__mocks__/README.md @@ -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 diff --git a/src/__mocks__/components/UI/Button.tsx b/src/__mocks__/components/UI/Button.tsx new file mode 100644 index 00000000..5b12fa35 --- /dev/null +++ b/src/__mocks__/components/UI/Button.tsx @@ -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 + }) => + +})); \ No newline at end of file diff --git a/src/__mocks__/components/UI/Checkbox.tsx b/src/__mocks__/components/UI/Checkbox.tsx new file mode 100644 index 00000000..52215ec9 --- /dev/null +++ b/src/__mocks__/components/UI/Checkbox.tsx @@ -0,0 +1,6 @@ +import { vi } from 'vitest' + +vi.mock('@components/UI/Checkbox.tsx', () => ({ + Checkbox: ({ id, checked, onChange }: { id: string, checked: boolean, onChange: () => void }) => + +})); \ No newline at end of file diff --git a/src/__mocks__/components/UI/Dialog/Dialog.tsx b/src/__mocks__/components/UI/Dialog/Dialog.tsx new file mode 100644 index 00000000..99ad3a0e --- /dev/null +++ b/src/__mocks__/components/UI/Dialog/Dialog.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +export const Dialog = ({ children, open }: { + children: React.ReactNode, + open: boolean, + onOpenChange?: (open: boolean) => void +}) => open ?
{children}
: null; + +export const DialogContent = ({ + children, + className +}: { + children: React.ReactNode, + className?: string +}) =>
{children}
; + +export const DialogHeader = ({ + children +}: { + children: React.ReactNode +}) =>
{children}
; + +export const DialogTitle = ({ + children +}: { + children: React.ReactNode +}) =>
{children}
; + +export const DialogDescription = ({ + children, + className +}: { + children: React.ReactNode, + className?: string +}) =>
{children}
; + +export const DialogFooter = ({ + children, + className +}: { + children: React.ReactNode, + className?: string +}) =>
{children}
; \ No newline at end of file diff --git a/src/__mocks__/components/UI/Label.tsx b/src/__mocks__/components/UI/Label.tsx new file mode 100644 index 00000000..be626fab --- /dev/null +++ b/src/__mocks__/components/UI/Label.tsx @@ -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 }) => + +})); \ No newline at end of file diff --git a/src/__mocks__/components/UI/Link.tsx b/src/__mocks__/components/UI/Link.tsx new file mode 100644 index 00000000..ec607e85 --- /dev/null +++ b/src/__mocks__/components/UI/Link.tsx @@ -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 }) => + {children} +})); + diff --git a/src/components/Dialog/DialogManager.tsx b/src/components/Dialog/DialogManager.tsx index ba6dd413..e8d597d4 100644 --- a/src/components/Dialog/DialogManager.tsx +++ b/src/components/Dialog/DialogManager.tsx @@ -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); }} /> + { + setDialogOpen("unsafeRoles", open); + }} + /> ); }; diff --git a/src/components/Dialog/ImportDialog.tsx b/src/components/Dialog/ImportDialog.tsx index 9a5ac133..28285a7f 100644 --- a/src/components/Dialog/ImportDialog.tsx +++ b/src/components/Dialog/ImportDialog.tsx @@ -1,6 +1,6 @@ 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, DialogContent, @@ -50,7 +50,7 @@ export const ImportDialog = ({ const paddedString = encodedChannelConfig .padEnd( encodedChannelConfig.length + - ((4 - (encodedChannelConfig.length % 4)) % 4), + ((4 - (encodedChannelConfig.length % 4)) % 4), "=", ) .replace(/-/g, "+") diff --git a/src/components/Dialog/QRDialog.tsx b/src/components/Dialog/QRDialog.tsx index f6b542cb..c434c918 100644 --- a/src/components/Dialog/QRDialog.tsx +++ b/src/components/Dialog/QRDialog.tsx @@ -1,5 +1,5 @@ import { create, toBinary } from "@bufbuild/protobuf"; -import { Checkbox } from "@components/UI/Checkbox.tsx"; +import { Checkbox } from "../UI/Checkbox/index.tsx"; import { Dialog, DialogContent, @@ -77,8 +77,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}`} + + + + + ); +}; diff --git a/src/components/Dialog/UnsafeRolesDialog/useUnsafeRoles.ts b/src/components/Dialog/UnsafeRolesDialog/useUnsafeRoles.ts new file mode 100644 index 00000000..f7c4b6fa --- /dev/null +++ b/src/components/Dialog/UnsafeRolesDialog/useUnsafeRoles.ts @@ -0,0 +1,40 @@ +import { useState, useCallback } from "react"; +import { useDevice } from "@core/stores/deviceStore.ts"; +import useLocalStorage from "@core/hooks/useLocalStorage.ts"; + +export const useUnsafeRoles = () => { + const [agreedToUnSafeRoles, setAgreedToUnsafeRoles] = useLocalStorage("agreeToUnsafeRole", false); + const [_confirmState, _setConfirmState] = useState(false); + const { setDialogOpen } = useDevice(); + + const toggleConfirmState = useCallback(() => { + setConfirmState(!_confirmState); + }, [_confirmState]); + + const setConfirmState = useCallback((state: boolean) => { + _setConfirmState(state); + }, [_setConfirmState]); + + const getConfirmState = useCallback(() => { + return _confirmState; + }, [_confirmState]); + + const handleCloseDialog = useCallback((closeState: "dismiss" | "confirm") => { + if (closeState === "dismiss") { + setAgreedToUnsafeRoles(false); + setConfirmState(false); + } + if (closeState === "confirm") { + setAgreedToUnsafeRoles(true); + setConfirmState(false); + } + setDialogOpen("unsafeRoles", false); + }, [setDialogOpen, setAgreedToUnsafeRoles]); + + return { + getConfirmState, + toggleConfirmState, + handleCloseDialog, + agreedToUnSafeRoles + }; +}; diff --git a/src/components/Form/FormMultiSelect.tsx b/src/components/Form/FormMultiSelect.tsx index 8657682b..b05d1e5c 100644 --- a/src/components/Form/FormMultiSelect.tsx +++ b/src/components/Form/FormMultiSelect.tsx @@ -19,28 +19,32 @@ export interface MultiSelectFieldProps extends BaseFormBuilderProps { }; } +const formatEnumDisplay = (name: string): string => { + return name + .replace(/_/g, " ") + .toLowerCase() + .split(" ") + .map((s) => s.charAt(0).toUpperCase() + s.substring(1)) + .join(" "); +}; + export function MultiSelectInput({ field, }: GenericFormElementProps>) { 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 = {}; + 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 ( @@ -52,9 +56,9 @@ export function MultiSelectInput({ checked={field.isChecked(name)} onCheckedChange={() => field.onValueChange(name)} > - {formatEnumName ? formatName(name) : name} + {formatEnumName ? formatEnumDisplay(name) : name} ))} ); -} +} \ No newline at end of file diff --git a/src/components/Form/FormSelect.tsx b/src/components/Form/FormSelect.tsx index 751bc5fe..ee533292 100644 --- a/src/components/Form/FormSelect.tsx +++ b/src/components/Form/FormSelect.tsx @@ -9,11 +9,12 @@ import { SelectTrigger, SelectValue, } from "@components/UI/Select.tsx"; -import { Controller, type FieldValues } from "react-hook-form"; +import { useController, type FieldValues } from "react-hook-form"; export interface SelectFieldProps extends BaseFormBuilderProps { type: "select"; - selectChange?: (e: string) => void; + selectChange?: (e: string, name: string) => void; + onBeforeChange?: (newValue: string, prevValue: string) => Promise; properties: BaseFormBuilderProps["properties"] & { enumValue: { [s: string]: string | number; @@ -22,56 +23,85 @@ export interface SelectFieldProps extends BaseFormBuilderProps { }; } + + +const formatEnumDisplay = (name: string): string => { + return name + .replace(/_/g, " ") + .toLowerCase() + .split(" ") + .map((s) => s.charAt(0).toUpperCase() + s.substring(1)) + .join(" "); +}; + export function SelectInput({ control, disabled, field, }: GenericFormElementProps>) { + const { + field: { value, onChange, ...rest }, + } = useController({ + name: field.name, + control, + }); + + const { enumValue, formatEnumName, ...remainingProperties } = field.properties; + const valueToKeyMap: Record = {}; + const keyToValueMap: Record = {}; + const optionsEnumValues: [string, number][] = []; + + if (enumValue) { + Object.entries(enumValue).forEach(([key, val]) => { + if (typeof val === "number") { + valueToKeyMap[val.toString()] = key; // Map enum value to key + keyToValueMap[key] = val; // Map key to enum value + optionsEnumValues.push([key, val]); + } + }); + } + + const handleValueChange = async (newValue: string) => { + const selectedKey = valueToKeyMap[newValue]; + if (!selectedKey) return; + + if (field.onBeforeChange) { + try { + const result = await field.onBeforeChange(selectedKey, valueToKeyMap[value?.toString()]); + + if (result === false) return; + const updatedValue = keyToValueMap[result]; + if (updatedValue !== undefined) { + if (field.selectChange) field.selectChange(updatedValue.toString(), result); + onChange(updatedValue); + } + } catch (error) { + console.error("Error in onBeforeChange function:", error); + } + } else { + if (field.selectChange) field.selectChange(newValue, selectedKey); + onChange(Number.parseInt(newValue)); + } + }; + return ( - { - const { enumValue, formatEnumName, ...remainingProperties } = - field.properties; - const optionsEnumValues = enumValue - ? Object.entries(enumValue).filter( - (value) => typeof value[1] === "number", - ) - : []; - return ( - - ); - }} - /> + ); } diff --git a/src/components/PageComponents/Channel.tsx b/src/components/PageComponents/Channel.tsx index 78cad593..1104c4c1 100644 --- a/src/components/PageComponents/Channel.tsx +++ b/src/components/PageComponents/Channel.tsx @@ -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)", diff --git a/src/components/PageComponents/Config/Device.tsx b/src/components/PageComponents/Config/Device/index.tsx similarity index 72% rename from src/components/PageComponents/Config/Device.tsx rename to src/components/PageComponents/Config/Device/index.tsx index 4e36a5f7..98919bb8 100644 --- a/src/components/PageComponents/Config/Device.tsx +++ b/src/components/PageComponents/Config/Device/index.tsx @@ -1,23 +1,44 @@ -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 { useUnsafeRoles } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRoles.ts"; export const Device = () => { - const { config, setWorkingConfig } = useDevice(); + + const { config, setWorkingConfig, setDialogOpen } = useDevice(); + const { agreedToUnSafeRoles } = useUnsafeRoles(); const onSubmit = (data: DeviceValidation) => { setWorkingConfig( create(Protobuf.Config.ConfigSchema, { payloadVariant: { case: "device", - value: data, + value: data }, }), ); }; + // deno-lint-ignore require-await + async function handleOnBeforeChange(newValue: string) { + if (newValue === "ROUTER" || newValue === 'REPEATER') { + // Open the dialog to confirm the user wants to select an unsafe role + setDialogOpen('unsafeRoles', true); + + // We checked the persisted value of agreedToUnSafeRoles in localStorage to see if the user has agreed to unsafe roles + if (agreedToUnSafeRoles) { + return newValue; + } else { + // If the user has not agreed to unsafe roles, we return false to prevent the role from being set + return false; + } + + } + return newValue; + } + return ( onSubmit={onSubmit} @@ -32,23 +53,9 @@ export const Device = () => { name: "role", label: "Role", description: "What role the device performs on the mesh", + onBeforeChange: handleOnBeforeChange, 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, }, }, @@ -106,4 +113,4 @@ export const Device = () => { ]} /> ); -}; +}; \ No newline at end of file diff --git a/src/components/PageComponents/Config/Position.tsx b/src/components/PageComponents/Config/Position.tsx index 565986b9..0454e833 100644 --- a/src/components/PageComponents/Config/Position.tsx +++ b/src/components/PageComponents/Config/Position.tsx @@ -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) => { diff --git a/src/components/UI/Button.tsx b/src/components/UI/Button.tsx index 5c4bde4c..08c7a7a8 100644 --- a/src/components/UI/Button.tsx +++ b/src/components/UI/Button.tsx @@ -40,16 +40,20 @@ export type ButtonVariant = VariantProps["variant"]; export interface ButtonProps extends - React.ButtonHTMLAttributes, - VariantProps {} + React.ButtonHTMLAttributes, + VariantProps { } const Button = React.forwardRef( - ({ className, variant, size, ...props }, ref) => { + ({ className, variant, size, disabled, ...props }, ref) => { return (