feat: added are you sure dialog
This commit is contained in:
@@ -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>
|
||||||
|
}));
|
||||||
|
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { RemoveNodeDialog } from "@components/Dialog/RemoveNodeDialog.tsx";
|
import { RemoveNodeDialog } from "@components/Dialog/RemoveNodeDialog.tsx";
|
||||||
import { DeviceNameDialog } from "@components/Dialog/DeviceNameDialog.tsx";
|
import { DeviceNameDialog } from "@components/Dialog/DeviceNameDialog.tsx";
|
||||||
import { ImportDialog } from "@components/Dialog/ImportDialog.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 { QRDialog } from "@components/Dialog/QRDialog.tsx";
|
||||||
import { RebootDialog } from "@components/Dialog/RebootDialog.tsx";
|
import { RebootDialog } from "@components/Dialog/RebootDialog.tsx";
|
||||||
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx";
|
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog.tsx";
|
||||||
|
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
|
||||||
import { NodeDetailsDialog } from "./NodeDetailsDialog.tsx";
|
|
||||||
|
|
||||||
export const DialogManager = () => {
|
export const DialogManager = () => {
|
||||||
const { channels, config, dialog, setDialogOpen } = useDevice();
|
const { channels, config, dialog, setDialogOpen } = useDevice();
|
||||||
@@ -64,6 +64,12 @@ export const DialogManager = () => {
|
|||||||
setDialogOpen("nodeDetails", open);
|
setDialogOpen("nodeDetails", open);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<UnsafeRolesDialog
|
||||||
|
open={dialog.unsafeRoles}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setDialogOpen("unsafeRoles", open);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { create, fromBinary } from "@bufbuild/protobuf";
|
import { create, fromBinary } from "@bufbuild/protobuf";
|
||||||
import { Button } from "@components/UI/Button.tsx";
|
import { Button } from "@components/UI/Button.tsx";
|
||||||
import { Checkbox } from "@components/UI/Checkbox.tsx";
|
import { Checkbox } from "../UI/Checkbox/index.tsx";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create, toBinary } from "@bufbuild/protobuf";
|
import { create, toBinary } from "@bufbuild/protobuf";
|
||||||
import { Checkbox } from "@components/UI/Checkbox.tsx";
|
import { Checkbox } from "../UI/Checkbox/index.tsx";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -106,8 +106,7 @@ export const QRDialog = ({
|
|||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`border-slate-900 border-t border-l border-b rounded-l h-10 px-7 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${
|
className={`border-slate-900 border-t border-l border-b rounded-l h-10 px-7 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${qrCodeAdd
|
||||||
qrCodeAdd
|
|
||||||
? "focus:ring-green-800 bg-green-800 text-white"
|
? "focus:ring-green-800 bg-green-800 text-white"
|
||||||
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
|
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
|
||||||
}`}
|
}`}
|
||||||
@@ -117,8 +116,7 @@ export const QRDialog = ({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`border-slate-900 border-t border-r border-b rounded-r h-10 px-4 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${
|
className={`border-slate-900 border-t border-r border-b rounded-r h-10 px-4 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${!qrCodeAdd
|
||||||
!qrCodeAdd
|
|
||||||
? "focus:ring-green-800 bg-green-800 text-white"
|
? "focus:ring-green-800 bg-green-800 text-white"
|
||||||
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
|
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -19,21 +19,7 @@ export interface MultiSelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MultiSelectInput<T extends FieldValues>({
|
const formatEnumDisplay = (name: string): string => {
|
||||||
field,
|
|
||||||
}: GenericFormElementProps<T, MultiSelectFieldProps<T>>) {
|
|
||||||
const { enumValue, formatEnumName, ...remainingProperties } =
|
|
||||||
field.properties;
|
|
||||||
|
|
||||||
// Make sure to filter out the UNSET value, as it shouldn't be shown in the UI
|
|
||||||
const optionsEnumValues = enumValue
|
|
||||||
? Object.entries(enumValue)
|
|
||||||
.filter((value) => typeof value[1] === "number")
|
|
||||||
.filter((value) => value[0] !== "UNSET")
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const formatName = (name: string) => {
|
|
||||||
if (!formatEnumName) return name;
|
|
||||||
return name
|
return name
|
||||||
.replace(/_/g, " ")
|
.replace(/_/g, " ")
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -42,6 +28,24 @@ export function MultiSelectInput<T extends FieldValues>({
|
|||||||
.join(" ");
|
.join(" ");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function MultiSelectInput<T extends FieldValues>({
|
||||||
|
field,
|
||||||
|
}: GenericFormElementProps<T, MultiSelectFieldProps<T>>) {
|
||||||
|
const { enumValue, formatEnumName, ...remainingProperties } =
|
||||||
|
field.properties;
|
||||||
|
|
||||||
|
const valueToKeyMap: Record<string, string> = {};
|
||||||
|
const optionsEnumValues: [string, number][] = [];
|
||||||
|
|
||||||
|
if (enumValue) {
|
||||||
|
Object.entries(enumValue).forEach(([key, val]) => {
|
||||||
|
if (typeof val === "number" && key !== "UNSET") {
|
||||||
|
valueToKeyMap[val.toString()] = key;
|
||||||
|
optionsEnumValues.push([key, val as number]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MultiSelect {...remainingProperties}>
|
<MultiSelect {...remainingProperties}>
|
||||||
{optionsEnumValues.map(([name, value]) => (
|
{optionsEnumValues.map(([name, value]) => (
|
||||||
@@ -52,7 +56,7 @@ export function MultiSelectInput<T extends FieldValues>({
|
|||||||
checked={field.isChecked(name)}
|
checked={field.isChecked(name)}
|
||||||
onCheckedChange={() => field.onValueChange(name)}
|
onCheckedChange={() => field.onValueChange(name)}
|
||||||
>
|
>
|
||||||
{formatEnumName ? formatName(name) : name}
|
{formatEnumName ? formatEnumDisplay(name) : name}
|
||||||
</MultiSelectItem>
|
</MultiSelectItem>
|
||||||
))}
|
))}
|
||||||
</MultiSelect>
|
</MultiSelect>
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@components/UI/Select.tsx";
|
} from "@components/UI/Select.tsx";
|
||||||
import { Controller, type FieldValues } from "react-hook-form";
|
import { useController, type FieldValues } from "react-hook-form";
|
||||||
|
|
||||||
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||||
type: "select";
|
type: "select";
|
||||||
selectChange?: (e: string) => void;
|
selectChange?: (e: string, name: string) => void;
|
||||||
|
onBeforeChange?: (newValue: string, prevValue: string) => Promise<string | false>;
|
||||||
properties: BaseFormBuilderProps<T>["properties"] & {
|
properties: BaseFormBuilderProps<T>["properties"] & {
|
||||||
enumValue: {
|
enumValue: {
|
||||||
[s: string]: string | number;
|
[s: string]: string | number;
|
||||||
@@ -22,29 +23,70 @@ export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const formatEnumDisplay = (name: string): string => {
|
||||||
|
return name
|
||||||
|
.replace(/_/g, " ")
|
||||||
|
.toLowerCase()
|
||||||
|
.split(" ")
|
||||||
|
.map((s) => s.charAt(0).toUpperCase() + s.substring(1))
|
||||||
|
.join(" ");
|
||||||
|
};
|
||||||
|
|
||||||
export function SelectInput<T extends FieldValues>({
|
export function SelectInput<T extends FieldValues>({
|
||||||
control,
|
control,
|
||||||
disabled,
|
disabled,
|
||||||
field,
|
field,
|
||||||
}: GenericFormElementProps<T, SelectFieldProps<T>>) {
|
}: GenericFormElementProps<T, SelectFieldProps<T>>) {
|
||||||
return (
|
const {
|
||||||
<Controller
|
field: { value, onChange, ...rest },
|
||||||
name={field.name}
|
} = useController({
|
||||||
control={control}
|
name: field.name,
|
||||||
render={({ field: { value, onChange, ...rest } }) => {
|
control,
|
||||||
const { enumValue, formatEnumName, ...remainingProperties } =
|
});
|
||||||
field.properties;
|
|
||||||
const optionsEnumValues = enumValue
|
const { enumValue, formatEnumName, ...remainingProperties } = field.properties;
|
||||||
? Object.entries(enumValue).filter(
|
const valueToKeyMap: Record<string, string> = {};
|
||||||
(value) => typeof value[1] === "number",
|
const keyToValueMap: Record<string, number> = {};
|
||||||
)
|
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 (
|
return (
|
||||||
<Select
|
<Select
|
||||||
onValueChange={(e) => {
|
onValueChange={handleValueChange}
|
||||||
if (field.selectChange) field.selectChange(e);
|
|
||||||
onChange(Number.parseInt(e));
|
|
||||||
}}
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
value={value?.toString()}
|
value={value?.toString()}
|
||||||
{...remainingProperties}
|
{...remainingProperties}
|
||||||
@@ -54,24 +96,12 @@ export function SelectInput<T extends FieldValues>({
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{optionsEnumValues.map(([name, value]) => (
|
{optionsEnumValues.map(([name, val]) => (
|
||||||
<SelectItem key={name} value={value.toString()}>
|
<SelectItem key={name} value={val.toString()}>
|
||||||
{formatEnumName
|
{formatEnumName ? formatEnumDisplay(name) : name}
|
||||||
? name
|
|
||||||
.replace(/_/g, " ")
|
|
||||||
.toLowerCase()
|
|
||||||
.split(" ")
|
|
||||||
.map((s) =>
|
|
||||||
s.charAt(0).toUpperCase() + s.substring(1)
|
|
||||||
)
|
|
||||||
.join(" ")
|
|
||||||
: name}
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
);
|
);
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
|
|||||||
{
|
{
|
||||||
type: "passwordGenerator",
|
type: "passwordGenerator",
|
||||||
name: "settings.psk",
|
name: "settings.psk",
|
||||||
|
id: 'channel-psk',
|
||||||
label: "Pre-Shared Key",
|
label: "Pre-Shared Key",
|
||||||
description:
|
description:
|
||||||
"Supported PSK lengths: 256-bit, 128-bit, 8-bit, Empty (0-bit)",
|
"Supported PSK lengths: 256-bit, 128-bit, 8-bit, Empty (0-bit)",
|
||||||
|
|||||||
+26
-19
@@ -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 { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
|
import { useUnsafeRoles } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRoles.ts";
|
||||||
|
|
||||||
export const Device = () => {
|
export const Device = () => {
|
||||||
const { config, setWorkingConfig } = useDevice();
|
|
||||||
|
const { config, setWorkingConfig, setDialogOpen } = useDevice();
|
||||||
|
const { agreedToUnSafeRoles } = useUnsafeRoles();
|
||||||
|
|
||||||
const onSubmit = (data: DeviceValidation) => {
|
const onSubmit = (data: DeviceValidation) => {
|
||||||
setWorkingConfig(
|
setWorkingConfig(
|
||||||
create(Protobuf.Config.ConfigSchema, {
|
create(Protobuf.Config.ConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
case: "device",
|
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 (
|
return (
|
||||||
<DynamicForm<DeviceValidation>
|
<DynamicForm<DeviceValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
@@ -32,23 +53,9 @@ export const Device = () => {
|
|||||||
name: "role",
|
name: "role",
|
||||||
label: "Role",
|
label: "Role",
|
||||||
description: "What role the device performs on the mesh",
|
description: "What role the device performs on the mesh",
|
||||||
|
onBeforeChange: handleOnBeforeChange,
|
||||||
properties: {
|
properties: {
|
||||||
enumValue: {
|
enumValue: Protobuf.Config.Config_DeviceConfig_Role,
|
||||||
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,
|
|
||||||
},
|
|
||||||
formatEnumName: true,
|
formatEnumName: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -12,7 +12,7 @@ import { useCallback } from "react";
|
|||||||
export const Position = () => {
|
export const Position = () => {
|
||||||
const { config, setWorkingConfig } = useDevice();
|
const { config, setWorkingConfig } = useDevice();
|
||||||
const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags(
|
const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags(
|
||||||
config.position.positionFlags ?? 0,
|
config?.position.positionFlags ?? 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
const onSubmit = (data: PositionValidation) => {
|
const onSubmit = (data: PositionValidation) => {
|
||||||
|
|||||||
@@ -44,12 +44,16 @@ export interface ButtonProps
|
|||||||
VariantProps<typeof buttonVariants> { }
|
VariantProps<typeof buttonVariants> { }
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
({ className, variant, size, ...props }, ref) => {
|
({ className, variant, size, disabled, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(
|
||||||
|
buttonVariants({ variant, size, className }),
|
||||||
|
{ "cursor-not-allowed": disabled }
|
||||||
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
disabled={disabled}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import newGithubIssueUrl from "../../core/utils/github.ts";
|
import newGithubIssueUrl from "@core/utils/github.ts";
|
||||||
import { ExternalLink } from "lucide-react";
|
import { ExternalLink } from "lucide-react";
|
||||||
import { Heading } from "./Typography/Heading.tsx";
|
import { Heading } from "@components/UI/Typography/Heading.tsx";
|
||||||
import { Link } from "./Typography/Link.tsx";
|
import { Link } from "@components/UI/Typography/Link.tsx";
|
||||||
import { P } from "./Typography/P.tsx";
|
import { P } from "@components/UI/Typography/P.tsx";
|
||||||
|
|
||||||
|
|
||||||
export function ErrorPage({ error }: { error: Error }) {
|
export function ErrorPage({ error }: { error: Error }) {
|
||||||
@@ -11,8 +11,8 @@ export function ErrorPage({ error }: { error: Error }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="w-full overflow-y-auto">
|
<article className="w-full h-full overflow-y-auto dark:bg-background-primary dark:text-text-primary">
|
||||||
<section className="flex shrink md:flex-row gap-16 mt-20 px-4 md:px-8 text-lg md:text-xl space-y-2 place-items-center">
|
<section className="flex shrink md:flex-row gap-16 mt-20 px-4 md:px-8 text-lg md:text-xl space-y-2 place-items-center dark:bg-background-primary text-slate-900 dark:text-text-primary">
|
||||||
<div>
|
<div>
|
||||||
<Heading as="h2" className="text-text-primary">
|
<Heading as="h2" className="text-text-primary">
|
||||||
This is a little embarrassing...
|
This is a little embarrassing...
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export const Link = ({ href, children, className }: LinkProps) => (
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-medium text-slate-900 underline underline-offset-4 dark:text-slate-50",
|
"font-medium text-slate-900 underline underline-offset-4 dark:text-slate-900",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -26,7 +26,8 @@ export type DialogVariant =
|
|||||||
| "deviceName"
|
| "deviceName"
|
||||||
| "nodeRemoval"
|
| "nodeRemoval"
|
||||||
| "pkiBackup"
|
| "pkiBackup"
|
||||||
| "nodeDetails";
|
| "nodeDetails"
|
||||||
|
| "unsafeRoles";
|
||||||
|
|
||||||
export interface Device {
|
export interface Device {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -63,6 +64,7 @@ export interface Device {
|
|||||||
nodeRemoval: boolean;
|
nodeRemoval: boolean;
|
||||||
pkiBackup: boolean;
|
pkiBackup: boolean;
|
||||||
nodeDetails: boolean;
|
nodeDetails: boolean;
|
||||||
|
unsafeRoles: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
setStatus: (status: Types.DeviceStatusEnum) => void;
|
setStatus: (status: Types.DeviceStatusEnum) => void;
|
||||||
@@ -146,6 +148,7 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
|||||||
nodeRemoval: false,
|
nodeRemoval: false,
|
||||||
pkiBackup: false,
|
pkiBackup: false,
|
||||||
nodeDetails: false,
|
nodeDetails: false,
|
||||||
|
unsafeRoles: false,
|
||||||
},
|
},
|
||||||
pendingSettingsChanges: false,
|
pendingSettingsChanges: false,
|
||||||
messageDraft: "",
|
messageDraft: "",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Bluetooth } from "@components/PageComponents/Config/Bluetooth.tsx";
|
import { Bluetooth } from "@components/PageComponents/Config/Bluetooth.tsx";
|
||||||
import { Device } from "@components/PageComponents/Config/Device.tsx";
|
import { Device } from "../../components/PageComponents/Config/Device/index.tsx";
|
||||||
import { Display } from "@components/PageComponents/Config/Display.tsx";
|
import { Display } from "@components/PageComponents/Config/Display.tsx";
|
||||||
import { LoRa } from "@components/PageComponents/Config/LoRa.tsx";
|
import { LoRa } from "@components/PageComponents/Config/LoRa.tsx";
|
||||||
import { Network } from "@components/PageComponents/Config/Network.tsx";
|
import { Network } from "@components/PageComponents/Config/Network.tsx";
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
|
import { vi } from 'vitest';
|
||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
|
|
||||||
|
// Enable auto mocks for our UI components
|
||||||
|
//vi.mock('@components/UI/Dialog.tsx');
|
||||||
|
//vi.mock('@components/UI/Typography/Link.tsx');
|
||||||
|
|
||||||
globalThis.ResizeObserver = class {
|
globalThis.ResizeObserver = class {
|
||||||
observe() { }
|
observe() { }
|
||||||
unobserve() { }
|
unobserve() { }
|
||||||
|
|||||||
+7
-3
@@ -1,8 +1,9 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vitest/config';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import { VitePWA } from 'vite-plugin-pwa';
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
import path from 'node:path';
|
|
||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
|
import process from "node:process";
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
let hash = '';
|
let hash = '';
|
||||||
try {
|
try {
|
||||||
@@ -32,7 +33,6 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
// Using Node's path and process.cwd() instead of Deno.cwd()
|
|
||||||
'@app': path.resolve(process.cwd(), './src'),
|
'@app': path.resolve(process.cwd(), './src'),
|
||||||
'@pages': path.resolve(process.cwd(), './src/pages'),
|
'@pages': path.resolve(process.cwd(), './src/pages'),
|
||||||
'@components': path.resolve(process.cwd(), './src/components'),
|
'@components': path.resolve(process.cwd(), './src/components'),
|
||||||
@@ -49,6 +49,10 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
globals: true,
|
globals: true,
|
||||||
|
mockReset: true,
|
||||||
|
clearMocks: true,
|
||||||
|
restoreMocks: true,
|
||||||
|
root: path.resolve(process.cwd(), './src'),
|
||||||
include: ['**/*.{test,spec}.{ts,tsx}'],
|
include: ['**/*.{test,spec}.{ts,tsx}'],
|
||||||
setupFiles: ["./src/tests/setupTests.ts"],
|
setupFiles: ["./src/tests/setupTests.ts"],
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user