From eca5d780c1c0caae25f9f42857263069808dae54 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Sun, 9 Mar 2025 12:57:37 -0400 Subject: [PATCH 1/7] feat: added are you sure dialog --- src/__mocks__/README.md | 43 +++++ src/__mocks__/components/UI/Button.tsx | 20 ++ src/__mocks__/components/UI/Checkbox.tsx | 6 + src/__mocks__/components/UI/Dialog/Dialog.tsx | 43 +++++ src/__mocks__/components/UI/Label.tsx | 6 + src/__mocks__/components/UI/Link.tsx | 7 + src/components/Dialog/DialogManager.tsx | 14 +- src/components/Dialog/ImportDialog.tsx | 4 +- src/components/Dialog/QRDialog.tsx | 16 +- .../UnsafeRolesDialog/UnsafeRolesDialog.tsx | 51 +++++ .../UnsafeRolesDialog/useUnsafeRoles.ts | 40 ++++ src/components/Form/FormMultiSelect.tsx | 38 ++-- src/components/Form/FormSelect.tsx | 124 +++++++----- src/components/PageComponents/Channel.tsx | 5 +- .../Config/{Device.tsx => Device/index.tsx} | 47 +++-- .../PageComponents/Config/Position.tsx | 2 +- src/components/UI/Button.tsx | 12 +- src/components/UI/Checkbox.tsx | 28 --- src/components/UI/Checkbox/index.tsx | 93 +++++++++ src/components/UI/ErrorPage.tsx | 12 +- src/components/UI/Typography/Link.tsx | 2 +- src/core/hooks/useLocalStorage.ts | 179 ++++++++++++++++++ src/core/stores/deviceStore.ts | 7 +- src/pages/Config/DeviceConfig.tsx | 2 +- src/tests/setupTests.ts | 5 + vite.config.ts | 10 +- 26 files changed, 669 insertions(+), 147 deletions(-) create mode 100644 src/__mocks__/README.md create mode 100644 src/__mocks__/components/UI/Button.tsx create mode 100644 src/__mocks__/components/UI/Checkbox.tsx create mode 100644 src/__mocks__/components/UI/Dialog/Dialog.tsx create mode 100644 src/__mocks__/components/UI/Label.tsx create mode 100644 src/__mocks__/components/UI/Link.tsx create mode 100644 src/components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx create mode 100644 src/components/Dialog/UnsafeRolesDialog/useUnsafeRoles.ts rename src/components/PageComponents/Config/{Device.tsx => Device/index.tsx} (72%) delete mode 100644 src/components/UI/Checkbox.tsx create mode 100644 src/components/UI/Checkbox/index.tsx create mode 100644 src/core/hooks/useLocalStorage.ts 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 ( - - + ); }; diff --git a/src/components/Dialog/UnsafeRolesDialog/useUnsafeRoles.test.ts b/src/components/Dialog/UnsafeRolesDialog/useUnsafeRoles.test.ts deleted file mode 100644 index d575d42d..00000000 --- a/src/components/Dialog/UnsafeRolesDialog/useUnsafeRoles.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; -import { useUnsafeRoles } from './useUnsafeRoles.ts'; -import { useDevice } from '@core/stores/deviceStore.ts'; -import useLocalStorage from '@core/hooks/useLocalStorage.ts'; - -vi.mock('@core/stores/deviceStore', () => ({ - useDevice: vi.fn() -})); - -vi.mock('@core/hooks/useLocalStorage', () => { - return { - default: vi.fn() - }; -}); - -describe('useUnsafeRoles', () => { - const setDialogOpenMock = vi.fn(); - const setAgreedToUnsafeRolesMock = vi.fn(); - - beforeEach(() => { - vi.resetAllMocks(); - - (useDevice as any).mockReturnValue({ - setDialogOpen: setDialogOpenMock - }); - - (useLocalStorage as any).mockReturnValue([ - false, - setAgreedToUnsafeRolesMock - ]); - }); - - it('should initialize with correct default values', () => { - const { result } = renderHook(() => useUnsafeRoles()); - - expect(result.current.agreedToUnSafeRoles).toBe(false); - expect(result.current.getConfirmState()).toBe(false); - }); - - it('should toggle confirm state correctly', () => { - const { result } = renderHook(() => useUnsafeRoles()); - - act(() => { - result.current.toggleConfirmState(); - }); - - expect(result.current.getConfirmState()).toBe(true); - - act(() => { - result.current.toggleConfirmState(); - }); - - expect(result.current.getConfirmState()).toBe(false); - }); - - it('should handle dialog close with dismiss state', () => { - const { result } = renderHook(() => useUnsafeRoles()); - - act(() => { - result.current.handleCloseDialog('dismiss'); - }); - - expect(setAgreedToUnsafeRolesMock).toHaveBeenCalledWith(false); - expect(setDialogOpenMock).toHaveBeenCalledWith('unsafeRoles', false); - }); - - it('should handle dialog close with confirm state', () => { - const { result } = renderHook(() => useUnsafeRoles()); - - act(() => { - result.current.handleCloseDialog('confirm'); - }); - - expect(setAgreedToUnsafeRolesMock).toHaveBeenCalledWith(true); - expect(setDialogOpenMock).toHaveBeenCalledWith('unsafeRoles', false); - }); - - it('should maintain state consistency across multiple operations', () => { - const { result } = renderHook(() => useUnsafeRoles()); - - act(() => { - result.current.toggleConfirmState(); - }); - expect(result.current.getConfirmState()).toBe(true); - - act(() => { - result.current.handleCloseDialog('confirm'); - }); - - expect(result.current.getConfirmState()).toBe(false); - expect(setAgreedToUnsafeRolesMock).toHaveBeenCalledWith(true); - - (useLocalStorage as any).mockReturnValue([ - true, - setAgreedToUnsafeRolesMock - ]); - - const { result: newResult } = renderHook(() => useUnsafeRoles()); - expect(newResult.current.agreedToUnSafeRoles).toBe(true); - }); -}); \ No newline at end of file diff --git a/src/components/Dialog/UnsafeRolesDialog/useUnsafeRoles.ts b/src/components/Dialog/UnsafeRolesDialog/useUnsafeRoles.ts deleted file mode 100644 index f7c4b6fa..00000000 --- a/src/components/Dialog/UnsafeRolesDialog/useUnsafeRoles.ts +++ /dev/null @@ -1,40 +0,0 @@ -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/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx b/src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx new file mode 100644 index 00000000..bc193646 --- /dev/null +++ b/src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx @@ -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); + } + }); +}); \ No newline at end of file diff --git a/src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts b/src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts new file mode 100644 index 00000000..5d417704 --- /dev/null +++ b/src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts @@ -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 => { + 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, + }; +}; diff --git a/src/components/Form/FormSelect.tsx b/src/components/Form/FormSelect.tsx index ee533292..037b651c 100644 --- a/src/components/Form/FormSelect.tsx +++ b/src/components/Form/FormSelect.tsx @@ -10,11 +10,12 @@ import { SelectValue, } from "@components/UI/Select.tsx"; import { useController, type FieldValues } from "react-hook-form"; +import { computeHeadingLevel } from "@core/utils/test.tsx"; export interface SelectFieldProps extends BaseFormBuilderProps { type: "select"; selectChange?: (e: string, name: string) => void; - onBeforeChange?: (newValue: string, prevValue: string) => Promise; + validate?: (newValue: string) => Promise; properties: BaseFormBuilderProps["properties"] & { enumValue: { [s: string]: string | number; @@ -23,8 +24,6 @@ export interface SelectFieldProps extends BaseFormBuilderProps { }; } - - const formatEnumDisplay = (name: string): string => { return name .replace(/_/g, " ") @@ -48,14 +47,12 @@ export function SelectInput({ 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 + valueToKeyMap[val.toString()] = key; optionsEnumValues.push([key, val]); } }); @@ -63,27 +60,17 @@ export function SelectInput({ 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)); + if (field.validate) { + const isValid = await field.validate(selectedKey); + if (!isValid) return; } + + if (field.selectChange) field.selectChange(newValue, selectedKey); + onChange(Number.parseInt(newValue)); }; + return ( { + // Simulate the validation and submission process + const mockData = { role: e.target.value }; + onSubmit(mockData); + }} + > + {Object.entries(Protobuf.Config.Config_DeviceConfig_Role).map(([key, value]) => ( + + ))} + + + + ); + }) +})); + +describe('Device component', () => { const setWorkingConfigMock = vi.fn(); - const validateRoleDialogResultMock = 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 useDevice hook + + // Mock the useDevice hook (useDevice as any).mockReturnValue({ config: { - device: {} + device: mockDeviceConfig }, setWorkingConfig: setWorkingConfigMock }); - - // Mock useUnsafeRolesDialog hook + + // Mock the useUnsafeRolesDialog hook + validateRoleSelectionMock.mockResolvedValue(true); (useUnsafeRolesDialog as any).mockReturnValue({ - validateRoleDialogResult: validateRoleDialogResultMock + validateRoleSelection: validateRoleSelectionMock }); }); - it('should use the validateRoleDialogResult from the hook', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should render the Device form', () => { + render(); + expect(screen.getByTestId('dynamic-form')).toBeInTheDocument(); + }); + + it('should use the validateRoleSelection from the unsafe roles hook', () => { render(); - - // Verify the hook was called expect(useUnsafeRolesDialog).toHaveBeenCalled(); - - // Verify the form is using the validation function from the hook - expect(setWorkingConfigMock).not.toHaveBeenCalled(); // Just ensure the component rendered without errors + }); + + it('should call setWorkingConfig when form is submitted', async () => { + render(); + + 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(); + + // Simulate form submission + fireEvent.click(screen.getByTestId('submit-button')); + + await waitFor(() => { + expect(setWorkingConfigMock).toHaveBeenCalledWith( + expect.objectContaining({ + payloadVariant: { + case: "device", + value: expect.any(Object) + } + }) + ); + }); }); }); \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index 23fcfb8b..b6b90406 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'vitest/config'; +import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import { VitePWA } from 'vite-plugin-pwa'; import { execSync } from 'node:child_process'; @@ -51,17 +51,4 @@ export default defineConfig({ optimizeDeps: { exclude: ['react-scan'] }, - - test: { - environment: 'jsdom', - globals: true, - mockReset: true, - clearMocks: true, - restoreMocks: true, - root: path.resolve(process.cwd(), './src'), - include: ['**/*.{test,spec}.{ts,tsx}'], - setupFiles: ["./src/tests/setupTests.ts"], - - } - }); \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts index e55b9841..73674288 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -16,9 +16,13 @@ export default defineConfig({ }, }, test: { + environment: 'jsdom', globals: true, - include: ['src/**/*.test.tsx', 'src/**/*.test.ts'], - setupFiles: ['src/tests/setupTests.ts'], - environment: 'happy-dom', + mockReset: true, + clearMocks: true, + restoreMocks: true, + root: path.resolve(process.cwd(), './src'), + include: ['**/*.{test,spec}.{ts,tsx}'], + setupFiles: ["./src/tests/setupTests.ts"], }, }) \ No newline at end of file From 2cebb8eee26df5b3d304f768e168d6e5a0ca739f Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Mon, 10 Mar 2025 20:53:09 -0400 Subject: [PATCH 5/7] refactor: added close button back to dialog --- src/components/Dialog/DeviceNameDialog.tsx | 2 ++ src/components/Dialog/ImportDialog.tsx | 2 ++ src/components/Dialog/LocationResponseDialog.tsx | 7 ++++--- src/components/Dialog/NewDeviceDialog.tsx | 2 ++ src/components/Dialog/NodeDetailsDialog.tsx | 12 ++++++------ src/components/Dialog/NodeOptionsDialog.tsx | 2 ++ src/components/Dialog/PKIBackupDialog.tsx | 2 ++ src/components/Dialog/PkiRegenerateDialog.tsx | 2 ++ src/components/Dialog/QRDialog.tsx | 10 ++++++---- src/components/Dialog/RebootDialog.tsx | 2 ++ src/components/Dialog/RemoveNodeDialog.tsx | 2 ++ src/components/Dialog/ShutdownDialog.tsx | 2 ++ src/components/Dialog/TracerouteResponseDialog.tsx | 2 ++ 13 files changed, 36 insertions(+), 13 deletions(-) diff --git a/src/components/Dialog/DeviceNameDialog.tsx b/src/components/Dialog/DeviceNameDialog.tsx index 7a8a08ff..765e4097 100644 --- a/src/components/Dialog/DeviceNameDialog.tsx +++ b/src/components/Dialog/DeviceNameDialog.tsx @@ -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 ( + Change Device Name diff --git a/src/components/Dialog/ImportDialog.tsx b/src/components/Dialog/ImportDialog.tsx index 28285a7f..805d2f3e 100644 --- a/src/components/Dialog/ImportDialog.tsx +++ b/src/components/Dialog/ImportDialog.tsx @@ -3,6 +3,7 @@ import { Button } from "@components/UI/Button.tsx"; import { Checkbox } from "../UI/Checkbox/index.tsx"; import { Dialog, + DialogClose, DialogContent, DialogDescription, DialogFooter, @@ -96,6 +97,7 @@ export const ImportDialog = ({ return ( + Import Channel Set diff --git a/src/components/Dialog/LocationResponseDialog.tsx b/src/components/Dialog/LocationResponseDialog.tsx index ce6766ca..c4df3761 100644 --- a/src/components/Dialog/LocationResponseDialog.tsx +++ b/src/components/Dialog/LocationResponseDialog.tsx @@ -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 ( + {`Location: ${longName} (${shortName})`} @@ -41,9 +43,8 @@ export const LocationResponseDialog = ({ Coordinates:{" "} diff --git a/src/components/Dialog/NewDeviceDialog.tsx b/src/components/Dialog/NewDeviceDialog.tsx index 192c51a6..8e755e4d 100644 --- a/src/components/Dialog/NewDeviceDialog.tsx +++ b/src/components/Dialog/NewDeviceDialog.tsx @@ -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 ( + Connect New Device diff --git a/src/components/Dialog/NodeDetailsDialog.tsx b/src/components/Dialog/NodeDetailsDialog.tsx index c84af0b2..2f90bae2 100644 --- a/src/components/Dialog/NodeDetailsDialog.tsx +++ b/src/components/Dialog/NodeDetailsDialog.tsx @@ -8,6 +8,7 @@ import { } from "../UI/Accordion.tsx"; import { Dialog, + DialogClose, DialogContent, DialogFooter, DialogHeader, @@ -36,6 +37,7 @@ export const NodeDetailsDialog = ({ ? ( + Node Details for {device.user?.longName ?? "UNKNOWN"} ( @@ -85,11 +87,9 @@ export const NodeDetailsDialog = ({ Coordinates:{" "} @@ -173,7 +173,7 @@ export const NodeDetailsDialog = ({
-                        {JSON.stringify(device, null, 2)}
+                            {JSON.stringify(device, null, 2)}
                           
diff --git a/src/components/Dialog/NodeOptionsDialog.tsx b/src/components/Dialog/NodeOptionsDialog.tsx index 34603f6f..a152d74e 100644 --- a/src/components/Dialog/NodeOptionsDialog.tsx +++ b/src/components/Dialog/NodeOptionsDialog.tsx @@ -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 ( + {`${longName} (${shortName})`} diff --git a/src/components/Dialog/PKIBackupDialog.tsx b/src/components/Dialog/PKIBackupDialog.tsx index 7304ba03..3cd16727 100644 --- a/src/components/Dialog/PKIBackupDialog.tsx +++ b/src/components/Dialog/PKIBackupDialog.tsx @@ -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 ( + Backup Keys diff --git a/src/components/Dialog/PkiRegenerateDialog.tsx b/src/components/Dialog/PkiRegenerateDialog.tsx index e36b2048..c4f9e91e 100644 --- a/src/components/Dialog/PkiRegenerateDialog.tsx +++ b/src/components/Dialog/PkiRegenerateDialog.tsx @@ -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 ( + Regenerate Key pair? diff --git a/src/components/Dialog/QRDialog.tsx b/src/components/Dialog/QRDialog.tsx index c434c918..c4eecfe4 100644 --- a/src/components/Dialog/QRDialog.tsx +++ b/src/components/Dialog/QRDialog.tsx @@ -2,6 +2,7 @@ import { create, toBinary } from "@bufbuild/protobuf"; import { Checkbox } from "../UI/Checkbox/index.tsx"; import { Dialog, + DialogClose, DialogContent, DialogDescription, DialogFooter, @@ -62,6 +63,7 @@ export const QRDialog = ({ return ( + Generate QR Code @@ -107,8 +109,8 @@ export const QRDialog = ({