26 changed files with 669 additions and 147 deletions
@ -0,0 +1,43 @@ |
|||
# Mocks Directory |
|||
|
|||
This directory contains mock implementations used by Vitest for testing. |
|||
|
|||
## Structure |
|||
|
|||
The directory structure mirrors the actual project structure to make mocking |
|||
more intuitive: |
|||
|
|||
``` |
|||
__mocks__/ |
|||
├── components/ |
|||
│ └── UI/ |
|||
│ ├── Dialog.tsx |
|||
│ ├── Button.tsx |
|||
│ ├── Checkbox.tsx |
|||
│ └── ... |
|||
├── core/ |
|||
│ └── ... |
|||
└── ... |
|||
``` |
|||
|
|||
## Auto-mocking |
|||
|
|||
Vitest will automatically use the mock files in this directory when the |
|||
corresponding module is imported in tests. For example, when a test imports |
|||
`@components/UI/Dialog.tsx`, Vitest will use |
|||
`__mocks__/components/UI/Dialog.tsx` instead. |
|||
|
|||
## Creating New Mocks |
|||
|
|||
To create a new mock: |
|||
|
|||
1. Create a file in the same relative path as the original module |
|||
2. Export the mocked functionality with the same names as the original |
|||
3. Add a `vi.mock()` statement to `vitest.setup.ts` if needed |
|||
|
|||
## Mock Guidelines |
|||
|
|||
- Keep mocks as simple as possible |
|||
- Use `data-testid` attributes for easy querying in tests |
|||
- Implement just enough functionality to test the component |
|||
- Use TypeScript types to ensure compatibility with the original module |
|||
@ -0,0 +1,20 @@ |
|||
import { vi } from 'vitest' |
|||
|
|||
vi.mock('@components/UI/Button.tsx', () => ({ |
|||
Button: ({ children, name, disabled, onClick }: { |
|||
children: React.ReactNode, |
|||
variant: string, |
|||
name: string, |
|||
disabled?: boolean, |
|||
onClick: () => void |
|||
}) => |
|||
<button |
|||
type="button" |
|||
name={name} |
|||
data-testid={`button-${name}`} |
|||
disabled={disabled} |
|||
onClick={onClick} |
|||
> |
|||
{children} |
|||
</button> |
|||
})); |
|||
@ -0,0 +1,6 @@ |
|||
import { vi } from 'vitest' |
|||
|
|||
vi.mock('@components/UI/Checkbox.tsx', () => ({ |
|||
Checkbox: ({ id, checked, onChange }: { id: string, checked: boolean, onChange: () => void }) => |
|||
<input data-testid="checkbox" type="checkbox" id={id} checked={checked} onChange={onChange} /> |
|||
})); |
|||
@ -0,0 +1,43 @@ |
|||
import React from 'react'; |
|||
|
|||
export const Dialog = ({ children, open }: { |
|||
children: React.ReactNode, |
|||
open: boolean, |
|||
onOpenChange?: (open: boolean) => void |
|||
}) => open ? <div data-testid="dialog">{children}</div> : null; |
|||
|
|||
export const DialogContent = ({ |
|||
children, |
|||
className |
|||
}: { |
|||
children: React.ReactNode, |
|||
className?: string |
|||
}) => <div data-testid="dialog-content" className={className}>{children}</div>; |
|||
|
|||
export const DialogHeader = ({ |
|||
children |
|||
}: { |
|||
children: React.ReactNode |
|||
}) => <div data-testid="dialog-header">{children}</div>; |
|||
|
|||
export const DialogTitle = ({ |
|||
children |
|||
}: { |
|||
children: React.ReactNode |
|||
}) => <div data-testid="dialog-title">{children}</div>; |
|||
|
|||
export const DialogDescription = ({ |
|||
children, |
|||
className |
|||
}: { |
|||
children: React.ReactNode, |
|||
className?: string |
|||
}) => <div data-testid="dialog-description" className={className}>{children}</div>; |
|||
|
|||
export const DialogFooter = ({ |
|||
children, |
|||
className |
|||
}: { |
|||
children: React.ReactNode, |
|||
className?: string |
|||
}) => <div data-testid="dialog-footer" className={className}>{children}</div>; |
|||
@ -0,0 +1,6 @@ |
|||
import { vi } from 'vitest' |
|||
|
|||
vi.mock('@components/UI/Label.tsx', () => ({ |
|||
Label: ({ children, htmlFor, className }: { children: React.ReactNode, htmlFor: string, className?: string }) => |
|||
<label data-testid="label" htmlFor={htmlFor} className={className}>{children}</label> |
|||
})); |
|||
@ -0,0 +1,7 @@ |
|||
import { vi } from "vitest"; |
|||
|
|||
vi.mock('@components/UI/Typography/Link.tsx', () => ({ |
|||
Link: ({ children, href, className }: { children: React.ReactNode, href: string, className?: string }) => |
|||
<a data-testid="link" href={href} className={className}>{children}</a> |
|||
})); |
|||
|
|||
@ -0,0 +1,51 @@ |
|||
import { |
|||
Dialog, |
|||
DialogContent, |
|||
DialogDescription, |
|||
DialogFooter, |
|||
DialogHeader, |
|||
DialogTitle, |
|||
} from "@components/UI/Dialog.tsx"; |
|||
import { Link } from "@components/UI/Typography/Link.tsx"; |
|||
import { Checkbox } from "../../UI/Checkbox/index.tsx"; |
|||
import { Label } from "@components/UI/Label.tsx"; |
|||
import { Button } from "@components/UI/Button.tsx"; |
|||
import { useUnsafeRoles } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRoles.ts"; |
|||
|
|||
export interface RouterRoleDialogProps { |
|||
open: boolean; |
|||
onOpenChange: (open: boolean) => void; |
|||
} |
|||
|
|||
export const UnsafeRolesDialog = ({ open, onOpenChange }: RouterRoleDialogProps) => { |
|||
const { getConfirmState, toggleConfirmState, handleCloseDialog } = useUnsafeRoles(); |
|||
|
|||
const deivceRoleLink = "https://meshtastic.org/docs/configuration/radio/device/"; |
|||
const choosingTheRightDeviceRoleLink = "https://meshtastic.org/blog/choosing-the-right-device-role/"; |
|||
return ( |
|||
<Dialog open={open} onOpenChange={onOpenChange}> |
|||
<DialogContent className="max-w-8 flex flex-col"> |
|||
<DialogHeader> |
|||
<DialogTitle>Are you sure?</DialogTitle> |
|||
</DialogHeader> |
|||
<DialogDescription className="text-md"> |
|||
I have read the <Link href={deivceRoleLink} 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={getConfirmState()} onChange={toggleConfirmState}> |
|||
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={!getConfirmState()} onClick={() => handleCloseDialog("confirm")}> |
|||
Confirm |
|||
</Button> |
|||
</DialogFooter> |
|||
</DialogContent> |
|||
</Dialog> |
|||
); |
|||
}; |
|||
@ -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 |
|||
}; |
|||
}; |
|||
@ -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,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> |
|||
); |
|||
} |
|||
@ -0,0 +1,179 @@ |
|||
// taken from https://react-hooked.vercel.app/docs/useLocalStorage/
|
|||
|
|||
import { useCallback, useEffect, useState } from "react"; |
|||
|
|||
import type { Dispatch, SetStateAction } from "react"; |
|||
|
|||
declare global { |
|||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
|||
interface WindowEventMap { |
|||
"local-storage": CustomEvent; |
|||
} |
|||
} |
|||
|
|||
type UseLocalStorageOptions<T> = { |
|||
serializer?: (value: T) => string; |
|||
deserializer?: (value: string) => T; |
|||
initializeWithValue?: boolean; |
|||
}; |
|||
|
|||
const IS_SERVER = typeof window === "undefined"; |
|||
|
|||
/** |
|||
* Hook for persisting state to localStorage. |
|||
* |
|||
* @param {string} key - The key to use for localStorage. |
|||
* @param {T | (() => T)} initialValue - The initial value to use, if not found in localStorage. |
|||
* @param {UseLocalStorageOptions<T>} options - Options for the hook. |
|||
* @returns A tuple of [storedValue, setValue, removeValue]. |
|||
*/ |
|||
export default function useLocalStorage<T>( |
|||
key: string, |
|||
initialValue: T | (() => T), |
|||
options: UseLocalStorageOptions<T> = {}, |
|||
): [T, Dispatch<SetStateAction<T>>, () => void] { |
|||
const { initializeWithValue = true } = options; |
|||
|
|||
const serializer = useCallback<(value: T) => string>( |
|||
(value) => { |
|||
if (options.serializer) { |
|||
return options.serializer(value); |
|||
} |
|||
|
|||
return JSON.stringify(value); |
|||
}, |
|||
[options], |
|||
); |
|||
|
|||
const deserializer = useCallback<(value: string) => T>( |
|||
(value) => { |
|||
if (options.deserializer) { |
|||
return options.deserializer(value); |
|||
} |
|||
// Support 'undefined' as a value
|
|||
if (value === "undefined") { |
|||
return undefined as unknown as T; |
|||
} |
|||
|
|||
const defaultValue = |
|||
initialValue instanceof Function ? initialValue() : initialValue; |
|||
|
|||
let parsed: unknown; |
|||
try { |
|||
parsed = JSON.parse(value); |
|||
} catch (error) { |
|||
console.error("Error parsing JSON:", error); |
|||
return defaultValue; // Return initialValue if parsing fails
|
|||
} |
|||
|
|||
return parsed as T; |
|||
}, |
|||
[options, initialValue], |
|||
); |
|||
|
|||
// Get from local storage then
|
|||
// parse stored json or return initialValue
|
|||
const readValue = useCallback((): T => { |
|||
const initialValueToUse = |
|||
initialValue instanceof Function ? initialValue() : initialValue; |
|||
|
|||
// Prevent build error "window is undefined" but keep working
|
|||
if (IS_SERVER) { |
|||
return initialValueToUse; |
|||
} |
|||
|
|||
try { |
|||
const raw = window.localStorage.getItem(key); |
|||
return raw ? deserializer(raw) : initialValueToUse; |
|||
} catch (error) { |
|||
console.warn(`Error reading localStorage key “${key}”:`, error); |
|||
return initialValueToUse; |
|||
} |
|||
}, [initialValue, key, deserializer]); |
|||
|
|||
const [storedValue, setStoredValue] = useState(() => { |
|||
if (initializeWithValue) { |
|||
return readValue(); |
|||
} |
|||
|
|||
return initialValue instanceof Function ? initialValue() : initialValue; |
|||
}); |
|||
|
|||
// Return a wrapped version of useState's setter function that ...
|
|||
// ... persists the new value to localStorage.
|
|||
const setValue: Dispatch<SetStateAction<T>> = useCallback( |
|||
(value) => { |
|||
// Prevent build error "window is undefined" but keeps working
|
|||
if (IS_SERVER) { |
|||
console.warn( |
|||
`Tried setting localStorage key “${key}” even though environment is not a client`, |
|||
); |
|||
} |
|||
|
|||
try { |
|||
// Allow value to be a function so we have the same API as useState
|
|||
const newValue = value instanceof Function ? value(readValue()) : value; |
|||
|
|||
// Save to local storage
|
|||
window.localStorage.setItem(key, serializer(newValue)); |
|||
|
|||
// Save state
|
|||
setStoredValue(newValue); |
|||
|
|||
// We dispatch a custom event so every similar useLocalStorage hook is notified
|
|||
window.dispatchEvent(new StorageEvent("local-storage", { key })); |
|||
} catch (error) { |
|||
console.warn(`Error setting localStorage key “${key}”:`, error); |
|||
} |
|||
}, |
|||
[key, serializer, readValue], |
|||
); |
|||
|
|||
const removeValue = useCallback(() => { |
|||
// Prevent build error "window is undefined" but keeps working
|
|||
if (IS_SERVER) { |
|||
console.warn( |
|||
`Tried removing localStorage key “${key}” even though environment is not a client`, |
|||
); |
|||
} |
|||
|
|||
const defaultValue = |
|||
initialValue instanceof Function ? initialValue() : initialValue; |
|||
|
|||
// Remove the key from local storage
|
|||
window.localStorage.removeItem(key); |
|||
|
|||
// Save state with default value
|
|||
setStoredValue(defaultValue); |
|||
|
|||
// We dispatch a custom event so every similar useLocalStorage hook is notified
|
|||
window.dispatchEvent(new StorageEvent("local-storage", { key })); |
|||
}, [key]); |
|||
|
|||
useEffect(() => { |
|||
setStoredValue(readValue()); |
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|||
}, [key]); |
|||
|
|||
const handleStorageChange = useCallback( |
|||
(event: StorageEvent | CustomEvent) => { |
|||
if ((event as StorageEvent).key && (event as StorageEvent).key !== key) { |
|||
return; |
|||
} |
|||
setStoredValue(readValue()); |
|||
}, |
|||
[key, readValue], |
|||
); |
|||
|
|||
useEffect(() => { |
|||
addEventListener("storage", handleStorageChange); |
|||
// this is a custom event, triggered in writeValueToLocalStorage
|
|||
addEventListener("local-storage", handleStorageChange); |
|||
return () => { |
|||
removeEventListener("storage", handleStorageChange); |
|||
removeEventListener("local-storage", handleStorageChange); |
|||
}; |
|||
}, []); |
|||
|
|||
return [storedValue, setValue, removeValue]; |
|||
} |
|||
Loading…
Reference in new issue