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