chore: lint/format all files (#604)
* chore: lint/format all files * Fix config sidebar button state (#602) * chore: Update deno.lock version and add Radix UI slider component (#601) * fix: improve how table addresses even/odd rows --------- Co-authored-by: philon- <philon-@users.noreply.github.com> Co-authored-by: Kamil Dzieniszewski <kamil.dzieniszewski@gmail.com>
This commit is contained in:
co-authored by
philon-
Kamil Dzieniszewski
parent
b7bdb1a502
commit
480ca46a95
+23
-19
@@ -16,7 +16,6 @@ import { CommandPalette } from "@components/CommandPalette/index.tsx";
|
||||
import { SidebarProvider } from "@core/stores/sidebarStore.tsx";
|
||||
import { useTheme } from "@core/hooks/useTheme.ts";
|
||||
|
||||
|
||||
export const App = (): JSX.Element => {
|
||||
const { getDevice } = useDeviceStore();
|
||||
const { selectedDevice, setConnectDialogOpen, connectDialogOpen } =
|
||||
@@ -25,7 +24,7 @@ export const App = (): JSX.Element => {
|
||||
const device = getDevice(selectedDevice);
|
||||
|
||||
// Sets up light/dark mode based on user preferences or system settings
|
||||
useTheme()
|
||||
useTheme();
|
||||
|
||||
return (
|
||||
<ErrorBoundary FallbackComponent={ErrorPage}>
|
||||
@@ -37,28 +36,33 @@ export const App = (): JSX.Element => {
|
||||
/>
|
||||
<Toaster />
|
||||
<DeviceWrapper device={device}>
|
||||
<div className="flex h-screen flex-col bg-background-primary text-text-primary" style={{ scrollbarWidth: 'thin' }}>
|
||||
<div
|
||||
className="flex h-screen flex-col bg-background-primary text-text-primary"
|
||||
style={{ scrollbarWidth: "thin" }}
|
||||
>
|
||||
<SidebarProvider>
|
||||
<div className="h-full flex flex-col">
|
||||
{device ? (
|
||||
<div className="h-full flex w-full">
|
||||
<DialogManager />
|
||||
<KeyBackupReminder />
|
||||
<CommandPalette />
|
||||
<MapProvider>
|
||||
<PageRouter />
|
||||
</MapProvider>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Dashboard />
|
||||
<Footer />
|
||||
</>
|
||||
)}
|
||||
{device
|
||||
? (
|
||||
<div className="h-full flex w-full">
|
||||
<DialogManager />
|
||||
<KeyBackupReminder />
|
||||
<CommandPalette />
|
||||
<MapProvider>
|
||||
<PageRouter />
|
||||
</MapProvider>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<Dashboard />
|
||||
<Footer />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
</DeviceWrapper>
|
||||
</ErrorBoundary >
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { vi } from 'vitest'
|
||||
import { vi } from "vitest";
|
||||
|
||||
vi.mock('@components/UI/Button.tsx', () => ({
|
||||
vi.mock("@components/UI/Button.tsx", () => ({
|
||||
Button: ({ children, name, disabled, onClick }: {
|
||||
children: React.ReactNode,
|
||||
variant: string,
|
||||
name: string,
|
||||
disabled?: boolean,
|
||||
onClick: () => void
|
||||
}) =>
|
||||
children: React.ReactNode;
|
||||
variant: string;
|
||||
name: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
name={name}
|
||||
@@ -17,4 +17,5 @@ vi.mock('@components/UI/Button.tsx', () => ({
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
}));
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import { vi } from 'vitest'
|
||||
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} />
|
||||
}));
|
||||
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}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -1,43 +1,45 @@
|
||||
import React from 'react';
|
||||
import React from "react";
|
||||
|
||||
export const Dialog = ({ children, open }: {
|
||||
children: React.ReactNode,
|
||||
open: boolean,
|
||||
onOpenChange?: (open: boolean) => void
|
||||
children: React.ReactNode;
|
||||
open: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) => open ? <div data-testid="dialog">{children}</div> : null;
|
||||
|
||||
export const DialogContent = ({
|
||||
children,
|
||||
className
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode,
|
||||
className?: string
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => <div data-testid="dialog-content" className={className}>{children}</div>;
|
||||
|
||||
export const DialogHeader = ({
|
||||
children
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
children: React.ReactNode;
|
||||
}) => <div data-testid="dialog-header">{children}</div>;
|
||||
|
||||
export const DialogTitle = ({
|
||||
children
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
children: React.ReactNode;
|
||||
}) => <div data-testid="dialog-title">{children}</div>;
|
||||
|
||||
export const DialogDescription = ({
|
||||
children,
|
||||
className
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode,
|
||||
className?: string
|
||||
}) => <div data-testid="dialog-description" className={className}>{children}</div>;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => (
|
||||
<div data-testid="dialog-description" className={className}>{children}</div>
|
||||
);
|
||||
|
||||
export const DialogFooter = ({
|
||||
children,
|
||||
className
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode,
|
||||
className?: string
|
||||
}) => <div data-testid="dialog-footer" className={className}>{children}</div>;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => <div data-testid="dialog-footer" className={className}>{children}</div>;
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { vi } from 'vitest'
|
||||
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>
|
||||
}));
|
||||
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>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
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>
|
||||
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,10 +1,10 @@
|
||||
import React from 'react';
|
||||
import React from "react";
|
||||
import {
|
||||
PlugZapIcon,
|
||||
BatteryFullIcon,
|
||||
BatteryMediumIcon,
|
||||
BatteryLowIcon,
|
||||
} from 'lucide-react';
|
||||
BatteryMediumIcon,
|
||||
PlugZapIcon,
|
||||
} from "lucide-react";
|
||||
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
|
||||
|
||||
interface DeviceMetrics {
|
||||
@@ -25,50 +25,52 @@ interface BatteryStateConfig {
|
||||
|
||||
const batteryStates: BatteryStateConfig[] = [
|
||||
{
|
||||
condition: level => level > 100,
|
||||
condition: (level) => level > 100,
|
||||
Icon: PlugZapIcon,
|
||||
className: 'text-gray-500',
|
||||
text: () => 'Plugged in',
|
||||
className: "text-gray-500",
|
||||
text: () => "Plugged in",
|
||||
},
|
||||
{
|
||||
condition: level => level > 80,
|
||||
condition: (level) => level > 80,
|
||||
Icon: BatteryFullIcon,
|
||||
className: 'text-green-500',
|
||||
text: level => `${level}% charging`,
|
||||
className: "text-green-500",
|
||||
text: (level) => `${level}% charging`,
|
||||
},
|
||||
{
|
||||
condition: level => level > 20,
|
||||
condition: (level) => level > 20,
|
||||
Icon: BatteryMediumIcon,
|
||||
className: 'text-yellow-500',
|
||||
text: level => `${level}% charging`,
|
||||
className: "text-yellow-500",
|
||||
text: (level) => `${level}% charging`,
|
||||
},
|
||||
{
|
||||
condition: () => true,
|
||||
Icon: BatteryLowIcon,
|
||||
className: 'text-red-500',
|
||||
text: level => `${level}% charging`,
|
||||
className: "text-red-500",
|
||||
text: (level) => `${level}% charging`,
|
||||
},
|
||||
];
|
||||
|
||||
const getBatteryState = (level: number) => {
|
||||
return batteryStates.find(state => state.condition(level));
|
||||
return batteryStates.find((state) => state.condition(level));
|
||||
};
|
||||
|
||||
|
||||
const BatteryStatus: React.FC<BatteryStatusProps> = ({ deviceMetrics }) => {
|
||||
if (deviceMetrics?.batteryLevel === undefined || deviceMetrics?.batteryLevel === null) {
|
||||
if (
|
||||
deviceMetrics?.batteryLevel === undefined ||
|
||||
deviceMetrics?.batteryLevel === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { batteryLevel, voltage } = deviceMetrics;
|
||||
const currentState = getBatteryState(batteryLevel) ?? batteryStates[batteryStates.length - 1];
|
||||
|
||||
const currentState = getBatteryState(batteryLevel) ??
|
||||
batteryStates[batteryStates.length - 1];
|
||||
|
||||
const BatteryIcon = currentState.Icon;
|
||||
const iconClassName = currentState.className;
|
||||
const statusText = currentState.text(batteryLevel);
|
||||
|
||||
const voltageTitle = `${voltage?.toPrecision(3) ?? 'Unknown'} volts`;
|
||||
const voltageTitle = `${voltage?.toPrecision(3) ?? "Unknown"} volts`;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -83,4 +85,4 @@ const BatteryStatus: React.FC<BatteryStatusProps> = ({ deviceMetrics }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default BatteryStatus;
|
||||
export default BatteryStatus;
|
||||
|
||||
@@ -17,8 +17,10 @@ import {
|
||||
FactoryIcon,
|
||||
LayersIcon,
|
||||
LinkIcon,
|
||||
type LucideIcon,
|
||||
MapIcon,
|
||||
MessageSquareIcon,
|
||||
Pin,
|
||||
PlusIcon,
|
||||
PowerIcon,
|
||||
QrCodeIcon,
|
||||
@@ -27,8 +29,6 @@ import {
|
||||
SmartphoneIcon,
|
||||
TrashIcon,
|
||||
UsersIcon,
|
||||
Pin,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { Avatar } from "@components/UI/Avatar.tsx";
|
||||
@@ -61,7 +61,9 @@ export const CommandPalette = () => {
|
||||
} = useAppStore();
|
||||
const { getDevices } = useDeviceStore();
|
||||
const { setDialogOpen, setActivePage, getNode, connection } = useDevice();
|
||||
const { pinnedItems, togglePinnedItem } = usePinnedItems({ storageName: 'pinnedCommandMenuGroups' });
|
||||
const { pinnedItems, togglePinnedItem } = usePinnedItems({
|
||||
storageName: "pinnedCommandMenuGroups",
|
||||
});
|
||||
|
||||
const groups: Group[] = [
|
||||
{
|
||||
@@ -114,15 +116,12 @@ export const CommandPalette = () => {
|
||||
label: "Switch Node",
|
||||
icon: ArrowLeftRightIcon,
|
||||
subItems: getDevices().map((device) => ({
|
||||
label:
|
||||
getNode(device.hardware.myNodeNum)?.user?.longName ??
|
||||
label: getNode(device.hardware.myNodeNum)?.user?.longName ??
|
||||
device.hardware.myNodeNum.toString(),
|
||||
icon: (
|
||||
<Avatar
|
||||
text={
|
||||
getNode(device.hardware.myNodeNum)?.user?.shortName ??
|
||||
device.hardware.myNodeNum.toString()
|
||||
}
|
||||
text={getNode(device.hardware.myNodeNum)?.user?.shortName ??
|
||||
device.hardware.myNodeNum.toString()}
|
||||
/>
|
||||
),
|
||||
action() {
|
||||
@@ -248,7 +247,10 @@ export const CommandPalette = () => {
|
||||
}, [setCommandPaletteOpen]);
|
||||
|
||||
return (
|
||||
<CommandDialog open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen}>
|
||||
<CommandDialog
|
||||
open={commandPaletteOpen}
|
||||
onOpenChange={setCommandPaletteOpen}
|
||||
>
|
||||
<CommandInput placeholder="Type a command or search..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
@@ -262,13 +264,11 @@ export const CommandPalette = () => {
|
||||
type="button"
|
||||
onClick={() => togglePinnedItem(group.label)}
|
||||
className={cn(
|
||||
"transition-all duration-300 scale-100 cursor-pointer p-2 focus:*:data-label:opacity-100"
|
||||
"transition-all duration-300 scale-100 cursor-pointer p-2 focus:*:data-label:opacity-100",
|
||||
)}
|
||||
aria-description={
|
||||
pinnedItems.includes(group.label)
|
||||
? "Unpin command group"
|
||||
: "Pin command group"
|
||||
}
|
||||
aria-description={pinnedItems.includes(group.label)
|
||||
? "Unpin command group"
|
||||
: "Pin command group"}
|
||||
>
|
||||
<span
|
||||
data-label
|
||||
@@ -280,7 +280,7 @@ export const CommandPalette = () => {
|
||||
"transition-opacity",
|
||||
pinnedItems.includes(group.label)
|
||||
? "opacity-100 text-red-500"
|
||||
: "opacity-40 hover:opacity-70"
|
||||
: "opacity-40 hover:opacity-70",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
// Ensure the path is correct for import
|
||||
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
||||
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
|
||||
|
||||
vi.mock('@core/stores/messageStore', () => ({
|
||||
vi.mock("@core/stores/messageStore", () => ({
|
||||
useMessageStore: vi.fn(() => ({
|
||||
deleteAllMessages: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('DeleteMessagesDialog', () => {
|
||||
describe("DeleteMessagesDialog", () => {
|
||||
const mockOnOpenChange = vi.fn();
|
||||
const mockClearAllMessages = vi.fn();
|
||||
|
||||
@@ -20,50 +20,61 @@ describe('DeleteMessagesDialog', () => {
|
||||
|
||||
const mockedUseMessageStore = vi.mocked(useMessageStore);
|
||||
mockedUseMessageStore.mockImplementation(() => ({
|
||||
deleteAllMessages: mockClearAllMessages
|
||||
deleteAllMessages: mockClearAllMessages,
|
||||
}));
|
||||
mockedUseMessageStore.mockClear();
|
||||
|
||||
});
|
||||
|
||||
it('calls onOpenChange with false when the close button (X) is clicked', () => {
|
||||
render(<DeleteMessagesDialog open={true} onOpenChange={mockOnOpenChange} />);
|
||||
const closeButton = screen.queryByTestId('dialog-close-button');
|
||||
it("calls onOpenChange with false when the close button (X) is clicked", () => {
|
||||
render(
|
||||
<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />,
|
||||
);
|
||||
const closeButton = screen.queryByTestId("dialog-close-button");
|
||||
if (!closeButton) {
|
||||
throw new Error("Dialog close button with data-testid='dialog-close-button' not found. Did you add it to the component?");
|
||||
throw new Error(
|
||||
"Dialog close button with data-testid='dialog-close-button' not found. Did you add it to the component?",
|
||||
);
|
||||
}
|
||||
fireEvent.click(closeButton);
|
||||
expect(mockOnOpenChange).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('renders the dialog when open is true', () => {
|
||||
render(<DeleteMessagesDialog open={true} onOpenChange={mockOnOpenChange} />);
|
||||
expect(screen.getByText('Clear All Messages')).toBeInTheDocument();
|
||||
expect(screen.getByText(/This action will clear all message history./)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Clear Messages' })).toBeInTheDocument();
|
||||
it("renders the dialog when open is true", () => {
|
||||
render(
|
||||
<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />,
|
||||
);
|
||||
expect(screen.getByText("Clear All Messages")).toBeInTheDocument();
|
||||
expect(screen.getByText(/This action will clear all message history./))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Dismiss" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Clear Messages" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the dialog when open is false', () => {
|
||||
render(<DeleteMessagesDialog open={false} onOpenChange={mockOnOpenChange} />);
|
||||
expect(screen.queryByText('Clear All Messages')).toBeNull();
|
||||
it("does not render the dialog when open is false", () => {
|
||||
render(
|
||||
<DeleteMessagesDialog open={false} onOpenChange={mockOnOpenChange} />,
|
||||
);
|
||||
expect(screen.queryByText("Clear All Messages")).toBeNull();
|
||||
});
|
||||
|
||||
it('calls onOpenChange with false when the dismiss button is clicked', () => {
|
||||
render(<DeleteMessagesDialog open={true} onOpenChange={mockOnOpenChange} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }));
|
||||
it("calls onOpenChange with false when the dismiss button is clicked", () => {
|
||||
render(
|
||||
<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
|
||||
expect(mockOnOpenChange).toHaveBeenCalledTimes(1); // Add count check
|
||||
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('calls deleteAllMessages and onOpenChange with false when the clear messages button is clicked', () => {
|
||||
render(<DeleteMessagesDialog open={true} onOpenChange={mockOnOpenChange} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear Messages' }));
|
||||
it("calls deleteAllMessages and onOpenChange with false when the clear messages button is clicked", () => {
|
||||
render(
|
||||
<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear Messages" }));
|
||||
expect(mockClearAllMessages).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnOpenChange).toHaveBeenCalledTimes(1); // Add count check
|
||||
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
|
||||
@@ -44,8 +44,14 @@ export const DeviceNameDialog = ({
|
||||
values: defaultValues,
|
||||
});
|
||||
|
||||
const { currentLength: currentLongNameLength } = validateMaxByteLength(getValues('longName'), MAX_LONG_NAME_BYTE_LENGTH);
|
||||
const { currentLength: currentShortNameLength } = validateMaxByteLength(getValues('shortName'), MAX_SHORT_NAME_BYTE_LENGTH);
|
||||
const { currentLength: currentLongNameLength } = validateMaxByteLength(
|
||||
getValues("longName"),
|
||||
MAX_LONG_NAME_BYTE_LENGTH,
|
||||
);
|
||||
const { currentLength: currentShortNameLength } = validateMaxByteLength(
|
||||
getValues("shortName"),
|
||||
MAX_SHORT_NAME_BYTE_LENGTH,
|
||||
);
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
connection?.setOwner(
|
||||
@@ -83,7 +89,7 @@ export const DeviceNameDialog = ({
|
||||
label: "Long Name",
|
||||
type: "text",
|
||||
properties: {
|
||||
className: 'text-slate-900 dark:text-slate-200',
|
||||
className: "text-slate-900 dark:text-slate-200",
|
||||
fieldLength: {
|
||||
currentValueLength: currentLongNameLength ?? 0,
|
||||
max: MAX_LONG_NAME_BYTE_LENGTH,
|
||||
@@ -113,11 +119,13 @@ export const DeviceNameDialog = ({
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="destructive" onClick={handleReset}>Reset</Button>
|
||||
<Button type="button" variant="destructive" onClick={handleReset}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button type="submit">Save</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -12,7 +12,6 @@ import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshK
|
||||
import { RebootOTADialog } from "@components/Dialog/RebootOTADialog.tsx";
|
||||
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
|
||||
|
||||
|
||||
export const DialogManager = () => {
|
||||
const { channels, config, dialog, setDialogOpen } = useDevice();
|
||||
return (
|
||||
|
||||
@@ -51,7 +51,7 @@ export const ImportDialog = ({
|
||||
const paddedString = encodedChannelConfig
|
||||
.padEnd(
|
||||
encodedChannelConfig.length +
|
||||
((4 - (encodedChannelConfig.length % 4)) % 4),
|
||||
((4 - (encodedChannelConfig.length % 4)) % 4),
|
||||
"=",
|
||||
)
|
||||
.replace(/-/g, "+")
|
||||
|
||||
@@ -43,8 +43,9 @@ export const LocationResponseDialog = ({
|
||||
Coordinates:{" "}
|
||||
<a
|
||||
className="text-blue-500 dark:text-blue-400"
|
||||
href={`https://www.openstreetmap.org/?mlat=${location?.data.latitudeI / 1e7
|
||||
}&mlon=${location?.data.longitudeI / 1e7}&layers=N`}
|
||||
href={`https://www.openstreetmap.org/?mlat=${
|
||||
location?.data.latitudeI / 1e7
|
||||
}&mlon=${location?.data.longitudeI / 1e7}&layers=N`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
||||
@@ -90,8 +90,8 @@ const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
|
||||
{browserFeatures.length > 0 && (
|
||||
<>
|
||||
This connection type requires{" "}
|
||||
{formatFeatureList(browserFeatures)}. Please use a
|
||||
supported browser, like Chrome or Edge.
|
||||
{formatFeatureList(browserFeatures)}. Please use a supported
|
||||
browser, like Chrome or Edge.
|
||||
</>
|
||||
)}
|
||||
{needsSecureContext && (
|
||||
@@ -114,11 +114,9 @@ export const NewDeviceDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: NewDeviceProps) => {
|
||||
const [connectionInProgress, setConnectionInProgress] =
|
||||
useState(false);
|
||||
const [connectionInProgress, setConnectionInProgress] = useState(false);
|
||||
const { unsupported } = useBrowserFeatureDetection();
|
||||
|
||||
|
||||
const tabs: TabManifest[] = [
|
||||
{
|
||||
label: "HTTP",
|
||||
@@ -160,7 +158,11 @@ export const NewDeviceDialog = ({
|
||||
{tab.isDisabled
|
||||
? <ErrorMessage missingFeatures={unsupported} />
|
||||
: null}
|
||||
<tab.element closeDialog={() => onOpenChange(false)} setConnectionInProgress={setConnectionInProgress} connectionInProgress={connectionInProgress} />
|
||||
<tab.element
|
||||
closeDialog={() => onOpenChange(false)}
|
||||
setConnectionInProgress={setConnectionInProgress}
|
||||
connectionInProgress={connectionInProgress}
|
||||
/>
|
||||
</fieldset>
|
||||
</TabsContent>
|
||||
))}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, vi, expect, beforeEach } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
@@ -11,7 +11,6 @@ vi.mock("@core/stores/appStore");
|
||||
const mockUseDevice = vi.mocked(useDevice);
|
||||
const mockUseAppStore = vi.mocked(useAppStore);
|
||||
|
||||
|
||||
describe("NodeDetailsDialog", () => {
|
||||
const mockDevice = {
|
||||
num: 1234,
|
||||
@@ -54,30 +53,33 @@ describe("NodeDetailsDialog", () => {
|
||||
});
|
||||
|
||||
it("renders node details correctly", () => {
|
||||
render(<NodeDetailsDialog open={true} onOpenChange={() => { }} />);
|
||||
render(<NodeDetailsDialog open onOpenChange={() => {}} />);
|
||||
|
||||
expect(screen.getByText(/Node Details for Test Node \(TN\)/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Node Details for Test Node \(TN\)/i))
|
||||
.toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("Node Number: 1234")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Node Hex: !/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Last Heard:/i)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/Coordinates:/i)).toBeInTheDocument();
|
||||
const link = screen.getByRole('link', { name: /^45, -75$/ });
|
||||
const link = screen.getByRole("link", { name: /^45, -75$/ });
|
||||
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute('href', expect.stringContaining('openstreetmap.org'));
|
||||
expect(link).toHaveAttribute(
|
||||
"href",
|
||||
expect.stringContaining("openstreetmap.org"),
|
||||
);
|
||||
expect(screen.getByText(/Altitude: 200m/i)).toBeInTheDocument();
|
||||
|
||||
|
||||
expect(screen.getByText(/Air TX utilization: 50.12%/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Channel utilization: 75.46%/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Channel utilization: 75.46%/i))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByText(/Battery level: 88.79%/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Voltage: 4.20V/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Uptime:/i)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/All Raw Metrics:/i)).toBeInTheDocument();
|
||||
|
||||
});
|
||||
|
||||
it("renders null if device is not found", () => {
|
||||
@@ -99,7 +101,9 @@ describe("NodeDetailsDialog", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const { container } = render(<NodeDetailsDialog open={true} onOpenChange={() => { }} />);
|
||||
const { container } = render(
|
||||
<NodeDetailsDialog open onOpenChange={() => {}} />,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
expect(screen.queryByText(/Node Details for/i)).not.toBeInTheDocument();
|
||||
@@ -110,7 +114,7 @@ describe("NodeDetailsDialog", () => {
|
||||
mockUseDevice.mockReturnValue({ getNode: () => nodeWithoutPosition });
|
||||
mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 });
|
||||
|
||||
render(<NodeDetailsDialog open={true} onOpenChange={() => { }} />);
|
||||
render(<NodeDetailsDialog open onOpenChange={() => {}} />);
|
||||
|
||||
expect(screen.queryByText(/Coordinates:/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Altitude:/i)).not.toBeInTheDocument();
|
||||
@@ -122,7 +126,7 @@ describe("NodeDetailsDialog", () => {
|
||||
mockUseDevice.mockReturnValue({ getNode: () => nodeWithoutMetrics });
|
||||
mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 });
|
||||
|
||||
render(<NodeDetailsDialog open={true} onOpenChange={() => { }} />);
|
||||
render(<NodeDetailsDialog open onOpenChange={() => {}} />);
|
||||
|
||||
expect(screen.queryByText(/Device Metrics:/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Air TX utilization:/i)).not.toBeInTheDocument();
|
||||
@@ -134,9 +138,8 @@ describe("NodeDetailsDialog", () => {
|
||||
mockUseDevice.mockReturnValue({ getNode: () => nodeNeverHeard });
|
||||
mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 });
|
||||
|
||||
render(<NodeDetailsDialog open={true} onOpenChange={() => { }} />);
|
||||
render(<NodeDetailsDialog open onOpenChange={() => {}} />);
|
||||
|
||||
expect(screen.getByText(/Last Heard: Never/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,7 +65,7 @@ export const NodeDetailsDialog = ({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent >
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
@@ -78,9 +78,8 @@ export const NodeDetailsDialog = ({
|
||||
<div className="flex flex-col">
|
||||
<DeviceImage
|
||||
className="w-32 h-32 mx-auto rounded-lg border-4 border-slate-200 dark:border-slate-800"
|
||||
deviceType={
|
||||
Protobuf.Mesh.HardwareModel[device.user?.hwModel ?? 0]
|
||||
}
|
||||
deviceType={Protobuf.Mesh
|
||||
.HardwareModel[device.user?.hwModel ?? 0]}
|
||||
/>
|
||||
<div className="bg-slate-100 text-slate-900 dark:text-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
|
||||
<p className="text-lg font-semibold">Details:</p>
|
||||
@@ -91,16 +90,14 @@ export const NodeDetailsDialog = ({
|
||||
<p>Node Number: {device.num}</p>
|
||||
<p>Node Hex: !{numberToHexUnpadded(device.num)}</p>
|
||||
<p>
|
||||
Role:{" "}
|
||||
{
|
||||
Protobuf.Config.Config_DeviceConfig_Role[
|
||||
Role: {Protobuf.Config.Config_DeviceConfig_Role[
|
||||
device.user?.role ?? 0
|
||||
]
|
||||
}
|
||||
]}
|
||||
</p>
|
||||
<p>
|
||||
Last Heard:{" "}
|
||||
{device.lastHeard === 0 ? "Never" : <TimeAgo timestamp={device.lastHeard * 1000} />}
|
||||
Last Heard: {device.lastHeard === 0
|
||||
? "Never"
|
||||
: <TimeAgo timestamp={device.lastHeard * 1000} />}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -112,9 +109,9 @@ export const NodeDetailsDialog = ({
|
||||
Coordinates:{" "}
|
||||
<a
|
||||
className="text-blue-500 dark:text-blue-400"
|
||||
href={`https://www.openstreetmap.org/?mlat=${device.position.latitudeI / 1e7
|
||||
}&mlon=${device.position.longitudeI / 1e7
|
||||
}&layers=N`}
|
||||
href={`https://www.openstreetmap.org/?mlat=${
|
||||
device.position.latitudeI / 1e7
|
||||
}&mlon=${device.position.longitudeI / 1e7}&layers=N`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
@@ -140,7 +137,7 @@ export const NodeDetailsDialog = ({
|
||||
<p key={metric.key}>
|
||||
{metric.label}: {metric.format(metric.value)}
|
||||
</p>
|
||||
)
|
||||
),
|
||||
)}
|
||||
{device.deviceMetrics.uptimeSeconds && (
|
||||
<p>
|
||||
@@ -150,7 +147,6 @@ export const NodeDetailsDialog = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<div className="text-slate-900 dark:text-slate-100 w-full max-w-[464px] bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
|
||||
|
||||
@@ -13,7 +13,10 @@ import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "../UI/Button.tsx";
|
||||
import { MessageType, useMessageStore } from "../../core/stores/messageStore/index.ts";
|
||||
import {
|
||||
MessageType,
|
||||
useMessageStore,
|
||||
} from "../../core/stores/messageStore/index.ts";
|
||||
|
||||
export interface NodeOptionsDialogProps {
|
||||
node: Protobuf.Mesh.NodeInfo | undefined;
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface PkiRegenerateDialogProps {
|
||||
title: string;
|
||||
description: string;
|
||||
button: string;
|
||||
}
|
||||
};
|
||||
open: boolean;
|
||||
onOpenChange: () => void;
|
||||
onSubmit: () => void;
|
||||
|
||||
@@ -79,8 +79,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}`}
|
||||
</Label>
|
||||
<Checkbox
|
||||
key={channel.index}
|
||||
@@ -108,20 +108,22 @@ export const QRDialog = ({
|
||||
<div className="flex justify-center">
|
||||
<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 ${qrCodeAdd
|
||||
? "focus:ring-green-800 bg-green-800 text-white"
|
||||
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
|
||||
}`}
|
||||
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
|
||||
? "focus:ring-green-800 bg-green-800 text-white"
|
||||
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
|
||||
}`}
|
||||
onClick={() => setQrCodeAdd(true)}
|
||||
>
|
||||
Add Channels
|
||||
</button>
|
||||
<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 ${!qrCodeAdd
|
||||
? "focus:ring-green-800 bg-green-800 text-white"
|
||||
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
|
||||
}`}
|
||||
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
|
||||
? "focus:ring-green-800 bg-green-800 text-white"
|
||||
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
|
||||
}`}
|
||||
onClick={() => setQrCodeAdd(false)}
|
||||
>
|
||||
Replace Channels
|
||||
@@ -134,7 +136,7 @@ export const QRDialog = ({
|
||||
value={qrCodeUrl}
|
||||
disabled
|
||||
action={{
|
||||
key: 'copy-value',
|
||||
key: "copy-value",
|
||||
icon: ClipboardIcon,
|
||||
onClick() {
|
||||
void navigator.clipboard.writeText(qrCodeUrl);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { RebootOTADialog } from './RebootOTADialog.tsx';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { RebootOTADialog } from "./RebootOTADialog.tsx";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
const rebootOtaMock = vi.fn();
|
||||
@@ -8,41 +8,46 @@ let mockConnection: { rebootOta: (delay: number) => void } | undefined = {
|
||||
rebootOta: rebootOtaMock,
|
||||
};
|
||||
|
||||
vi.mock('@core/stores/deviceStore.ts', () => ({
|
||||
vi.mock("@core/stores/deviceStore.ts", () => ({
|
||||
useDevice: () => ({
|
||||
connection: mockConnection,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@components/UI/Button.tsx', async () => {
|
||||
const actual = await vi.importActual('@components/UI/Button.tsx');
|
||||
vi.mock("@components/UI/Button.tsx", async () => {
|
||||
const actual = await vi.importActual("@components/UI/Button.tsx");
|
||||
return {
|
||||
...actual,
|
||||
Button: (props: any) => <button {...props} />,
|
||||
Button: (props) => <button {...props} />,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@components/UI/Input.tsx', async () => {
|
||||
const actual = await vi.importActual('@components/UI/Input.tsx');
|
||||
vi.mock("@components/UI/Input.tsx", async () => {
|
||||
const actual = await vi.importActual("@components/UI/Input.tsx");
|
||||
return {
|
||||
...actual,
|
||||
Input: (props: any) => <input {...props} />,
|
||||
Input: (props) => <input {...props} />,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@components/UI/Dialog.tsx', () => {
|
||||
vi.mock("@components/UI/Dialog.tsx", () => {
|
||||
return {
|
||||
Dialog: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogContent: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DialogHeader: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DialogTitle: ({ children }: { children: ReactNode }) => <h1>{children}</h1>,
|
||||
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
|
||||
DialogDescription: ({ children }: { children: ReactNode }) => (
|
||||
<p>{children}</p>
|
||||
),
|
||||
DialogClose: () => null,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
describe('RebootOTADialog', () => {
|
||||
describe("RebootOTADialog", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
rebootOtaMock.mockClear();
|
||||
@@ -52,19 +57,19 @@ describe('RebootOTADialog', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders dialog with default input value', () => {
|
||||
render(<RebootOTADialog open={true} onOpenChange={() => { }} />);
|
||||
it("renders dialog with default input value", () => {
|
||||
render(<RebootOTADialog open onOpenChange={() => {}} />);
|
||||
expect(screen.getByPlaceholderText(/enter delay/i)).toHaveValue(5);
|
||||
expect(screen.getByText(/schedule reboot/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/reboot to ota mode now/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('schedules a reboot with delay and calls rebootOta', async () => {
|
||||
it("schedules a reboot with delay and calls rebootOta", async () => {
|
||||
const onOpenChangeMock = vi.fn();
|
||||
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />);
|
||||
render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/enter delay/i), {
|
||||
target: { value: '3' },
|
||||
target: { value: "3" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText(/schedule reboot/i));
|
||||
@@ -79,9 +84,9 @@ describe('RebootOTADialog', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('triggers an instant reboot', async () => {
|
||||
it("triggers an instant reboot", async () => {
|
||||
const onOpenChangeMock = vi.fn();
|
||||
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />);
|
||||
render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />);
|
||||
|
||||
fireEvent.click(screen.getByText(/reboot to ota mode now/i));
|
||||
|
||||
@@ -91,13 +96,13 @@ describe('RebootOTADialog', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call reboot if connection is undefined', async () => {
|
||||
it("does not call reboot if connection is undefined", async () => {
|
||||
const onOpenChangeMock = vi.fn();
|
||||
|
||||
// simulate no connection
|
||||
mockConnection = undefined;
|
||||
|
||||
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />);
|
||||
render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />);
|
||||
|
||||
fireEvent.click(screen.getByText(/schedule reboot/i));
|
||||
vi.advanceTimersByTime(5000);
|
||||
@@ -110,5 +115,4 @@ describe('RebootOTADialog', () => {
|
||||
// reset connection for other tests
|
||||
mockConnection = { rebootOta: rebootOtaMock };
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -19,7 +19,9 @@ export interface RebootOTADialogProps {
|
||||
|
||||
const DEFAULT_REBOOT_DELAY = 5; // seconds
|
||||
|
||||
export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) => {
|
||||
export const RebootOTADialog = (
|
||||
{ open, onOpenChange }: RebootOTADialogProps,
|
||||
) => {
|
||||
const { connection } = useDevice();
|
||||
const [time, setTime] = useState<number>(DEFAULT_REBOOT_DELAY);
|
||||
const [isScheduled, setIsScheduled] = useState(false);
|
||||
@@ -28,8 +30,8 @@ export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) =>
|
||||
const handleSetTime = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!e.target.validity.valid) {
|
||||
e.preventDefault();
|
||||
return
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const val = e.target.value;
|
||||
setInputValue(val);
|
||||
@@ -73,7 +75,8 @@ export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) =>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reboot to OTA Mode</DialogTitle>
|
||||
<DialogDescription>
|
||||
Reboot the connected node after a delay into OTA (Over-the-Air) mode.
|
||||
Reboot the connected node after a delay into OTA (Over-the-Air)
|
||||
mode.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -89,7 +92,7 @@ export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) =>
|
||||
/>
|
||||
<Button onClick={() => handleRebootWithTimeout()} className="w-9/12">
|
||||
<ClockIcon className="mr-2" size={18} />
|
||||
{isScheduled ? 'Reboot has been scheduled' : 'Schedule Reboot'}
|
||||
{isScheduled ? "Reboot has been scheduled" : "Schedule Reboot"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -101,4 +104,3 @@ export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) =>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { DeviceContext, useDeviceStore } from "@core/stores/deviceStore.ts";
|
||||
import { RefreshKeysDialog } from "./RefreshKeysDialog.tsx";
|
||||
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
||||
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
|
||||
import { expect, test, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
|
||||
vi.mock("@core/stores/messageStore");
|
||||
@@ -12,7 +12,9 @@ vi.mock("./useRefreshKeysDialog");
|
||||
const mockUseMessageStore = vi.mocked(useMessageStore);
|
||||
const mockUseRefreshKeysDialog = vi.mocked(useRefreshKeysDialog);
|
||||
|
||||
const getInitialState = () => useDeviceStore.getInitialState?.() ?? { devices: new Map(), remoteDevices: new Map() };
|
||||
const getInitialState = () =>
|
||||
useDeviceStore.getInitialState?.() ??
|
||||
{ devices: new Map(), remoteDevices: new Map() };
|
||||
|
||||
beforeEach(() => {
|
||||
useDeviceStore.setState(getInitialState(), true);
|
||||
@@ -39,17 +41,19 @@ test("renders dialog when there is a node error for the active chat", () => {
|
||||
longName: "Problem Node Long",
|
||||
shortName: "ProbNode",
|
||||
isLicensed: false,
|
||||
macaddr: new Uint8Array(0)
|
||||
macaddr: new Uint8Array(0),
|
||||
},
|
||||
lastHeard: Date.now() / 1000,
|
||||
snr: 10
|
||||
snr: 10,
|
||||
} as Protobuf.Mesh.NodeInfo);
|
||||
|
||||
deviceStore.setNodeError(activeChatNum, "PKI_MISMATCH");
|
||||
|
||||
const updatedDeviceState = useDeviceStore.getState().getDevice(deviceId);
|
||||
if (!updatedDeviceState) {
|
||||
throw new Error("Failed to get updated device state from store for provider");
|
||||
throw new Error(
|
||||
"Failed to get updated device state from store for provider",
|
||||
);
|
||||
}
|
||||
|
||||
mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum });
|
||||
@@ -63,12 +67,18 @@ test("renders dialog when there is a node error for the active chat", () => {
|
||||
render(
|
||||
<DeviceContext.Provider value={updatedDeviceState}>
|
||||
<RefreshKeysDialog open onOpenChange={vi.fn()} />
|
||||
</DeviceContext.Provider>
|
||||
</DeviceContext.Provider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Keys Mismatch - Problem Node Long/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Your node is unable to send a direct message to node: Problem Node Long \(ProbNode\)/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Request New Keys" })).toBeInTheDocument();
|
||||
expect(screen.getByText(/Keys Mismatch - Problem Node Long/))
|
||||
.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Your node is unable to send a direct message to node: Problem Node Long \(ProbNode\)/,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Request New Keys" }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Dismiss" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -90,7 +100,7 @@ test("does not render dialog if no error exists for active chat", () => {
|
||||
const { container } = render(
|
||||
<DeviceContext.Provider value={currentDeviceState}>
|
||||
<RefreshKeysDialog open onOpenChange={vi.fn()} />
|
||||
</DeviceContext.Provider>
|
||||
</DeviceContext.Provider>,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
|
||||
@@ -16,7 +16,9 @@ export interface RefreshKeysDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps) => {
|
||||
export const RefreshKeysDialog = (
|
||||
{ open, onOpenChange }: RefreshKeysDialogProps,
|
||||
) => {
|
||||
const { activeChat } = useMessageStore();
|
||||
const { nodeErrors, getNode } = useDevice();
|
||||
const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog();
|
||||
@@ -31,8 +33,12 @@ export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps
|
||||
|
||||
const text = {
|
||||
title: `Keys Mismatch - ${nodeWithError?.user?.longName ?? ""}`,
|
||||
description: `Your node is unable to send a direct message to node: ${nodeWithError?.user?.longName ?? ""} (${nodeWithError?.user?.shortName ?? ""}). This is due to the remote node's current public key does not match the previously stored key for this node.`,
|
||||
}
|
||||
description: `Your node is unable to send a direct message to node: ${
|
||||
nodeWithError?.user?.longName ?? ""
|
||||
} (${
|
||||
nodeWithError?.user?.shortName ?? ""
|
||||
}). This is due to the remote node's current public key does not match the previously stored key for this node.`,
|
||||
};
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-8 flex flex-col gap-2">
|
||||
@@ -44,7 +50,10 @@ export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps
|
||||
<ul className="mt-2">
|
||||
<li className="flex place-items-center gap-2 items-start">
|
||||
<div className="p-2 bg-slate-500 rounded-lg mt-1">
|
||||
<LockKeyholeOpenIcon size={30} className="text-white justify-center" />
|
||||
<LockKeyholeOpenIcon
|
||||
size={30}
|
||||
className="text-white justify-center"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
@@ -70,6 +79,6 @@ export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps
|
||||
</ul>
|
||||
{/* </DialogDescription> */}
|
||||
</DialogContent>
|
||||
</Dialog >
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
|
||||
import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
@@ -38,7 +38,7 @@ describe("useRefreshKeysDialog Hook", () => {
|
||||
});
|
||||
|
||||
vi.mocked(useMessageStore).mockReturnValue({
|
||||
activeChat: "chat-123"
|
||||
activeChat: "chat-123",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,7 +46,9 @@ describe("useRefreshKeysDialog Hook", () => {
|
||||
getNodeErrorMock.mockReturnValue({ node: "node-abc" });
|
||||
|
||||
const { result } = renderHook(() => useRefreshKeysDialog());
|
||||
act(() => { result.current.handleNodeRemove(); });
|
||||
act(() => {
|
||||
result.current.handleNodeRemove();
|
||||
});
|
||||
|
||||
expect(getNodeErrorMock).toHaveBeenCalledTimes(1);
|
||||
expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123");
|
||||
@@ -60,7 +62,9 @@ describe("useRefreshKeysDialog Hook", () => {
|
||||
|
||||
it("handleNodeRemove should do nothing if there is no error", () => {
|
||||
const { result } = renderHook(() => useRefreshKeysDialog());
|
||||
act(() => { result.current.handleNodeRemove(); });
|
||||
act(() => {
|
||||
result.current.handleNodeRemove();
|
||||
});
|
||||
|
||||
expect(getNodeErrorMock).toHaveBeenCalledTimes(1);
|
||||
expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123");
|
||||
@@ -79,4 +83,4 @@ describe("useRefreshKeysDialog Hook", () => {
|
||||
expect(setDialogOpenMock).toHaveBeenCalledTimes(1);
|
||||
expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,11 +3,12 @@ import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { useMessageStore } from "@core/stores/messageStore/index.ts";
|
||||
|
||||
export function useRefreshKeysDialog() {
|
||||
const { removeNode, setDialogOpen, clearNodeError, getNodeError } = useDevice();
|
||||
const { removeNode, setDialogOpen, clearNodeError, getNodeError } =
|
||||
useDevice();
|
||||
const { activeChat } = useMessageStore();
|
||||
|
||||
const handleCloseDialog = useCallback(() => {
|
||||
setDialogOpen('refreshKeys', false);
|
||||
setDialogOpen("refreshKeys", false);
|
||||
}, [setDialogOpen]);
|
||||
|
||||
const handleNodeRemove = useCallback(() => {
|
||||
@@ -22,6 +23,6 @@ export function useRefreshKeysDialog() {
|
||||
|
||||
return {
|
||||
handleCloseDialog,
|
||||
handleNodeRemove
|
||||
handleNodeRemove,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ export const TracerouteResponseDialog = ({
|
||||
const { getNode } = useDevice();
|
||||
const route: number[] = traceroute?.data.route ?? [];
|
||||
const routeBack: number[] = traceroute?.data.routeBack ?? [];
|
||||
const snrTowards = (traceroute?.data.snrTowards ?? []).map(snr => snr / 4);
|
||||
const snrBack = (traceroute?.data.snrBack ?? []).map(snr => snr / 4);
|
||||
const snrTowards = (traceroute?.data.snrTowards ?? []).map((snr) => snr / 4);
|
||||
const snrBack = (traceroute?.data.snrBack ?? []).map((snr) => snr / 4);
|
||||
const from = getNode(traceroute?.from ?? 0);
|
||||
const longName = from?.user?.longName ??
|
||||
(from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// deno-lint-ignore-file
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
|
||||
import { eventBus } from "@core/utils/eventBus.ts";
|
||||
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
|
||||
@@ -10,41 +10,58 @@ describe("UnsafeRolesDialog", () => {
|
||||
setDialogOpen: vi.fn(),
|
||||
};
|
||||
|
||||
const renderWithDeviceContext = (ui: any) => {
|
||||
const renderWithDeviceContext = (ui: React.ReactNode) => {
|
||||
return render(
|
||||
<DeviceWrapper device={mockDevice}>
|
||||
{ui}
|
||||
</DeviceWrapper>
|
||||
</DeviceWrapper>,
|
||||
);
|
||||
};
|
||||
|
||||
it("renders the dialog when open is true", () => {
|
||||
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
|
||||
renderWithDeviceContext(
|
||||
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/I have read the/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/understand the implications/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/understand the implications/i))
|
||||
.toBeInTheDocument();
|
||||
|
||||
const links = screen.getAllByRole('link');
|
||||
const links = screen.getAllByRole("link");
|
||||
expect(links).toHaveLength(2);
|
||||
expect(links[0]).toHaveTextContent('Device Role Documentation');
|
||||
expect(links[1]).toHaveTextContent('Choosing The Right Device Role');
|
||||
expect(links[0]).toHaveTextContent("Device Role Documentation");
|
||||
expect(links[1]).toHaveTextContent("Choosing The Right Device Role");
|
||||
});
|
||||
|
||||
it("displays the correct links", () => {
|
||||
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
|
||||
renderWithDeviceContext(
|
||||
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
|
||||
);
|
||||
|
||||
const docLink = screen.getByRole("link", { name: /Device Role Documentation/i });
|
||||
const blogLink = screen.getByRole("link", { name: /Choosing The Right Device Role/i });
|
||||
const docLink = screen.getByRole("link", {
|
||||
name: /Device Role Documentation/i,
|
||||
});
|
||||
const blogLink = screen.getByRole("link", {
|
||||
name: /Choosing The Right Device Role/i,
|
||||
});
|
||||
|
||||
expect(docLink).toHaveAttribute("href", "https://meshtastic.org/docs/configuration/radio/device/");
|
||||
expect(blogLink).toHaveAttribute("href", "https://meshtastic.org/blog/choosing-the-right-device-role/");
|
||||
expect(docLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://meshtastic.org/docs/configuration/radio/device/",
|
||||
);
|
||||
expect(blogLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://meshtastic.org/blog/choosing-the-right-device-role/",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not allow confirmation until checkbox is checked", () => {
|
||||
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
|
||||
renderWithDeviceContext(
|
||||
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
|
||||
);
|
||||
|
||||
const confirmButton = screen.getByRole("button", { name: /confirm/i });
|
||||
|
||||
@@ -58,27 +75,37 @@ describe("UnsafeRolesDialog", () => {
|
||||
|
||||
it("emits the correct event when closing via close button", () => {
|
||||
const eventSpy = vi.spyOn(eventBus, "emit");
|
||||
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
|
||||
renderWithDeviceContext(
|
||||
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
|
||||
);
|
||||
|
||||
const dismissButton = screen.getByRole("button", { name: /close/i });
|
||||
fireEvent.click(dismissButton);
|
||||
|
||||
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", { action: "dismiss" });
|
||||
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", {
|
||||
action: "dismiss",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits the correct event when dismissing", () => {
|
||||
const eventSpy = vi.spyOn(eventBus, "emit");
|
||||
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
|
||||
renderWithDeviceContext(
|
||||
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
|
||||
);
|
||||
|
||||
const dismissButton = screen.getByRole("button", { name: /dismiss/i });
|
||||
fireEvent.click(dismissButton);
|
||||
|
||||
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", { action: "dismiss" });
|
||||
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", {
|
||||
action: "dismiss",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits the correct event when confirming", () => {
|
||||
const eventSpy = vi.spyOn(eventBus, "emit");
|
||||
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
|
||||
renderWithDeviceContext(
|
||||
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
|
||||
);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
const confirmButton = screen.getByRole("button", { name: /confirm/i });
|
||||
@@ -86,6 +113,8 @@ describe("UnsafeRolesDialog", () => {
|
||||
fireEvent.click(checkbox);
|
||||
fireEvent.click(confirmButton);
|
||||
|
||||
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", { action: "confirm" });
|
||||
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", {
|
||||
action: "confirm",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,29 +19,40 @@ export interface RouterRoleDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const UnsafeRolesDialog = ({ open, onOpenChange }: RouterRoleDialogProps) => {
|
||||
export const UnsafeRolesDialog = (
|
||||
{ open, onOpenChange }: RouterRoleDialogProps,
|
||||
) => {
|
||||
const [confirmState, setConfirmState] = useState(false);
|
||||
const { setDialogOpen } = useDevice();
|
||||
|
||||
const deviceRoleLink = "https://meshtastic.org/docs/configuration/radio/device/";
|
||||
const choosingTheRightDeviceRoleLink = "https://meshtastic.org/blog/choosing-the-right-device-role/";
|
||||
const deviceRoleLink =
|
||||
"https://meshtastic.org/docs/configuration/radio/device/";
|
||||
const choosingTheRightDeviceRoleLink =
|
||||
"https://meshtastic.org/blog/choosing-the-right-device-role/";
|
||||
|
||||
const handleCloseDialog = (action: 'confirm' | 'dismiss') => {
|
||||
setDialogOpen('unsafeRoles', false);
|
||||
const handleCloseDialog = (action: "confirm" | "dismiss") => {
|
||||
setDialogOpen("unsafeRoles", false);
|
||||
setConfirmState(false);
|
||||
eventBus.emit('dialog:unsafeRoles', { action });
|
||||
}
|
||||
eventBus.emit("dialog:unsafeRoles", { action });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-8 flex flex-col">
|
||||
<DialogClose onClick={() => handleCloseDialog('dismiss')} />
|
||||
<DialogClose onClick={() => handleCloseDialog("dismiss")} />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you sure?</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription className="text-md">
|
||||
I have read the <Link href={deviceRoleLink} 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.
|
||||
I have read the{" "}
|
||||
<Link href={deviceRoleLink} 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
|
||||
@@ -56,16 +67,20 @@ export const UnsafeRolesDialog = ({ open, onOpenChange }: RouterRoleDialogProps)
|
||||
<Button
|
||||
variant="default"
|
||||
name="dismiss"
|
||||
onClick={() => handleCloseDialog('dismiss')}> Dismiss
|
||||
onClick={() => handleCloseDialog("dismiss")}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
name="confirm"
|
||||
disabled={!confirmState}
|
||||
onClick={() => handleCloseDialog('confirm')}> Confirm
|
||||
onClick={() => handleCloseDialog("confirm")}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog >
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
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.ts";
|
||||
import { afterEach, beforeEach, describe, expect, it, Mock, vi } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import {
|
||||
UNSAFE_ROLES,
|
||||
useUnsafeRolesDialog,
|
||||
} from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
|
||||
import { eventBus } from "@core/utils/eventBus.ts";
|
||||
|
||||
vi.mock('@core/utils/eventBus', () => ({
|
||||
vi.mock("@core/utils/eventBus", () => ({
|
||||
eventBus: {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
@@ -15,13 +18,13 @@ const mockDevice = {
|
||||
setDialogOpen: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@core/stores/deviceStore', () => ({
|
||||
vi.mock("@core/stores/deviceStore", () => ({
|
||||
useDevice: () => ({
|
||||
setDialogOpen: mockDevice.setDialogOpen,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('useUnsafeRolesDialog', () => {
|
||||
describe("useUnsafeRolesDialog", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
@@ -34,84 +37,115 @@ describe('useUnsafeRolesDialog', () => {
|
||||
return renderHook(() => useUnsafeRolesDialog());
|
||||
};
|
||||
|
||||
describe('handleCloseDialog', () => {
|
||||
it('should call setDialogOpen with correct parameters when dialog is closed', () => {
|
||||
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);
|
||||
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith(
|
||||
"unsafeRoles",
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateRoleSelection', () => {
|
||||
it('should resolve with true for safe roles without opening dialog', async () => {
|
||||
describe("validateRoleSelection", () => {
|
||||
it("should resolve with true for safe roles without opening dialog", async () => {
|
||||
const { result } = renderUnsafeRolesHook();
|
||||
const safeRole = 'SAFE_ROLE';
|
||||
const safeRole = "SAFE_ROLE";
|
||||
|
||||
const validationResult = await result.current.validateRoleSelection(safeRole);
|
||||
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 () => {
|
||||
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]);
|
||||
const validationPromise = result.current.validateRoleSelection(
|
||||
UNSAFE_ROLES[0],
|
||||
);
|
||||
|
||||
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith('unsafeRoles', true);
|
||||
expect(eventBus.on).toHaveBeenCalledWith('dialog:unsafeRoles', expect.any(Function));
|
||||
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' });
|
||||
onHandler({ action: "confirm" });
|
||||
const validationResult = await validationPromise;
|
||||
|
||||
expect(validationResult).toBe(true);
|
||||
expect(eventBus.off).toHaveBeenCalledWith('dialog:unsafeRoles', onHandler);
|
||||
expect(eventBus.off).toHaveBeenCalledWith(
|
||||
"dialog:unsafeRoles",
|
||||
onHandler,
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve with false when user dismisses the dialog', async () => {
|
||||
it("should resolve with false when user dismisses the dialog", async () => {
|
||||
const { result } = renderUnsafeRolesHook();
|
||||
const validationPromise = result.current.validateRoleSelection(UNSAFE_ROLES[0]);
|
||||
const validationPromise = result.current.validateRoleSelection(
|
||||
UNSAFE_ROLES[0],
|
||||
);
|
||||
const onHandler = (eventBus.on as Mock).mock.calls[0][1];
|
||||
onHandler({ action: 'dismiss' });
|
||||
onHandler({ action: "dismiss" });
|
||||
|
||||
const validationResult = await validationPromise;
|
||||
expect(validationResult).toBe(false);
|
||||
expect(eventBus.off).toHaveBeenCalledWith('dialog:unsafeRoles', onHandler);
|
||||
expect(eventBus.off).toHaveBeenCalledWith(
|
||||
"dialog:unsafeRoles",
|
||||
onHandler,
|
||||
);
|
||||
});
|
||||
|
||||
it('should clean up event listener after response', async () => {
|
||||
it("should clean up event listener after response", async () => {
|
||||
const { result } = renderUnsafeRolesHook();
|
||||
|
||||
const validationPromise = result.current.validateRoleSelection(UNSAFE_ROLES[1]);
|
||||
const validationPromise = result.current.validateRoleSelection(
|
||||
UNSAFE_ROLES[1],
|
||||
);
|
||||
const onHandler = (eventBus.on as Mock).mock.calls[0][1];
|
||||
|
||||
onHandler({ action: 'confirm' });
|
||||
onHandler({ action: "confirm" });
|
||||
await validationPromise;
|
||||
|
||||
expect(eventBus.off).toHaveBeenCalledWith('dialog:unsafeRoles', onHandler);
|
||||
expect(eventBus.off).toHaveBeenCalledWith(
|
||||
"dialog:unsafeRoles",
|
||||
onHandler,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should work with all unsafe roles', async () => {
|
||||
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);
|
||||
const validationPromise = result.current.validateRoleSelection(
|
||||
unsafeRole,
|
||||
);
|
||||
|
||||
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith('unsafeRoles', true);
|
||||
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith(
|
||||
"unsafeRoles",
|
||||
true,
|
||||
);
|
||||
|
||||
const onHandler = (eventBus.on as Mock).mock.calls[0][1];
|
||||
onHandler({ action: 'confirm' });
|
||||
onHandler({ action: "confirm" });
|
||||
|
||||
const validationResult = await validationPromise;
|
||||
|
||||
expect(validationResult).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,9 @@ export const useUnsafeRolesDialog = () => {
|
||||
setDialogOpen("unsafeRoles", true);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const handleResponse = ({ action }: { action: "confirm" | "dismiss" }) => {
|
||||
const handleResponse = (
|
||||
{ action }: { action: "confirm" | "dismiss" },
|
||||
) => {
|
||||
eventBus.off("dialog:unsafeRoles", handleResponse);
|
||||
resolve(action === "confirm");
|
||||
};
|
||||
@@ -29,7 +31,7 @@ export const useUnsafeRolesDialog = () => {
|
||||
eventBus.on("dialog:unsafeRoles", handleResponse);
|
||||
});
|
||||
},
|
||||
[setDialogOpen]
|
||||
[setDialogOpen],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
} from "react-hook-form";
|
||||
import { Heading } from "@components/UI/Typography/Heading.tsx";
|
||||
|
||||
|
||||
interface DisabledBy<T> {
|
||||
fieldName: Path<T>;
|
||||
selector?: number;
|
||||
@@ -124,7 +123,9 @@ export function DynamicForm<T extends FieldValues>({
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{hasSubmitButton && <Button type="submit" variant="outline">Submit</Button>}
|
||||
{hasSubmitButton && (
|
||||
<Button type="submit" variant="outline">Submit</Button>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@ import type {
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import type { ChangeEventHandler } from "react";
|
||||
import { useState } from "react";
|
||||
import { useController, type FieldValues } from "react-hook-form";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { type FieldValues, useController } from "react-hook-form";
|
||||
|
||||
export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||
type: "text" | "number" | "password";
|
||||
@@ -22,7 +21,7 @@ export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||
max?: number;
|
||||
currentValueLength?: number;
|
||||
showCharacterCount?: boolean;
|
||||
},
|
||||
};
|
||||
showPasswordToggle?: boolean;
|
||||
showCopyButton?: boolean;
|
||||
};
|
||||
@@ -34,7 +33,9 @@ export function GenericInput<T extends FieldValues>({
|
||||
field,
|
||||
}: GenericFormElementProps<T, InputFieldProps<T>>) {
|
||||
const { fieldLength, ...restProperties } = field.properties || {};
|
||||
const [currentLength, setCurrentLength] = useState<number>(fieldLength?.currentValueLength || 0);
|
||||
const [currentLength, setCurrentLength] = useState<number>(
|
||||
fieldLength?.currentValueLength || 0,
|
||||
);
|
||||
|
||||
const { field: controllerField } = useController({
|
||||
name: field.name,
|
||||
@@ -44,27 +45,36 @@ export function GenericInput<T extends FieldValues>({
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
|
||||
if (field.properties?.fieldLength?.max && newValue.length > field.properties?.fieldLength?.max) {
|
||||
if (
|
||||
field.properties?.fieldLength?.max &&
|
||||
newValue.length > field.properties?.fieldLength?.max
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setCurrentLength(newValue.length);
|
||||
|
||||
if (field.inputChange) field.inputChange(e);
|
||||
|
||||
controllerField.onChange(field.type === "number" ? Number.parseFloat(newValue).toString() : newValue);
|
||||
controllerField.onChange(
|
||||
field.type === "number"
|
||||
? Number.parseFloat(newValue).toString()
|
||||
: newValue,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
type={field.type}
|
||||
step={field.properties?.step}
|
||||
value={field.type === "number" ? String(controllerField.value) : controllerField.value}
|
||||
value={field.type === "number"
|
||||
? String(controllerField.value)
|
||||
: controllerField.value}
|
||||
id={field.name}
|
||||
onChange={handleInputChange}
|
||||
showCopyButton={field.properties?.showCopyButton}
|
||||
showPasswordToggle={field.properties?.showPasswordToggle || field.type === "password"}
|
||||
showPasswordToggle={field.properties?.showPasswordToggle ||
|
||||
field.type === "password"}
|
||||
className={field.properties?.className}
|
||||
{...restProperties}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -61,4 +61,4 @@ export function MultiSelectInput<T extends FieldValues>({
|
||||
))}
|
||||
</MultiSelect>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function PasswordGenerator<T extends FieldValues>({
|
||||
field,
|
||||
disabled,
|
||||
}: GenericFormElementProps<T, PasswordGeneratorProps<T>>) {
|
||||
const { isVisible } = usePasswordVisibilityToggle()
|
||||
const { isVisible } = usePasswordVisibilityToggle();
|
||||
|
||||
return (
|
||||
<Controller
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@components/UI/Select.tsx";
|
||||
import { useController, type FieldValues } from "react-hook-form";
|
||||
import { type FieldValues, useController } from "react-hook-form";
|
||||
|
||||
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||
type: "select";
|
||||
@@ -46,7 +46,8 @@ export function SelectInput<T extends FieldValues>({
|
||||
control,
|
||||
});
|
||||
|
||||
const { enumValue, formatEnumName, ...remainingProperties } = field.properties;
|
||||
const { enumValue, formatEnumName, ...remainingProperties } =
|
||||
field.properties;
|
||||
const valueToKeyMap: Record<string, string> = {};
|
||||
const optionsEnumValues: [string, number][] = [];
|
||||
|
||||
|
||||
@@ -24,7 +24,9 @@ export const FieldWrapper = ({
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[0.6fr_2fr_.1fr] sm:items-baseline gap-4">
|
||||
<Label htmlFor={fieldName}>{label}</Label>
|
||||
<div className="max-w-3xl">
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">{description}</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{description}
|
||||
</p>
|
||||
<p hidden={valid ?? true} className="text-sm text-red-500">
|
||||
{validationText}
|
||||
</p>
|
||||
|
||||
@@ -99,8 +99,12 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
|
||||
psk: pass,
|
||||
moduleSettings: {
|
||||
...channel?.settings?.moduleSettings,
|
||||
positionPrecision: channel?.settings?.moduleSettings?.positionPrecision === undefined ? 10 : channel?.settings?.moduleSettings?.positionPrecision,
|
||||
}
|
||||
positionPrecision:
|
||||
channel?.settings?.moduleSettings?.positionPrecision ===
|
||||
undefined
|
||||
? 10
|
||||
: channel?.settings?.moduleSettings?.positionPrecision,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
@@ -125,7 +129,7 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
|
||||
{
|
||||
type: "passwordGenerator",
|
||||
name: "settings.psk",
|
||||
id: 'channel-psk',
|
||||
id: "channel-psk",
|
||||
label: "Pre-Shared Key",
|
||||
description:
|
||||
"Supported PSK lengths: 256-bit, 128-bit, 8-bit, Empty (0-bit)",
|
||||
@@ -212,7 +216,8 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
|
||||
text={{
|
||||
button: "Regenerate",
|
||||
title: "Regenerate Pre-Shared Key?",
|
||||
description: "Are you sure you want to regenerate the pre-shared key?",
|
||||
description:
|
||||
"Are you sure you want to regenerate the pre-shared key?",
|
||||
}}
|
||||
open={preSharedDialogOpen}
|
||||
onOpenChange={() => setPreSharedDialogOpen(false)}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { Device } from '@components/PageComponents/Config/Device/index.tsx';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { Device } from "@components/PageComponents/Config/Device/index.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
|
||||
vi.mock('@core/stores/deviceStore.ts', () => ({
|
||||
useDevice: vi.fn()
|
||||
vi.mock("@core/stores/deviceStore.ts", () => ({
|
||||
useDevice: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts', () => ({
|
||||
useUnsafeRolesDialog: vi.fn()
|
||||
vi.mock("@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts", () => ({
|
||||
useUnsafeRolesDialog: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the DynamicForm component since we're testing the Device component,
|
||||
// not the DynamicForm implementation
|
||||
vi.mock('@components/Form/DynamicForm', () => ({
|
||||
vi.mock("@components/Form/DynamicForm", () => ({
|
||||
DynamicForm: vi.fn(({ onSubmit }) => {
|
||||
// Render a simplified version of the form for testing
|
||||
return (
|
||||
@@ -28,13 +28,16 @@ vi.mock('@components/Form/DynamicForm', () => ({
|
||||
onSubmit(mockData);
|
||||
}}
|
||||
>
|
||||
{Object.entries(Protobuf.Config.Config_DeviceConfig_Role).map(([key, value]) => (
|
||||
{Object.entries(Protobuf.Config.Config_DeviceConfig_Role).map((
|
||||
[key, value],
|
||||
) => (
|
||||
<option key={key} value={value}>
|
||||
{key}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit"
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="submit-button"
|
||||
onClick={() => onSubmit({ role: "CLIENT" })}
|
||||
>
|
||||
@@ -42,10 +45,10 @@ vi.mock('@components/Form/DynamicForm', () => ({
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Device component', () => {
|
||||
describe("Device component", () => {
|
||||
const setWorkingConfigMock = vi.fn();
|
||||
const validateRoleSelectionMock = vi.fn();
|
||||
const mockDeviceConfig = {
|
||||
@@ -63,17 +66,17 @@ describe('Device component', () => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
// Mock the useDevice hook
|
||||
(useDevice as any).mockReturnValue({
|
||||
useDevice.mockReturnValue({
|
||||
config: {
|
||||
device: mockDeviceConfig
|
||||
device: mockDeviceConfig,
|
||||
},
|
||||
setWorkingConfig: setWorkingConfigMock
|
||||
setWorkingConfig: setWorkingConfigMock,
|
||||
});
|
||||
|
||||
// Mock the useUnsafeRolesDialog hook
|
||||
validateRoleSelectionMock.mockResolvedValue(true);
|
||||
(useUnsafeRolesDialog as any).mockReturnValue({
|
||||
validateRoleSelection: validateRoleSelectionMock
|
||||
useUnsafeRolesDialog.mockReturnValue({
|
||||
validateRoleSelection: validateRoleSelectionMock,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,49 +84,48 @@ describe('Device component', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render the Device form', () => {
|
||||
it("should render the Device form", () => {
|
||||
render(<Device />);
|
||||
expect(screen.getByTestId('dynamic-form')).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dynamic-form")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use the validateRoleSelection from the unsafe roles hook', () => {
|
||||
it("should use the validateRoleSelection from the unsafe roles hook", () => {
|
||||
render(<Device />);
|
||||
expect(useUnsafeRolesDialog).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call setWorkingConfig when form is submitted', async () => {
|
||||
it("should call setWorkingConfig when form is submitted", async () => {
|
||||
render(<Device />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('submit-button'));
|
||||
fireEvent.click(screen.getByTestId("submit-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setWorkingConfigMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payloadVariant: {
|
||||
case: "device",
|
||||
value: expect.objectContaining({ role: "CLIENT" })
|
||||
}
|
||||
})
|
||||
value: expect.objectContaining({ role: "CLIENT" }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should create config with proper structure', async () => {
|
||||
it("should create config with proper structure", async () => {
|
||||
render(<Device />);
|
||||
|
||||
// Simulate form submission
|
||||
fireEvent.click(screen.getByTestId('submit-button'));
|
||||
fireEvent.click(screen.getByTestId("submit-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setWorkingConfigMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payloadVariant: {
|
||||
case: "device",
|
||||
value: expect.any(Object)
|
||||
}
|
||||
})
|
||||
value: expect.any(Object),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ export const Device = () => {
|
||||
case: "device",
|
||||
value: data,
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
};
|
||||
return (
|
||||
@@ -83,16 +83,16 @@ export const Device = () => {
|
||||
description: "Disable triple click",
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'tzdef',
|
||||
label: 'POSIX Timezone',
|
||||
description: 'The POSIX timezone string for the device',
|
||||
type: "text",
|
||||
name: "tzdef",
|
||||
label: "POSIX Timezone",
|
||||
description: "The POSIX timezone string for the device",
|
||||
properties: {
|
||||
fieldLength: {
|
||||
max: 64,
|
||||
currentValueLength: config.device?.tzdef?.length,
|
||||
showCharacterCount: true,
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,131 +1,24 @@
|
||||
// import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
// import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
// import { Network } from '@components/PageComponents/Config/Network/index.tsx';
|
||||
// import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
// import { Protobuf } from "@meshtastic/core";
|
||||
|
||||
// vi.mock('@core/stores/deviceStore', () => ({
|
||||
// useDevice: vi.fn()
|
||||
// }));
|
||||
|
||||
// vi.mock('@components/Form/DynamicForm', () => ({
|
||||
// DynamicForm: vi.fn(({ onSubmit }) => {
|
||||
// return (
|
||||
// <div data-testid="dynamic-form">
|
||||
// <select
|
||||
// data-testid="role-select"
|
||||
// onChange={(e) => {
|
||||
// const mockData = { role: e.target.value };
|
||||
// onSubmit(mockData);
|
||||
// }}
|
||||
// >
|
||||
// {Object.entries(Protobuf.Config.Config_DeviceConfig_Role).map(([key, value]) => (
|
||||
// <option key={key} value={value}>
|
||||
// {key}
|
||||
// </option>
|
||||
// ))}
|
||||
// </select>
|
||||
// <button type="submit"
|
||||
// data-testid="submit-button"
|
||||
// onClick={() => onSubmit({ role: "CLIENT" })}
|
||||
// >
|
||||
// Submit
|
||||
// </button>
|
||||
// </div>
|
||||
// );
|
||||
// })
|
||||
// }));
|
||||
|
||||
// describe('Network component', () => {
|
||||
// const setWorkingConfigMock = vi.fn();
|
||||
// const mockDeviceConfig = {
|
||||
// role: "CLIENT",
|
||||
// buttonGpio: 0,
|
||||
// buzzerGpio: 0,
|
||||
// rebroadcastMode: "ALL",
|
||||
// nodeInfoBroadcastSecs: 300,
|
||||
// doubleTapAsButtonPress: false,
|
||||
// disableTripleClick: false,
|
||||
// ledHeartbeatDisabled: false,
|
||||
// };
|
||||
|
||||
// beforeEach(() => {
|
||||
// vi.resetAllMocks();
|
||||
|
||||
// (useDevice as any).mockReturnValue({
|
||||
// config: {
|
||||
// device: mockDeviceConfig
|
||||
// },
|
||||
// setWorkingConfig: setWorkingConfigMock
|
||||
// });
|
||||
|
||||
// });
|
||||
|
||||
// afterEach(() => {
|
||||
// vi.clearAllMocks();
|
||||
// });
|
||||
|
||||
// it('should render the Network form', () => {
|
||||
// render(<Network />);
|
||||
// expect(screen.getByTestId('dynamic-form')).toBeInTheDocument();
|
||||
// });
|
||||
|
||||
// it('should call setWorkingConfig when form is submitted', async () => {
|
||||
// render(<Network />);
|
||||
|
||||
// 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(<Network />);
|
||||
|
||||
// // Simulate form submission
|
||||
// fireEvent.click(screen.getByTestId('submit-button'));
|
||||
|
||||
// await waitFor(() => {
|
||||
// expect(setWorkingConfigMock).toHaveBeenCalledWith(
|
||||
// expect.objectContaining({
|
||||
// payloadVariant: {
|
||||
// case: "network",
|
||||
// value: expect.any(Object)
|
||||
// }
|
||||
// })
|
||||
// );
|
||||
// });
|
||||
// });
|
||||
// });
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { Network } from '@components/PageComponents/Config/Network/index.tsx';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { Network } from "@components/PageComponents/Config/Network/index.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
|
||||
vi.mock('@core/stores/deviceStore', () => ({
|
||||
useDevice: vi.fn()
|
||||
vi.mock("@core/stores/deviceStore", () => ({
|
||||
useDevice: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@components/Form/DynamicForm', async () => {
|
||||
const React = await import('react');
|
||||
vi.mock("@components/Form/DynamicForm", async () => {
|
||||
const React = await import("react");
|
||||
const { useState } = React;
|
||||
|
||||
return {
|
||||
DynamicForm: ({ onSubmit, defaultValues }: any) => {
|
||||
const [wifiEnabled, setWifiEnabled] = useState(defaultValues.wifiEnabled ?? false);
|
||||
const [ssid, setSsid] = useState(defaultValues.wifiSsid ?? '');
|
||||
const [psk, setPsk] = useState(defaultValues.wifiPsk ?? '');
|
||||
DynamicForm: ({ onSubmit, defaultValues }) => {
|
||||
const [wifiEnabled, setWifiEnabled] = useState(
|
||||
defaultValues.wifiEnabled ?? false,
|
||||
);
|
||||
const [ssid, setSsid] = useState(defaultValues.wifiSsid ?? "");
|
||||
const [psk, setPsk] = useState(defaultValues.wifiPsk ?? "");
|
||||
|
||||
return (
|
||||
<form
|
||||
@@ -166,15 +59,14 @@ vi.mock('@components/Form/DynamicForm', async () => {
|
||||
},
|
||||
};
|
||||
});
|
||||
;
|
||||
|
||||
describe('Network component', () => {
|
||||
describe("Network component", () => {
|
||||
const setWorkingConfigMock = vi.fn();
|
||||
const mockNetworkConfig = {
|
||||
wifiEnabled: false,
|
||||
wifiSsid: '',
|
||||
wifiPsk: '',
|
||||
ntpServer: '',
|
||||
wifiSsid: "",
|
||||
wifiPsk: "",
|
||||
ntpServer: "",
|
||||
ethEnabled: false,
|
||||
addressMode: Protobuf.Config.Config_NetworkConfig_AddressMode.DHCP,
|
||||
ipv4Config: {
|
||||
@@ -185,17 +77,17 @@ describe('Network component', () => {
|
||||
},
|
||||
enabledProtocols:
|
||||
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
|
||||
rsyslogServer: '',
|
||||
rsyslogServer: "",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
(useDevice as any).mockReturnValue({
|
||||
useDevice.mockReturnValue({
|
||||
config: {
|
||||
network: mockNetworkConfig
|
||||
network: mockNetworkConfig,
|
||||
},
|
||||
setWorkingConfig: setWorkingConfigMock
|
||||
setWorkingConfig: setWorkingConfigMock,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -203,21 +95,21 @@ describe('Network component', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render the Network form', () => {
|
||||
it("should render the Network form", () => {
|
||||
render(<Network />);
|
||||
expect(screen.getByTestId('dynamic-form')).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dynamic-form")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should disable SSID and PSK fields when wifi is off', () => {
|
||||
it("should disable SSID and PSK fields when wifi is off", () => {
|
||||
render(<Network />);
|
||||
expect(screen.getByLabelText("SSID")).toBeDisabled();
|
||||
expect(screen.getByLabelText("PSK")).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should enable SSID and PSK when wifi is toggled on', async () => {
|
||||
it("should enable SSID and PSK when wifi is toggled on", async () => {
|
||||
render(<Network />);
|
||||
const toggle = screen.getByLabelText("WiFi Enabled");
|
||||
screen.debug()
|
||||
screen.debug();
|
||||
|
||||
fireEvent.click(toggle); // turns wifiEnabled = true
|
||||
|
||||
@@ -227,7 +119,7 @@ describe('Network component', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should call setWorkingConfig with the right structure on submit', async () => {
|
||||
it("should call setWorkingConfig with the right structure on submit", async () => {
|
||||
render(<Network />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("submit-button"));
|
||||
@@ -239,28 +131,28 @@ describe('Network component', () => {
|
||||
case: "network",
|
||||
value: expect.objectContaining({
|
||||
wifiEnabled: false,
|
||||
wifiSsid: '',
|
||||
wifiPsk: '',
|
||||
ntpServer: '',
|
||||
wifiSsid: "",
|
||||
wifiPsk: "",
|
||||
ntpServer: "",
|
||||
ethEnabled: false,
|
||||
rsyslogServer: '',
|
||||
})
|
||||
}
|
||||
})
|
||||
rsyslogServer: "",
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should submit valid data after enabling wifi and entering SSID and PSK', async () => {
|
||||
it("should submit valid data after enabling wifi and entering SSID and PSK", async () => {
|
||||
render(<Network />);
|
||||
fireEvent.click(screen.getByLabelText("WiFi Enabled"));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("SSID"), {
|
||||
target: { value: "MySSID" }
|
||||
target: { value: "MySSID" },
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText("PSK"), {
|
||||
target: { value: "MySecretPSK" }
|
||||
target: { value: "MySecretPSK" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("submit-button"));
|
||||
@@ -273,10 +165,10 @@ describe('Network component', () => {
|
||||
value: expect.objectContaining({
|
||||
wifiEnabled: true,
|
||||
wifiSsid: "MySSID",
|
||||
wifiPsk: "MySecretPSK"
|
||||
})
|
||||
}
|
||||
})
|
||||
wifiPsk: "MySecretPSK",
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { NetworkValidationSchema, type NetworkValidation } from "@app/validation/config/network.ts";
|
||||
import {
|
||||
type NetworkValidation,
|
||||
NetworkValidationSchema,
|
||||
} from "@app/validation/config/network.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
@@ -55,8 +58,8 @@ export const Network = () => {
|
||||
),
|
||||
dns: convertIntToIpAddress(config.network?.ipv4Config?.dns ?? 0),
|
||||
},
|
||||
enabledProtocols: config.network?.enabledProtocols ?? Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST
|
||||
|
||||
enabledProtocols: config.network?.enabledProtocols ??
|
||||
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
|
||||
}}
|
||||
fieldGroups={[
|
||||
{
|
||||
@@ -183,10 +186,9 @@ export const Network = () => {
|
||||
name: "enabledProtocols",
|
||||
label: "Mesh via UDP",
|
||||
properties: {
|
||||
enumValue:
|
||||
Protobuf.Config.Config_NetworkConfig_ProtocolFlags,
|
||||
enumValue: Protobuf.Config.Config_NetworkConfig_ProtocolFlags,
|
||||
formatEnumName: true,
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -74,7 +74,7 @@ export const Position = () => {
|
||||
name: "positionFlags",
|
||||
value: activeFlags,
|
||||
isChecked: (name: string) =>
|
||||
activeFlags?.includes(name as FlagName) ?? false,
|
||||
activeFlags?.includes(name as FlagName) ?? false,
|
||||
onValueChange: onPositonFlagChange,
|
||||
label: "Position Flags",
|
||||
placeholder: "Select position flags...",
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
|
||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import {
|
||||
getX25519PrivateKey,
|
||||
getX25519PublicKey,
|
||||
} from "@core/utils/x25519.ts";
|
||||
import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts";
|
||||
import type { SecurityValidation } from "@app/validation/config/security.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { fromByteArray, toByteArray } from "base64-js";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { useReducer } from "react";
|
||||
import { securityReducer } from "@components/PageComponents/Config/Security/securityReducer.tsx";
|
||||
|
||||
@@ -58,7 +54,8 @@ export const Security = () => {
|
||||
if (input.length % 4 !== 0) {
|
||||
addError(
|
||||
fieldName,
|
||||
`${fieldName === "privateKey" ? "Private" : "Admin"
|
||||
`${
|
||||
fieldName === "privateKey" ? "Private" : "Admin"
|
||||
} Key is required to be a 256 bit pre-shared key (PSK)`,
|
||||
);
|
||||
return;
|
||||
@@ -73,7 +70,8 @@ export const Security = () => {
|
||||
console.error(e);
|
||||
addError(
|
||||
fieldName,
|
||||
`Invalid ${fieldName === "privateKey" ? "Private" : "Admin"
|
||||
`Invalid ${
|
||||
fieldName === "privateKey" ? "Private" : "Admin"
|
||||
} Key format`,
|
||||
);
|
||||
}
|
||||
@@ -242,7 +240,7 @@ export const Security = () => {
|
||||
? getErrorMessage("adminKey")
|
||||
: "",
|
||||
inputChange: adminKeyInputChangeEvent,
|
||||
selectChange: () => { },
|
||||
selectChange: () => {},
|
||||
bits: [{ text: "256 bit", value: "32", key: "bit256" }],
|
||||
devicePSKBitCount: state.privateKeyBitCount,
|
||||
hide: !state.adminKeyVisible,
|
||||
|
||||
@@ -9,10 +9,12 @@ import { BleConnection, ServiceUuid } from "@meshtastic/js";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
||||
|
||||
export const BLE = ({ setConnectionInProgress, closeDialog }: TabElementProps) => {
|
||||
export const BLE = (
|
||||
{ setConnectionInProgress, closeDialog }: TabElementProps,
|
||||
) => {
|
||||
const [bleDevices, setBleDevices] = useState<BluetoothDevice[]>([]);
|
||||
const { addDevice } = useDeviceStore();
|
||||
const messageStore = useMessageStore()
|
||||
const messageStore = useMessageStore();
|
||||
const { setSelectedDevice } = useAppStore();
|
||||
|
||||
const updateBleDeviceList = useCallback(async (): Promise<void> => {
|
||||
@@ -59,8 +61,6 @@ export const BLE = ({ setConnectionInProgress, closeDialog }: TabElementProps) =
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={async () => {
|
||||
|
||||
|
||||
await navigator.bluetooth
|
||||
.requestDevice({
|
||||
filters: [{ services: [ServiceUuid] }],
|
||||
@@ -82,4 +82,4 @@ export const BLE = ({ setConnectionInProgress, closeDialog }: TabElementProps) =
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx";
|
||||
import { MeshDevice } from "@meshtastic/core";
|
||||
import { TransportHTTP } from "@meshtastic/transport-http";
|
||||
import { vi, describe, it, expect } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@core/stores/appStore.ts", () => ({
|
||||
useAppStore: vi.fn(() => ({ setSelectedDevice: vi.fn() })),
|
||||
}));
|
||||
|
||||
vi.mock("@core/stores/deviceStore.ts", () => ({
|
||||
useDeviceStore: vi.fn(() => ({ addDevice: vi.fn(() => ({ addConnection: vi.fn() })) })),
|
||||
useDeviceStore: vi.fn(() => ({
|
||||
addDevice: vi.fn(() => ({ addConnection: vi.fn() })),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@core/utils/randId.ts", () => ({
|
||||
@@ -28,13 +30,13 @@ vi.mock("@meshtastic/core", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
|
||||
describe("HTTP Component", () => {
|
||||
it("renders correctly", () => {
|
||||
render(<HTTP closeDialog={vi.fn()} />);
|
||||
expect(screen.getByText("IP Address/Hostname")).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("000.000.000.000 / meshtastic.local")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("000.000.000.000 / meshtastic.local"))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByText("Use HTTPS")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Connect" })).toBeInTheDocument();
|
||||
});
|
||||
@@ -42,8 +44,9 @@ describe("HTTP Component", () => {
|
||||
it("allows input field to be updated", () => {
|
||||
render(<HTTP closeDialog={vi.fn()} />);
|
||||
const inputField = screen.getByRole("textbox");
|
||||
fireEvent.change(inputField, { target: { value: 'meshtastic.local' } })
|
||||
expect(screen.getByPlaceholderText("000.000.000.000 / meshtastic.local")).toBeInTheDocument();
|
||||
fireEvent.change(inputField, { target: { value: "meshtastic.local" } });
|
||||
expect(screen.getByPlaceholderText("000.000.000.000 / meshtastic.local"))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles HTTPS switch and updates prefix", () => {
|
||||
@@ -52,10 +55,10 @@ describe("HTTP Component", () => {
|
||||
const switchInput = screen.getByRole("switch");
|
||||
expect(screen.getByText("http://")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(switchInput)
|
||||
fireEvent.click(switchInput);
|
||||
expect(screen.getByText("https://")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(switchInput)
|
||||
fireEvent.click(switchInput);
|
||||
expect(switchInput).not.toBeChecked();
|
||||
expect(screen.getByText("http://")).toBeInTheDocument();
|
||||
});
|
||||
@@ -89,8 +92,7 @@ describe("HTTP Component", () => {
|
||||
expect(MeshDevice).toBeCalled();
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
console.error(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { randId } from "@core/utils/randId.ts";
|
||||
import { MeshDevice } from "@meshtastic/core";
|
||||
import { TransportHTTP } from "@meshtastic/transport-http";
|
||||
import { useState } from "react";
|
||||
import { useForm, useController } from "react-hook-form";
|
||||
import { useController, useForm } from "react-hook-form";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
||||
|
||||
@@ -20,7 +20,10 @@ interface FormData {
|
||||
tls: boolean;
|
||||
}
|
||||
|
||||
export const HTTP = ({ closeDialog, setConnectionInProgress, connectionInProgress }: TabElementProps) => {
|
||||
export const HTTP = (
|
||||
{ closeDialog, setConnectionInProgress, connectionInProgress }:
|
||||
TabElementProps,
|
||||
) => {
|
||||
const isURLHTTPS = location.protocol === "https:";
|
||||
|
||||
const { addDevice } = useDeviceStore();
|
||||
@@ -30,8 +33,8 @@ export const HTTP = ({ closeDialog, setConnectionInProgress, connectionInProgres
|
||||
const { control, handleSubmit, register } = useForm<FormData>({
|
||||
defaultValues: {
|
||||
ip: ["client.meshtastic.org", "localhost"].includes(
|
||||
globalThis.location.hostname,
|
||||
)
|
||||
globalThis.location.hostname,
|
||||
)
|
||||
? "meshtastic.local"
|
||||
: globalThis.location.host,
|
||||
tls: isURLHTTPS ? true : false,
|
||||
@@ -42,7 +45,9 @@ export const HTTP = ({ closeDialog, setConnectionInProgress, connectionInProgres
|
||||
field: { value: tlsValue, onChange: setTLS },
|
||||
} = useController({ name: "tls", control });
|
||||
|
||||
const [connectionError, setConnectionError] = useState<{ host: string; secure: boolean } | null>(null);
|
||||
const [connectionError, setConnectionError] = useState<
|
||||
{ host: string; secure: boolean } | null
|
||||
>(null);
|
||||
|
||||
const onSubmit = handleSubmit(async (data) => {
|
||||
setConnectionInProgress(true);
|
||||
@@ -91,21 +96,31 @@ export const HTTP = ({ closeDialog, setConnectionInProgress, connectionInProgres
|
||||
{connectionError && (
|
||||
<div className="mt-2 mb-2 p-3 rounded-md bg-amber-100 border border-amber-300 dark:bg-amber-100 dark:border-amber-300">
|
||||
<div className="flex gap-2 items-start">
|
||||
<AlertTriangle className="shrink-0 mt-0.5 text-amber-600 dark:text-amber-600" size={20} />
|
||||
<AlertTriangle
|
||||
className="shrink-0 mt-0.5 text-amber-600 dark:text-amber-600"
|
||||
size={20}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-800 dark:text-amber-800">
|
||||
Connection Failed
|
||||
</p>
|
||||
<p className="text-xs mt-1 text-amber-700 dark:text-amber-700">
|
||||
Could not connect to the device. {connectionError.secure && "If using HTTPS, you may need to accept a self-signed certificate first. "}
|
||||
Could not connect to the device. {connectionError.secure &&
|
||||
"If using HTTPS, you may need to accept a self-signed certificate first. "}
|
||||
Please open{" "}
|
||||
<Link
|
||||
href={`${connectionError.secure ? "https" : "http"}://${connectionError.host}`}
|
||||
href={`${
|
||||
connectionError.secure ? "https" : "http"
|
||||
}://${connectionError.host}`}
|
||||
className="underline font-medium text-amber-800 dark:text-amber-800"
|
||||
>
|
||||
{`${connectionError.secure ? "https" : "http"}://${connectionError.host}`}
|
||||
{`${
|
||||
connectionError.secure ? "https" : "http"
|
||||
}://${connectionError.host}`}
|
||||
</Link>{" "}
|
||||
in a new tab{connectionError.secure ? ", accept any TLS warnings if prompted, then try again" : ""}.{" "}
|
||||
in a new tab{connectionError.secure
|
||||
? ", accept any TLS warnings if prompted, then try again"
|
||||
: ""}.{" "}
|
||||
<Link
|
||||
href="https://meshtastic.org/docs/software/web-client/#http"
|
||||
className="underline font-medium text-amber-800 dark:text-amber-800"
|
||||
@@ -120,10 +135,10 @@ export const HTTP = ({ closeDialog, setConnectionInProgress, connectionInProgres
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant={"default"}
|
||||
variant="default"
|
||||
>
|
||||
<span>{connectionInProgress ? "Connecting..." : "Connect"}</span>
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -10,10 +10,12 @@ import { TransportWebSerial } from "@meshtastic/transport-web-serial";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
||||
|
||||
export const Serial = ({ setConnectionInProgress, closeDialog }: TabElementProps) => {
|
||||
export const Serial = (
|
||||
{ setConnectionInProgress, closeDialog }: TabElementProps,
|
||||
) => {
|
||||
const [serialPorts, setSerialPorts] = useState<SerialPort[]>([]);
|
||||
const { addDevice } = useDeviceStore();
|
||||
const messageStore = useMessageStore()
|
||||
const messageStore = useMessageStore();
|
||||
const { setSelectedDevice } = useAppStore();
|
||||
|
||||
const updateSerialPortList = useCallback(async () => {
|
||||
@@ -58,8 +60,9 @@ export const Serial = ({ setConnectionInProgress, closeDialog }: TabElementProps
|
||||
await onConnect(port);
|
||||
}}
|
||||
>
|
||||
{`# ${index} - ${usbVendorId ?? "UNK"} - ${usbProductId ?? "UNK"
|
||||
}`}
|
||||
{`# ${index} - ${usbVendorId ?? "UNK"} - ${
|
||||
usbProductId ?? "UNK"
|
||||
}`}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -23,7 +23,10 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@radix-ui/react-tooltip";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { MessageType, useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
||||
import {
|
||||
MessageType,
|
||||
useMessageStore,
|
||||
} from "../../../core/stores/messageStore/index.ts";
|
||||
import BatteryStatus from "@components/BatteryStatus.tsx";
|
||||
|
||||
export interface NodeDetailProps {
|
||||
@@ -51,10 +54,12 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
||||
<div className="flex flex-col items-center gap-2 min-w-6 pt-1">
|
||||
<Avatar text={shortName} size="sm" />
|
||||
|
||||
<div onFocusCapture={(e) => {
|
||||
// Required to prevent DM tooltip auto-appearing on creation
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
<div
|
||||
onFocusCapture={(e) => {
|
||||
// Required to prevent DM tooltip auto-appearing on creation
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{node.user?.publicKey && node.user?.publicKey.length > 0
|
||||
? (
|
||||
<LockIcon
|
||||
@@ -177,7 +182,9 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
||||
{!!node.deviceMetrics?.airUtilTx && (
|
||||
<div className="grow">
|
||||
<div>Airtime Util</div>
|
||||
<Mono className="text-gray-500">{node.deviceMetrics?.airUtilTx.toPrecision(3)}%</Mono>
|
||||
<Mono className="text-gray-500">
|
||||
{node.deviceMetrics?.airUtilTx.toPrecision(3)}%
|
||||
</Mono>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@ export const ChannelChat = ({ messages = [] }: ChannelChatProps) => {
|
||||
const scrollContainerRef = useRef<HTMLUListElement>(null);
|
||||
const userScrolledUpRef = useRef(false);
|
||||
|
||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
|
||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
|
||||
requestAnimationFrame(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior });
|
||||
});
|
||||
@@ -28,10 +28,12 @@ export const ChannelChat = ({ messages = [] }: ChannelChatProps) => {
|
||||
useEffect(() => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (!scrollContainer) return;
|
||||
const isScrolledToBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight <= 10;
|
||||
const isScrolledToBottom =
|
||||
scrollContainer.scrollHeight - scrollContainer.scrollTop -
|
||||
scrollContainer.clientHeight <= 10;
|
||||
|
||||
if (isScrolledToBottom || !userScrolledUpRef.current) {
|
||||
scrollToBottom('smooth');
|
||||
scrollToBottom("smooth");
|
||||
}
|
||||
}, [messages, scrollToBottom]);
|
||||
|
||||
@@ -39,12 +41,16 @@ export const ChannelChat = ({ messages = [] }: ChannelChatProps) => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
const handleScroll = () => {
|
||||
if (!scrollContainer) return;
|
||||
const isAtBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight <= 10;
|
||||
const isAtBottom =
|
||||
scrollContainer.scrollHeight - scrollContainer.scrollTop -
|
||||
scrollContainer.clientHeight <= 10;
|
||||
userScrolledUpRef.current = !isAtBottom;
|
||||
};
|
||||
scrollContainer?.addEventListener('scroll', handleScroll, { passive: true });
|
||||
scrollContainer?.addEventListener("scroll", handleScroll, {
|
||||
passive: true,
|
||||
});
|
||||
return () => {
|
||||
scrollContainer?.removeEventListener('scroll', handleScroll);
|
||||
scrollContainer?.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -74,4 +80,4 @@ export const ChannelChat = ({ messages = [] }: ChannelChatProps) => {
|
||||
<div ref={messagesEndRef} className="h-px" />
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@components/UI/Tooltip.tsx";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { SmilePlus, Reply } from "lucide-react";
|
||||
import { Reply, SmilePlus } from "lucide-react";
|
||||
|
||||
interface MessageActionsMenuProps {
|
||||
onAddReaction?: () => void;
|
||||
@@ -15,7 +15,7 @@ interface MessageActionsMenuProps {
|
||||
|
||||
export const MessageActionsMenu = ({
|
||||
onAddReaction,
|
||||
onReply
|
||||
onReply,
|
||||
}: MessageActionsMenuProps) => {
|
||||
const hoverIconBarClass = cn(
|
||||
"absolute top-2 right-2",
|
||||
@@ -25,7 +25,7 @@ export const MessageActionsMenu = ({
|
||||
"rounded-md shadow-sm p-1",
|
||||
"opacity-0 group-hover:opacity-100",
|
||||
"transition-opacity duration-100 ease-in-out",
|
||||
"z-10"
|
||||
"z-10",
|
||||
);
|
||||
|
||||
const hoverIconButtonClass = cn(
|
||||
@@ -33,13 +33,16 @@ export const MessageActionsMenu = ({
|
||||
"text-gray-500 dark:text-gray-400",
|
||||
"hover:text-gray-700 dark:hover:text-gray-300",
|
||||
"hover:bg-gray-100 dark:hover:bg-zinc-700",
|
||||
"cursor-pointer"
|
||||
"cursor-pointer",
|
||||
);
|
||||
|
||||
const iconSizeClass = "size-4";
|
||||
|
||||
return (
|
||||
<div className={cn(hoverIconBarClass)} onClick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
className={cn(hoverIconBarClass)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -47,7 +50,7 @@ export const MessageActionsMenu = ({
|
||||
type="button"
|
||||
aria-label="Add Reaction"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.stopPropagation();
|
||||
if (onAddReaction) {
|
||||
onAddReaction();
|
||||
}
|
||||
@@ -69,7 +72,7 @@ export const MessageActionsMenu = ({
|
||||
type="button"
|
||||
aria-label="Reply"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.stopPropagation();
|
||||
if (onReply) {
|
||||
onReply();
|
||||
}
|
||||
@@ -85,7 +88,6 @@ export const MessageActionsMenu = ({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,18 +1,34 @@
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { MessageInput, MessageInputProps } from './MessageInput.tsx';
|
||||
import { Types } from '@meshtastic/core';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MessageInput, MessageInputProps } from "./MessageInput.tsx";
|
||||
import { Types } from "@meshtastic/core";
|
||||
|
||||
vi.mock('@components/UI/Button.tsx', () => ({
|
||||
Button: vi.fn(({ type, className, children, onClick, onSubmit, variant, ...rest }) => (
|
||||
<button type={type} className={className} onClick={onClick} onSubmit={onSubmit} {...rest}>
|
||||
vi.mock("@components/UI/Button.tsx", () => ({
|
||||
Button: vi.fn((
|
||||
{ type, className, children, onClick, onSubmit, ...rest },
|
||||
) => (
|
||||
<button
|
||||
type={type}
|
||||
className={className}
|
||||
onClick={onClick}
|
||||
onSubmit={onSubmit}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)),
|
||||
}));
|
||||
|
||||
vi.mock('@components/UI/Input.tsx', () => ({
|
||||
Input: vi.fn(({ autoFocus, minLength, name, placeholder, value, onChange }) => (
|
||||
vi.mock("@components/UI/Input.tsx", () => ({
|
||||
Input: vi.fn((
|
||||
{ autoFocus, minLength, name, placeholder, value, onChange },
|
||||
) => (
|
||||
<input
|
||||
autoFocus={autoFocus}
|
||||
minLength={minLength}
|
||||
@@ -29,28 +45,28 @@ const mockSetDraft = vi.fn();
|
||||
const mockGetDraft = vi.fn();
|
||||
const mockClearDraft = vi.fn();
|
||||
|
||||
vi.mock('@core/stores/messageStore', () => ({
|
||||
vi.mock("@core/stores/messageStore", () => ({
|
||||
useMessageStore: vi.fn(() => ({
|
||||
setDraft: mockSetDraft,
|
||||
getDraft: mockGetDraft,
|
||||
clearDraft: mockClearDraft,
|
||||
})),
|
||||
MessageState: {
|
||||
Ack: 'ack',
|
||||
Waiting: 'waiting',
|
||||
Failed: 'failed',
|
||||
Ack: "ack",
|
||||
Waiting: "waiting",
|
||||
Failed: "failed",
|
||||
},
|
||||
MessageType: {
|
||||
Direct: 'direct',
|
||||
Broadcast: 'broadcast',
|
||||
Direct: "direct",
|
||||
Broadcast: "broadcast",
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
vi.mock("lucide-react", () => ({
|
||||
SendIcon: vi.fn(() => <svg data-testid="send-icon" />),
|
||||
}));
|
||||
|
||||
describe('MessageInput', () => {
|
||||
describe("MessageInput", () => {
|
||||
const mockOnSend = vi.fn();
|
||||
const defaultProps: MessageInputProps = {
|
||||
onSend: mockOnSend,
|
||||
@@ -61,56 +77,62 @@ describe('MessageInput', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockGetDraft.mockReturnValue('');
|
||||
mockGetDraft.mockReturnValue("");
|
||||
});
|
||||
|
||||
const renderComponent = (props: Partial<MessageInputProps> = {}) => {
|
||||
render(<MessageInput {...defaultProps} {...props} />);
|
||||
};
|
||||
|
||||
it('should render the input field, byte counter, and send button', () => {
|
||||
it("should render the input field, byte counter, and send button", () => {
|
||||
renderComponent();
|
||||
expect(screen.getByPlaceholderText('Enter Message')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('byte-counter')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('send-icon')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("Enter Message")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("byte-counter")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("send-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should initialize with the draft from the store', () => {
|
||||
const initialDraft = 'Existing draft message';
|
||||
it("should initialize with the draft from the store", () => {
|
||||
const initialDraft = "Existing draft message";
|
||||
mockGetDraft.mockImplementation((key) => {
|
||||
return key === defaultProps.to ? initialDraft : '';
|
||||
return key === defaultProps.to ? initialDraft : "";
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
const inputElement = screen.getByPlaceholderText('Enter Message') as HTMLInputElement;
|
||||
const inputElement = screen.getByPlaceholderText(
|
||||
"Enter Message",
|
||||
) as HTMLInputElement;
|
||||
expect(inputElement.value).toBe(initialDraft);
|
||||
expect(mockGetDraft).toHaveBeenCalledWith(defaultProps.to);
|
||||
const expectedBytes = new Blob([initialDraft]).size;
|
||||
expect(screen.getByTestId('byte-counter')).toHaveTextContent(`${expectedBytes}/${defaultProps.maxBytes}`);
|
||||
expect(screen.getByTestId("byte-counter")).toHaveTextContent(
|
||||
`${expectedBytes}/${defaultProps.maxBytes}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should update input value, byte counter, and call setDraft on change within limits', () => {
|
||||
it("should update input value, byte counter, and call setDraft on change within limits", () => {
|
||||
renderComponent();
|
||||
const inputElement = screen.getByPlaceholderText('Enter Message');
|
||||
const testMessage = 'Hello there!';
|
||||
const inputElement = screen.getByPlaceholderText("Enter Message");
|
||||
const testMessage = "Hello there!";
|
||||
const expectedBytes = new Blob([testMessage]).size;
|
||||
|
||||
fireEvent.change(inputElement, { target: { value: testMessage } });
|
||||
|
||||
expect((inputElement as HTMLInputElement).value).toBe(testMessage);
|
||||
expect(screen.getByTestId('byte-counter')).toHaveTextContent(`${expectedBytes}/${defaultProps.maxBytes}`);
|
||||
expect(screen.getByTestId("byte-counter")).toHaveTextContent(
|
||||
`${expectedBytes}/${defaultProps.maxBytes}`,
|
||||
);
|
||||
expect(mockSetDraft).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetDraft).toHaveBeenCalledWith(defaultProps.to, testMessage);
|
||||
});
|
||||
|
||||
it('should NOT update input value or call setDraft if maxBytes is exceeded', () => {
|
||||
it("should NOT update input value or call setDraft if maxBytes is exceeded", () => {
|
||||
const smallMaxBytes = 5;
|
||||
renderComponent({ maxBytes: smallMaxBytes });
|
||||
const inputElement = screen.getByPlaceholderText('Enter Message');
|
||||
const initialValue = '12345';
|
||||
const excessiveValue = '123456';
|
||||
const inputElement = screen.getByPlaceholderText("Enter Message");
|
||||
const initialValue = "12345";
|
||||
const excessiveValue = "123456";
|
||||
|
||||
fireEvent.change(inputElement, { target: { value: initialValue } });
|
||||
expect((inputElement as HTMLInputElement).value).toBe(initialValue);
|
||||
@@ -120,15 +142,17 @@ describe('MessageInput', () => {
|
||||
fireEvent.change(inputElement, { target: { value: excessiveValue } });
|
||||
|
||||
expect((inputElement as HTMLInputElement).value).toBe(initialValue);
|
||||
expect(screen.getByTestId('byte-counter')).toHaveTextContent(`${smallMaxBytes}/${smallMaxBytes}`);
|
||||
expect(screen.getByTestId("byte-counter")).toHaveTextContent(
|
||||
`${smallMaxBytes}/${smallMaxBytes}`,
|
||||
);
|
||||
expect(mockSetDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onSend, clear input, reset byte counter, and call clearDraft on valid submit', async () => {
|
||||
it("should call onSend, clear input, reset byte counter, and call clearDraft on valid submit", async () => {
|
||||
renderComponent();
|
||||
const inputElement = screen.getByPlaceholderText('Enter Message');
|
||||
const formElement = screen.getByRole('form');
|
||||
const testMessage = 'Send this message';
|
||||
const inputElement = screen.getByPlaceholderText("Enter Message");
|
||||
const formElement = screen.getByRole("form");
|
||||
const testMessage = "Send this message";
|
||||
|
||||
fireEvent.change(inputElement, { target: { value: testMessage } });
|
||||
fireEvent.submit(formElement);
|
||||
@@ -136,21 +160,25 @@ describe('MessageInput', () => {
|
||||
await waitFor(() => {
|
||||
expect(mockOnSend).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnSend).toHaveBeenCalledWith(testMessage);
|
||||
expect((inputElement as HTMLInputElement).value).toBe('');
|
||||
expect(screen.getByTestId('byte-counter')).toHaveTextContent(`0/${defaultProps.maxBytes}`);
|
||||
expect((inputElement as HTMLInputElement).value).toBe("");
|
||||
expect(screen.getByTestId("byte-counter")).toHaveTextContent(
|
||||
`0/${defaultProps.maxBytes}`,
|
||||
);
|
||||
expect(mockClearDraft).toHaveBeenCalledTimes(1);
|
||||
expect(mockClearDraft).toHaveBeenCalledWith(defaultProps.to);
|
||||
});
|
||||
});
|
||||
|
||||
it('should trim whitespace before calling onSend', async () => {
|
||||
it("should trim whitespace before calling onSend", async () => {
|
||||
renderComponent();
|
||||
const inputElement = screen.getByPlaceholderText('Enter Message');
|
||||
const formElement = screen.getByRole('form');
|
||||
const testMessageWithWhitespace = ' Trim me! ';
|
||||
const expectedTrimmedMessage = 'Trim me!';
|
||||
const inputElement = screen.getByPlaceholderText("Enter Message");
|
||||
const formElement = screen.getByRole("form");
|
||||
const testMessageWithWhitespace = " Trim me! ";
|
||||
const expectedTrimmedMessage = "Trim me!";
|
||||
|
||||
fireEvent.change(inputElement, { target: { value: testMessageWithWhitespace } });
|
||||
fireEvent.change(inputElement, {
|
||||
target: { value: testMessageWithWhitespace },
|
||||
});
|
||||
fireEvent.submit(formElement);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -160,28 +188,28 @@ describe('MessageInput', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should not call onSend or clearDraft if input is empty on submit', async () => {
|
||||
it("should not call onSend or clearDraft if input is empty on submit", async () => {
|
||||
renderComponent();
|
||||
const inputElement = screen.getByPlaceholderText('Enter Message');
|
||||
const formElement = screen.getByRole('form');
|
||||
const inputElement = screen.getByPlaceholderText("Enter Message");
|
||||
const formElement = screen.getByRole("form");
|
||||
|
||||
expect((inputElement as HTMLInputElement).value).toBe('');
|
||||
expect((inputElement as HTMLInputElement).value).toBe("");
|
||||
|
||||
fireEvent.submit(formElement);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
});
|
||||
|
||||
expect(mockOnSend).not.toHaveBeenCalled();
|
||||
expect(mockClearDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call onSend or clearDraft if input contains only whitespace on submit', async () => {
|
||||
it("should not call onSend or clearDraft if input contains only whitespace on submit", async () => {
|
||||
renderComponent();
|
||||
const inputElement = screen.getByTestId('message-input-field');
|
||||
const formElement = screen.getByRole('form');
|
||||
const whitespaceMessage = ' \t ';
|
||||
const inputElement = screen.getByTestId("message-input-field");
|
||||
const formElement = screen.getByRole("form");
|
||||
const whitespaceMessage = " \t ";
|
||||
|
||||
fireEvent.change(inputElement, { target: { value: whitespaceMessage } });
|
||||
expect((inputElement as HTMLInputElement).value).toBe(whitespaceMessage);
|
||||
@@ -189,7 +217,7 @@ describe('MessageInput', () => {
|
||||
fireEvent.submit(formElement);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
});
|
||||
|
||||
expect(mockOnSend).not.toHaveBeenCalled();
|
||||
@@ -198,18 +226,22 @@ describe('MessageInput', () => {
|
||||
expect((inputElement as HTMLInputElement).value).toBe(whitespaceMessage);
|
||||
});
|
||||
|
||||
it('should work with broadcast destination for drafts', () => {
|
||||
const broadcastDest: Types.Destination = 'broadcast';
|
||||
mockGetDraft.mockImplementation((key) => key === broadcastDest ? 'Broadcast draft' : '');
|
||||
it("should work with broadcast destination for drafts", () => {
|
||||
const broadcastDest: Types.Destination = "broadcast";
|
||||
mockGetDraft.mockImplementation((key) =>
|
||||
key === broadcastDest ? "Broadcast draft" : ""
|
||||
);
|
||||
|
||||
renderComponent({ to: broadcastDest });
|
||||
|
||||
expect(mockGetDraft).toHaveBeenCalledWith(broadcastDest);
|
||||
expect((screen.getByPlaceholderText('Enter Message') as HTMLInputElement).value).toBe('Broadcast draft');
|
||||
expect(
|
||||
(screen.getByPlaceholderText("Enter Message") as HTMLInputElement).value,
|
||||
).toBe("Broadcast draft");
|
||||
|
||||
const inputElement = screen.getByPlaceholderText('Enter Message');
|
||||
const formElement = screen.getByRole('form');
|
||||
const newMessage = 'New broadcast msg';
|
||||
const inputElement = screen.getByPlaceholderText("Enter Message");
|
||||
const formElement = screen.getByRole("form");
|
||||
const newMessage = "New broadcast msg";
|
||||
|
||||
fireEvent.change(inputElement, { target: { value: newMessage } });
|
||||
expect(mockSetDraft).toHaveBeenCalledWith(broadcastDest, newMessage);
|
||||
@@ -219,4 +251,4 @@ describe('MessageInput', () => {
|
||||
expect(mockOnSend).toHaveBeenCalledWith(newMessage);
|
||||
expect(mockClearDraft).toHaveBeenCalledWith(broadcastDest);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,7 +22,9 @@ export const MessageInput = ({
|
||||
|
||||
const initialDraft = getDraft(to);
|
||||
const [localDraft, setLocalDraft] = useState(initialDraft);
|
||||
const [messageBytes, setMessageBytes] = useState(() => calculateBytes(initialDraft));
|
||||
const [messageBytes, setMessageBytes] = useState(() =>
|
||||
calculateBytes(initialDraft)
|
||||
);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
@@ -63,7 +65,10 @@ export const MessageInput = ({
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label data-testid="byte-counter" className="flex items-center w-20 p-1 text-sm place-content-end">
|
||||
<label
|
||||
data-testid="byte-counter"
|
||||
className="flex items-center w-20 p-1 text-sm place-content-end"
|
||||
>
|
||||
{messageBytes}/{maxBytes}
|
||||
</label>
|
||||
|
||||
@@ -77,4 +82,4 @@ export const MessageInput = ({
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -11,7 +11,10 @@ import { Avatar } from "@components/UI/Avatar.tsx";
|
||||
import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { ReactNode, useMemo } from "react";
|
||||
import { MessageState, useMessageStore } from "@core/stores/messageStore/index.ts";
|
||||
import {
|
||||
MessageState,
|
||||
useMessageStore,
|
||||
} from "@core/stores/messageStore/index.ts";
|
||||
import { Protobuf, Types } from "@meshtastic/js";
|
||||
import { Message } from "@core/stores/messageStore/types.ts";
|
||||
// import { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx"; // Uncomment if needed later
|
||||
@@ -24,17 +27,42 @@ interface MessageStatusInfo {
|
||||
}
|
||||
|
||||
const MESSAGE_STATUS_MAP: Record<MessageState, MessageStatusInfo> = {
|
||||
[MessageState.Ack]: { displayText: "Message delivered", icon: CheckCircle2, ariaLabel: "Message delivered", iconClassName: "text-green-500" },
|
||||
[MessageState.Waiting]: { displayText: "Waiting for delivery", icon: CircleEllipsis, ariaLabel: "Sending message", iconClassName: "text-slate-400" },
|
||||
[MessageState.Failed]: { displayText: "Delivery failed", icon: AlertCircle, ariaLabel: "Message delivery failed", iconClassName: "text-red-500 dark:text-red-400" },
|
||||
[MessageState.Ack]: {
|
||||
displayText: "Message delivered",
|
||||
icon: CheckCircle2,
|
||||
ariaLabel: "Message delivered",
|
||||
iconClassName: "text-green-500",
|
||||
},
|
||||
[MessageState.Waiting]: {
|
||||
displayText: "Waiting for delivery",
|
||||
icon: CircleEllipsis,
|
||||
ariaLabel: "Sending message",
|
||||
iconClassName: "text-slate-400",
|
||||
},
|
||||
[MessageState.Failed]: {
|
||||
displayText: "Delivery failed",
|
||||
icon: AlertCircle,
|
||||
ariaLabel: "Message delivery failed",
|
||||
iconClassName: "text-red-500 dark:text-red-400",
|
||||
},
|
||||
};
|
||||
|
||||
const UNKNOWN_STATUS: MessageStatusInfo = { displayText: "Unknown state", icon: AlertCircle, ariaLabel: "Message status unknown", iconClassName: "text-red-500 dark:text-red-400" };
|
||||
const UNKNOWN_STATUS: MessageStatusInfo = {
|
||||
displayText: "Unknown state",
|
||||
icon: AlertCircle,
|
||||
ariaLabel: "Message status unknown",
|
||||
iconClassName: "text-red-500 dark:text-red-400",
|
||||
};
|
||||
|
||||
const getMessageStatusInfo = (state: MessageState): MessageStatusInfo =>
|
||||
MESSAGE_STATUS_MAP[state] ?? UNKNOWN_STATUS;
|
||||
|
||||
const StatusTooltip = ({ statusInfo, children }: { statusInfo: MessageStatusInfo; children: ReactNode }) => (
|
||||
const StatusTooltip = (
|
||||
{ statusInfo, children }: {
|
||||
statusInfo: MessageStatusInfo;
|
||||
children: ReactNode;
|
||||
},
|
||||
) => (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
@@ -52,18 +80,17 @@ interface MessageItemProps {
|
||||
|
||||
export const MessageItem = ({ message }: MessageItemProps) => {
|
||||
const { getNode } = useDevice();
|
||||
const { getMyNodeNum } = useMessageStore()
|
||||
const { getMyNodeNum } = useMessageStore();
|
||||
|
||||
const messageUser: Protobuf.Mesh.NodeInfo | null | undefined = useMemo(() => {
|
||||
return message.from != null ? getNode(message.from) : null;
|
||||
}, [getNode, message.from]);
|
||||
|
||||
|
||||
const myNodeNum = useMemo(() => getMyNodeNum(), [getMyNodeNum]);
|
||||
const { displayName, shortName } = useMemo(() => {
|
||||
const userIdHex = message.from.toString(16).toUpperCase().padStart(2, '0');
|
||||
const userIdHex = message.from.toString(16).toUpperCase().padStart(2, "0");
|
||||
const last4 = userIdHex.slice(-4);
|
||||
const fallbackName = `Meshtastic ${last4}`
|
||||
const fallbackName = `Meshtastic ${last4}`;
|
||||
const longName = messageUser?.user?.longName;
|
||||
const derivedShortName = messageUser?.user?.shortName || fallbackName;
|
||||
const derivedDisplayName = longName || derivedShortName;
|
||||
@@ -73,22 +100,35 @@ export const MessageItem = ({ message }: MessageItemProps) => {
|
||||
const messageStatusInfo = getMessageStatusInfo(message.state);
|
||||
const StatusIconComponent = messageStatusInfo.icon;
|
||||
|
||||
const messageDate = useMemo(() => message.date ? new Date(message.date) : null, [message.date]);
|
||||
const locale = 'en-US'; // TODO: Make dynamic via props or context
|
||||
const messageDate = useMemo(
|
||||
() => message.date ? new Date(message.date) : null,
|
||||
[message.date],
|
||||
);
|
||||
const locale = "en-US"; // TODO: Make dynamic via props or context
|
||||
|
||||
const formattedTime = useMemo(() =>
|
||||
messageDate?.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit', hour12: true }) ?? '',
|
||||
[messageDate, locale]);
|
||||
const formattedTime = useMemo(
|
||||
() =>
|
||||
messageDate?.toLocaleTimeString(locale, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
}) ?? "",
|
||||
[messageDate, locale],
|
||||
);
|
||||
|
||||
const fullDateTime = useMemo(() =>
|
||||
messageDate?.toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' }) ?? '',
|
||||
[messageDate, locale]);
|
||||
const fullDateTime = useMemo(
|
||||
() =>
|
||||
messageDate?.toLocaleString(locale, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}) ?? "",
|
||||
[messageDate, locale],
|
||||
);
|
||||
|
||||
const isSender = myNodeNum !== undefined && message.from === myNodeNum;
|
||||
const isOnPrimaryChannel = message.channel === Types.ChannelNumber.Primary; // Use the enum
|
||||
const shouldShowStatusIcon = isSender && isOnPrimaryChannel;
|
||||
|
||||
|
||||
const messageItemWrapperClass = cn(
|
||||
"group w-full py-2 relative list-none",
|
||||
"rounded-md",
|
||||
@@ -97,7 +137,6 @@ export const MessageItem = ({ message }: MessageItemProps) => {
|
||||
);
|
||||
const dateTextStyle = "text-xs text-slate-500 dark:text-slate-400";
|
||||
|
||||
|
||||
return (
|
||||
<li className={messageItemWrapperClass}>
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-2">
|
||||
@@ -109,7 +148,10 @@ export const MessageItem = ({ message }: MessageItemProps) => {
|
||||
{displayName}
|
||||
</span>
|
||||
{messageDate && (
|
||||
<time dateTime={messageDate.toISOString()} className={dateTextStyle}>
|
||||
<time
|
||||
dateTime={messageDate.toISOString()}
|
||||
className={dateTextStyle}
|
||||
>
|
||||
<span aria-hidden="true">{formattedTime}</span>
|
||||
<span className="sr-only">{fullDateTime}</span>
|
||||
</time>
|
||||
@@ -118,7 +160,10 @@ export const MessageItem = ({ message }: MessageItemProps) => {
|
||||
<StatusTooltip statusInfo={messageStatusInfo}>
|
||||
<span aria-label={messageStatusInfo.ariaLabel} role="img">
|
||||
<StatusIconComponent
|
||||
className={cn("size-4 shrink-0", messageStatusInfo.iconClassName)}
|
||||
className={cn(
|
||||
"size-4 shrink-0",
|
||||
messageStatusInfo.iconClassName,
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
@@ -134,9 +179,11 @@ export const MessageItem = ({ message }: MessageItemProps) => {
|
||||
</div>
|
||||
</div>
|
||||
{/* Actions Menu Placeholder */}
|
||||
{/* <div className="absolute top-1 right-1">
|
||||
{
|
||||
/* <div className="absolute top-1 right-1">
|
||||
<MessageActionsMenu onReply={() => console.log("Reply")} />
|
||||
</div> */}
|
||||
</div> */
|
||||
}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
@@ -34,11 +34,11 @@ describe("TraceRoute", () => {
|
||||
it("renders the route to destination with SNR values", () => {
|
||||
render(
|
||||
<TraceRoute
|
||||
from={{ user: { longName: "Source Node" } } as any}
|
||||
to={{ user: { longName: "Destination Node" } } as any}
|
||||
from={{ user: { longName: "Source Node" } }}
|
||||
to={{ user: { longName: "Destination Node" } }}
|
||||
route={[1, 2]}
|
||||
snrTowards={[10, 20, 30]}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByText("Source Node")).toHaveLength(1);
|
||||
@@ -56,13 +56,13 @@ describe("TraceRoute", () => {
|
||||
it("renders the route back when provided", () => {
|
||||
render(
|
||||
<TraceRoute
|
||||
from={{ user: { longName: "Source Node" } } as any}
|
||||
to={{ user: { longName: "Destination Node" } } as any}
|
||||
from={{ user: { longName: "Source Node" } }}
|
||||
to={{ user: { longName: "Destination Node" } }}
|
||||
route={[1]}
|
||||
snrTowards={[15, 25]}
|
||||
routeBack={[3]}
|
||||
snrBack={[35, 45]}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Route back:")).toBeInTheDocument();
|
||||
@@ -79,16 +79,15 @@ describe("TraceRoute", () => {
|
||||
|
||||
expect(screen.getByText("↓ 15dB")).toBeInTheDocument();
|
||||
expect(screen.getByText("↓ 25dB")).toBeInTheDocument();
|
||||
|
||||
});
|
||||
|
||||
it("renders '??' for missing SNR values", () => {
|
||||
render(
|
||||
<TraceRoute
|
||||
from={{ user: { longName: "Source" } } as any}
|
||||
to={{ user: { longName: "Dest" } } as any}
|
||||
from={{ user: { longName: "Source" } }}
|
||||
to={{ user: { longName: "Dest" } }}
|
||||
route={[1]}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Node A")).toBeInTheDocument();
|
||||
@@ -102,11 +101,11 @@ describe("TraceRoute", () => {
|
||||
to={{ user: { longName: "Dest" } } as unknown}
|
||||
route={[99]}
|
||||
snrTowards={[5, 15]}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/^!63$/)).toBeInTheDocument();
|
||||
expect(screen.getByText("↓ 5dB")).toBeInTheDocument();
|
||||
expect(screen.getByText("↓ 15dB")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,17 +19,24 @@ interface RoutePathProps {
|
||||
snr?: number[];
|
||||
}
|
||||
|
||||
const RoutePath = ({ title, startNode, endNode, path, snr }: RoutePathProps) => {
|
||||
const RoutePath = (
|
||||
{ title, startNode, endNode, path, snr }: RoutePathProps,
|
||||
) => {
|
||||
const { getNode } = useDevice();
|
||||
|
||||
return (
|
||||
<span id={title} className="ml-4 border-l-2 border-l-background-primary pl-2 text-slate-900 dark:text-slate-900">
|
||||
<span
|
||||
id={title}
|
||||
className="ml-4 border-l-2 border-l-background-primary pl-2 text-slate-900 dark:text-slate-900"
|
||||
>
|
||||
<p className="font-semibold">{title}</p>
|
||||
<p>{startNode?.user?.longName}</p>
|
||||
<p>↓ {snr?.[0] ?? "??"}dB</p>
|
||||
{path.map((hop, i) => (
|
||||
<span key={getNode(hop)?.num ?? hop}>
|
||||
<p>{getNode(hop)?.user?.longName ?? `!${numberToHexUnpadded(hop)}`}</p>
|
||||
<p>
|
||||
{getNode(hop)?.user?.longName ?? `!${numberToHexUnpadded(hop)}`}
|
||||
</p>
|
||||
<p>↓ {snr?.[i + 1] ?? "??"}dB</p>
|
||||
</span>
|
||||
))}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React from "react";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { type LucideIcon } from "lucide-react";
|
||||
import Footer from "@components/UI/Footer.tsx";
|
||||
@@ -39,7 +39,7 @@ export const PageLayout = ({
|
||||
leftBarClassName,
|
||||
rightBarClassName,
|
||||
topBarClassName,
|
||||
contentClassName
|
||||
contentClassName,
|
||||
}: PageLayoutProps) => {
|
||||
return (
|
||||
<ErrorBoundary FallbackComponent={ErrorPage}>
|
||||
@@ -49,7 +49,7 @@ export const PageLayout = ({
|
||||
<aside
|
||||
className={cn(
|
||||
"px-2 pr-0 shrink-0 border-r-[0.5px] border-slate-300 dark:border-slate-700 ",
|
||||
leftBarClassName
|
||||
leftBarClassName,
|
||||
)}
|
||||
>
|
||||
{leftBar}
|
||||
@@ -61,7 +61,7 @@ export const PageLayout = ({
|
||||
<header
|
||||
className={cn(
|
||||
"flex h-14 shrink-0 mt-2 p-2 items-center border-b border-slate-300 dark:border-slate-700",
|
||||
topBarClassName
|
||||
topBarClassName,
|
||||
)}
|
||||
>
|
||||
{/* Header Content */}
|
||||
@@ -82,9 +82,7 @@ export const PageLayout = ({
|
||||
aria-busy={action.isLoading}
|
||||
>
|
||||
<div className="mr-6">
|
||||
{action.isLoading ? (
|
||||
<Spinner size="md" />
|
||||
) : (
|
||||
{action.isLoading ? <Spinner size="md" /> : (
|
||||
<action.icon
|
||||
className={cn("h-5 w-5", action.iconClasses)}
|
||||
/>
|
||||
@@ -101,7 +99,7 @@ export const PageLayout = ({
|
||||
"flex-1 flex flex-col",
|
||||
"overflow-hidden",
|
||||
!noPadding && "px-2",
|
||||
contentClassName
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
@@ -114,7 +112,7 @@ export const PageLayout = ({
|
||||
<aside
|
||||
className={cn(
|
||||
"w-48 lg:w-[270px] shrink-0 border-l border-slate-300 dark:border-slate-700 px-2 overflow-hidden",
|
||||
rightBarClassName
|
||||
rightBarClassName,
|
||||
)}
|
||||
>
|
||||
{rightBar}
|
||||
@@ -123,4 +121,4 @@ export const PageLayout = ({
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function ThemeSwitcher({
|
||||
}: {
|
||||
className?: string;
|
||||
}) {
|
||||
const { theme, preference, setPreference } = useTheme();
|
||||
const { preference, setPreference } = useTheme();
|
||||
|
||||
const themeIcons = {
|
||||
light: <Sun className="size-6" />,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { LockKeyholeOpenIcon } from 'lucide-react';
|
||||
import { LockKeyholeOpenIcon } from "lucide-react";
|
||||
|
||||
type RGBColor = {
|
||||
r: number;
|
||||
@@ -88,17 +88,17 @@ export const Avatar = ({
|
||||
color: textColor,
|
||||
}}
|
||||
>
|
||||
{showError ? (
|
||||
<LockKeyholeOpenIcon
|
||||
className="absolute bottom-0 right-0 z-10 size-4 text-red-500 stroke-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
<p
|
||||
className="p-1"
|
||||
>
|
||||
{showError
|
||||
? (
|
||||
<LockKeyholeOpenIcon
|
||||
className="absolute bottom-0 right-0 z-10 size-4 text-red-500 stroke-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
<p className="p-1">
|
||||
{initials}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -39,8 +39,9 @@ const buttonVariants = cva(
|
||||
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
extends
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
icon?: React.ReactNode;
|
||||
iconAlignment?: "left" | "right";
|
||||
}
|
||||
@@ -65,7 +66,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
className={cn(
|
||||
buttonVariants({ variant, size, className }),
|
||||
{ "cursor-not-allowed": disabled },
|
||||
"inline-flex items-center"
|
||||
"inline-flex items-center",
|
||||
)}
|
||||
ref={ref}
|
||||
disabled={disabled}
|
||||
@@ -85,4 +86,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
export { Button, buttonVariants };
|
||||
|
||||
@@ -1,113 +1,130 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||
import { Checkbox } from '@components/UI/Checkbox/index.tsx';
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
|
||||
import React from "react";
|
||||
|
||||
vi.mock('@components/UI/Label.tsx', () => ({
|
||||
Label: ({ children, className, htmlFor, id }: { children: React.ReactNode; className: string; htmlFor: string; id: string }) => (
|
||||
<label data-testid="label-component" className={className} htmlFor={htmlFor} id={id}>
|
||||
vi.mock("@components/UI/Label.tsx", () => ({
|
||||
Label: (
|
||||
{ children, className, htmlFor, id }: {
|
||||
children: React.ReactNode;
|
||||
className: string;
|
||||
htmlFor: string;
|
||||
id: string;
|
||||
},
|
||||
) => (
|
||||
<label
|
||||
data-testid="label-component"
|
||||
className={className}
|
||||
htmlFor={htmlFor}
|
||||
id={id}
|
||||
>
|
||||
{children}
|
||||
</label>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@core/utils/cn.ts', () => ({
|
||||
cn: (...args: any) => args.filter(Boolean).join(' '),
|
||||
vi.mock("@core/utils/cn.ts", () => ({
|
||||
cn: (...args) => args.filter(Boolean).join(" "),
|
||||
}));
|
||||
|
||||
vi.mock('react', async () => {
|
||||
const actual = await vi.importActual('react');
|
||||
vi.mock("react", async () => {
|
||||
const actual = await vi.importActual("react");
|
||||
return {
|
||||
...actual,
|
||||
useId: () => 'test-id',
|
||||
useId: () => "test-id",
|
||||
};
|
||||
});
|
||||
|
||||
describe('Checkbox', () => {
|
||||
describe("Checkbox", () => {
|
||||
beforeEach(cleanup);
|
||||
|
||||
it('renders unchecked by default', () => {
|
||||
it("renders unchecked by default", () => {
|
||||
render(<Checkbox />);
|
||||
const checkbox = screen.getByRole('checkbox');
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
expect(checkbox).not.toBeChecked();
|
||||
expect(screen.queryByText('Check')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Check")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders checked when checked prop is true', () => {
|
||||
render(<Checkbox checked={true} />);
|
||||
expect(screen.getByRole('checkbox')).toBeChecked();
|
||||
expect(screen.getByRole('presentation')).toBeInTheDocument();
|
||||
it("renders checked when checked prop is true", () => {
|
||||
render(<Checkbox checked />);
|
||||
expect(screen.getByRole("checkbox")).toBeChecked();
|
||||
expect(screen.getByRole("presentation")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onChange when clicked', () => {
|
||||
it("calls onChange when clicked", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Checkbox onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('presentation'));
|
||||
fireEvent.click(screen.getByRole("presentation"));
|
||||
expect(onChange).toHaveBeenCalledWith(true);
|
||||
|
||||
fireEvent.click(screen.getByRole('presentation'));
|
||||
fireEvent.click(screen.getByRole("presentation"));
|
||||
expect(onChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('uses provided id', () => {
|
||||
it("uses provided id", () => {
|
||||
render(<Checkbox id="custom-id" />);
|
||||
expect(screen.getByRole('checkbox').id).toBe('custom-id');
|
||||
expect(screen.getByRole("checkbox").id).toBe("custom-id");
|
||||
});
|
||||
|
||||
it('generates id when not provided', () => {
|
||||
it("generates id when not provided", () => {
|
||||
render(<Checkbox />);
|
||||
expect(screen.getByRole('checkbox').id).toBe('test-id');
|
||||
expect(screen.getByRole("checkbox").id).toBe("test-id");
|
||||
});
|
||||
|
||||
it('renders children in Label component', () => {
|
||||
it("renders children in Label component", () => {
|
||||
render(<Checkbox>Test Label</Checkbox>);
|
||||
expect(screen.getByTestId('label-component')).toHaveTextContent('Test Label');
|
||||
expect(screen.getByTestId("label-component")).toHaveTextContent(
|
||||
"Test Label",
|
||||
);
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
it("applies custom className", () => {
|
||||
const { container } = render(<Checkbox className="custom-class" />);
|
||||
expect(container.firstChild).toHaveClass('custom-class');
|
||||
expect(container.firstChild).toHaveClass("custom-class");
|
||||
});
|
||||
|
||||
it('applies labelClassName to Label', () => {
|
||||
it("applies labelClassName to Label", () => {
|
||||
render(<Checkbox labelClassName="label-class">Test</Checkbox>);
|
||||
expect(screen.getByTestId('label-component')).toHaveClass('label-class');
|
||||
expect(screen.getByTestId("label-component")).toHaveClass("label-class");
|
||||
});
|
||||
|
||||
it('disables checkbox when disabled prop is true', () => {
|
||||
it("disables checkbox when disabled prop is true", () => {
|
||||
render(<Checkbox disabled />);
|
||||
expect(screen.getByRole('checkbox')).toBeDisabled();
|
||||
expect(screen.getByRole('presentation')).toHaveClass('opacity-50');
|
||||
expect(screen.getByRole("checkbox")).toBeDisabled();
|
||||
expect(screen.getByRole("presentation")).toHaveClass("opacity-50");
|
||||
});
|
||||
|
||||
it('does not call onChange when disabled', () => {
|
||||
it("does not call onChange when disabled", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Checkbox onChange={onChange} disabled />);
|
||||
|
||||
fireEvent.click(screen.getByRole('presentation'));
|
||||
fireEvent.click(screen.getByRole("presentation"));
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets required attribute when required prop is true', () => {
|
||||
it("sets required attribute when required prop is true", () => {
|
||||
render(<Checkbox required />);
|
||||
expect(screen.getByRole('checkbox')).toHaveAttribute('required');
|
||||
expect(screen.getByRole("checkbox")).toHaveAttribute("required");
|
||||
});
|
||||
|
||||
it('sets name attribute when name prop is provided', () => {
|
||||
it("sets name attribute when name prop is provided", () => {
|
||||
render(<Checkbox name="test-name" />);
|
||||
expect(screen.getByRole('checkbox')).toHaveAttribute('name', 'test-name');
|
||||
expect(screen.getByRole("checkbox")).toHaveAttribute("name", "test-name");
|
||||
});
|
||||
|
||||
it('passes through additional props', () => {
|
||||
it("passes through additional props", () => {
|
||||
render(<Checkbox data-testid="extra-prop" />);
|
||||
expect(screen.getByRole('checkbox')).toHaveAttribute('data-testid', 'extra-prop');
|
||||
expect(screen.getByRole("checkbox")).toHaveAttribute(
|
||||
"data-testid",
|
||||
"extra-prop",
|
||||
);
|
||||
});
|
||||
|
||||
it('toggles checked state correctly', () => {
|
||||
it("toggles checked state correctly", () => {
|
||||
render(<Checkbox />);
|
||||
const checkbox = screen.getByRole('checkbox');
|
||||
const presentation = screen.getByRole('presentation');
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
const presentation = screen.getByRole("presentation");
|
||||
|
||||
expect(checkbox).not.toBeChecked();
|
||||
|
||||
@@ -117,4 +134,4 @@ describe('Checkbox', () => {
|
||||
fireEvent.click(presentation);
|
||||
expect(checkbox).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,7 +58,9 @@ DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
const DialogClose = ({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.DialogCloseProps & React.RefAttributes<HTMLButtonElement> & { className?: string }) => (
|
||||
}: DialogPrimitive.DialogCloseProps & React.RefAttributes<HTMLButtonElement> & {
|
||||
className?: string;
|
||||
}) => (
|
||||
<DialogPrimitive.Close
|
||||
aria-label="Close"
|
||||
data-testid="dialog-close-button"
|
||||
@@ -107,7 +109,10 @@ const DialogTitle = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-slate-900 dark:text-slate-100", className)}
|
||||
className={cn(
|
||||
"text-lg font-semibold text-slate-900 dark:text-slate-100",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -127,11 +132,11 @@ DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Heading } from "@components/UI/Typography/Heading.tsx";
|
||||
import { Link } from "@components/UI/Typography/Link.tsx";
|
||||
import { P } from "@components/UI/Typography/P.tsx";
|
||||
|
||||
|
||||
export function ErrorPage({ error }: { error: Error }) {
|
||||
if (!error) {
|
||||
return null;
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { cn } from "@core/utils/cn.ts"
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
|
||||
type FooterProps = {
|
||||
className?: string;
|
||||
}
|
||||
};
|
||||
|
||||
const Footer = ({ className, ...props }: FooterProps) => {
|
||||
return (
|
||||
<footer
|
||||
className={cn("flex mt-auto justify-center py-2 px-4 text-sm lg:text-md", className)}
|
||||
className={cn(
|
||||
"flex mt-auto justify-center py-2 px-4 text-sm lg:text-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<p>
|
||||
@@ -27,6 +30,6 @@ const Footer = ({ className, ...props }: FooterProps) => {
|
||||
</p>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
|
||||
@@ -15,7 +15,8 @@ export interface ActionButton {
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
variant: ButtonVariant;
|
||||
className?: string;
|
||||
}[]
|
||||
}
|
||||
[];
|
||||
|
||||
export interface GeneratorProps extends React.BaseHTMLAttributes<HTMLElement> {
|
||||
type: "text" | "password";
|
||||
@@ -26,97 +27,98 @@ export interface GeneratorProps extends React.BaseHTMLAttributes<HTMLElement> {
|
||||
actionButtons: ActionButton[];
|
||||
bits?: { text: string; value: string; key: string }[];
|
||||
selectChange: (event: string) => void;
|
||||
inputChange: (event: React.ChangeEventHandler<HTMLInputElement> | undefined) => void;
|
||||
inputChange: (
|
||||
event: React.ChangeEventHandler<HTMLInputElement> | undefined,
|
||||
) => void;
|
||||
showPasswordToggle?: boolean;
|
||||
showCopyButton?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const Generator =
|
||||
(
|
||||
{
|
||||
type,
|
||||
devicePSKBitCount,
|
||||
id = "pskInput",
|
||||
variant,
|
||||
value,
|
||||
actionButtons,
|
||||
bits = [
|
||||
{ text: "256 bit", value: "32", key: "bit256" },
|
||||
{ text: "128 bit", value: "16", key: "bit128" },
|
||||
{ text: "8 bit", value: "1", key: "bit8" },
|
||||
{ text: "Empty", value: "0", key: "empty" },
|
||||
],
|
||||
selectChange,
|
||||
inputChange,
|
||||
disabled,
|
||||
showPasswordToggle,
|
||||
showCopyButton,
|
||||
...props
|
||||
}: GeneratorProps
|
||||
) => {
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const Generator = (
|
||||
{
|
||||
type,
|
||||
devicePSKBitCount,
|
||||
id = "pskInput",
|
||||
variant,
|
||||
value,
|
||||
actionButtons,
|
||||
bits = [
|
||||
{ text: "256 bit", value: "32", key: "bit256" },
|
||||
{ text: "128 bit", value: "16", key: "bit128" },
|
||||
{ text: "8 bit", value: "1", key: "bit8" },
|
||||
{ text: "Empty", value: "0", key: "empty" },
|
||||
],
|
||||
selectChange,
|
||||
inputChange,
|
||||
disabled,
|
||||
showPasswordToggle,
|
||||
showCopyButton,
|
||||
...props
|
||||
}: GeneratorProps,
|
||||
) => {
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
// Invokes onChange event on the input element when the value changes from the parent component
|
||||
React.useEffect(() => {
|
||||
if (!inputRef.current) return;
|
||||
const setValue = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
// Invokes onChange event on the input element when the value changes from the parent component
|
||||
React.useEffect(() => {
|
||||
if (!inputRef.current) return;
|
||||
const setValue = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
|
||||
if (!setValue) return;
|
||||
inputRef.current.value = "";
|
||||
setValue.call(inputRef.current, value);
|
||||
inputRef.current.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}, [value]);
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
type={type}
|
||||
id={id}
|
||||
variant={variant}
|
||||
value={value}
|
||||
onChange={inputChange}
|
||||
disabled={disabled}
|
||||
ref={inputRef}
|
||||
showCopyButton={showCopyButton}
|
||||
showPasswordToggle={showPasswordToggle}
|
||||
/>
|
||||
<Select
|
||||
value={devicePSKBitCount?.toString()}
|
||||
onValueChange={(e) => selectChange(e)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="w-36 ml-2">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="w-36">
|
||||
{bits.map(({ text, value, key }) => (
|
||||
<SelectItem key={key} value={value} className="w-36">
|
||||
{text}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex ml-2 space-x-2">
|
||||
{actionButtons?.map(({ text, onClick, variant, className }) => (
|
||||
<Button
|
||||
key={text}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
variant={variant}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
if (!setValue) return;
|
||||
inputRef.current.value = "";
|
||||
setValue.call(inputRef.current, value);
|
||||
inputRef.current.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}, [value]);
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
type={type}
|
||||
id={id}
|
||||
variant={variant}
|
||||
value={value}
|
||||
onChange={inputChange}
|
||||
disabled={disabled}
|
||||
ref={inputRef}
|
||||
showCopyButton={showCopyButton}
|
||||
showPasswordToggle={showPasswordToggle}
|
||||
/>
|
||||
<Select
|
||||
value={devicePSKBitCount?.toString()}
|
||||
onValueChange={(e) => selectChange(e)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="w-36 ml-2">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="w-36">
|
||||
{bits.map(({ text, value, key }) => (
|
||||
<SelectItem key={key} value={value} className="w-36">
|
||||
{text}
|
||||
</Button>
|
||||
</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex ml-2 space-x-2">
|
||||
{actionButtons?.map(({ text, onClick, variant, className }) => (
|
||||
<Button
|
||||
key={text}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
variant={variant}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{text}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Generator.displayName = "Button";
|
||||
|
||||
export { Generator };
|
||||
|
||||
+32
-23
@@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Check, Copy, Eye, EyeOff, X, type LucideIcon } from "lucide-react";
|
||||
import { Check, Copy, Eye, EyeOff, type LucideIcon, X } from "lucide-react";
|
||||
import { useCopyToClipboard } from "@core/hooks/useCopyToClipboard.ts";
|
||||
import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts";
|
||||
|
||||
@@ -18,7 +18,7 @@ const inputVariants = cva(
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
type InputActionType = {
|
||||
@@ -26,13 +26,14 @@ type InputActionType = {
|
||||
icon: LucideIcon;
|
||||
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
ariaLabel: string;
|
||||
tooltip?: string
|
||||
tooltip?: string;
|
||||
condition?: boolean;
|
||||
};
|
||||
|
||||
export interface InputProps
|
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "prefix" | "suffix">,
|
||||
VariantProps<typeof inputVariants> {
|
||||
extends
|
||||
Omit<React.InputHTMLAttributes<HTMLInputElement>, "prefix" | "suffix">,
|
||||
VariantProps<typeof inputVariants> {
|
||||
prefix?: React.ReactNode;
|
||||
suffix?: React.ReactNode;
|
||||
showPasswordToggle?: boolean;
|
||||
@@ -57,7 +58,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
onChange,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
ref,
|
||||
) => {
|
||||
const { isVisible, toggleVisibility } = usePasswordVisibilityToggle();
|
||||
const { copy, isCopied } = useCopyToClipboard({ timeout: 1500 });
|
||||
@@ -109,10 +110,11 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
},
|
||||
];
|
||||
|
||||
const actions = potentialActions.filter(action => action.condition);
|
||||
const actions = potentialActions.filter((action) => action.condition);
|
||||
|
||||
const inputType =
|
||||
showPasswordToggle ? (isVisible ? "text" : "password") : type;
|
||||
const inputType = showPasswordToggle
|
||||
? (isVisible ? "text" : "password")
|
||||
: type;
|
||||
|
||||
const hasPrefix = !!prefix;
|
||||
const hasSuffix = !!suffix;
|
||||
@@ -122,11 +124,13 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
inputVariants({ variant }),
|
||||
hasActions && !hasSuffix && "pr-10",
|
||||
hasPrefix && "rounded-l-none",
|
||||
className
|
||||
className,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("relative flex w-full items-stretch", containerClassName)}>
|
||||
<div
|
||||
className={cn("relative flex w-full items-stretch", containerClassName)}
|
||||
>
|
||||
{prefix && (
|
||||
<span className="inline-flex items-center rounded-l-md border border-r-0 border-slate-300 bg-slate-100/80 px-3 text-sm text-slate-600 dark:border-slate-700 dark:bg-slate-200 dark:text-slate-700">
|
||||
{prefix}
|
||||
@@ -144,27 +148,32 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
|
||||
<div className="absolute right-0 top-0 flex h-full items-stretch">
|
||||
{suffix && (
|
||||
<span className={cn(
|
||||
"inline-flex items-center border border-l-0 border-slate-300 bg-slate-100/80 px-3 text-sm text-slate-600 dark:border-slate-700 dark:bg-slate-700 dark:text-slate-300",
|
||||
!hasActions && "rounded-r-md"
|
||||
)}>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center border border-l-0 border-slate-300 bg-slate-100/80 px-3 text-sm text-slate-600 dark:border-slate-700 dark:bg-slate-700 dark:text-slate-300",
|
||||
!hasActions && "rounded-r-md",
|
||||
)}
|
||||
>
|
||||
{suffix}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{hasActions && (
|
||||
<div className={cn(
|
||||
"flex items-center divide-x divide-slate-300 border border-l-0 border-slate-300 dark:divide-slate-700 dark:border-slate-700",
|
||||
!hasSuffix && "rounded-r-md",
|
||||
"bg-white dark:bg-slate-800"
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center divide-x divide-slate-300 border border-l-0 border-slate-300 dark:divide-slate-700 dark:border-slate-700",
|
||||
!hasSuffix && "rounded-r-md",
|
||||
"bg-white dark:bg-slate-800",
|
||||
)}
|
||||
>
|
||||
{actions.map((action) => (
|
||||
<button
|
||||
key={action.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex h-full items-center justify-center px-2.5 text-slate-500 hover:bg-slate-100 hover:text-slate-700 focus:outline-none focus:ring-1 focus:ring-slate-400 focus:ring-offset-0 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-200 dark:focus:ring-slate-500 hover:rounded-md dark:hover:rounded-md",
|
||||
action.id === 'copy-value' && isCopied && "text-green-600 dark:text-green-500"
|
||||
action.id === "copy-value" && isCopied &&
|
||||
"text-green-600 dark:text-green-500",
|
||||
)}
|
||||
onClick={action.onClick}
|
||||
aria-label={action.ariaLabel}
|
||||
@@ -178,8 +187,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input, inputVariants };
|
||||
export { Input, inputVariants };
|
||||
|
||||
@@ -36,8 +36,8 @@ export const SidebarButton = ({
|
||||
className={cn(
|
||||
"flex w-full items-center text-wrap",
|
||||
isButtonCollapsed
|
||||
? 'justify-center gap-0 px-2 h-9'
|
||||
: 'justify-start gap-2 min-h-9'
|
||||
? "justify-center gap-0 px-2 h-9"
|
||||
: "justify-start gap-2 min-h-9",
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
@@ -52,13 +52,13 @@ export const SidebarButton = ({
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
'flex flex-wrap justify-start text-left text-wrap break-all',
|
||||
'min-w-0',
|
||||
'px-1',
|
||||
'transition-all duration-300 ease-in-out',
|
||||
"flex flex-wrap justify-start text-left text-wrap break-all",
|
||||
"min-w-0",
|
||||
"px-1",
|
||||
"transition-all duration-300 ease-in-out",
|
||||
isButtonCollapsed
|
||||
? 'opacity-0 max-w-0 invisible w-0 overflow-hidden'
|
||||
: 'opacity-100 max-w-full visible flex-1 whitespace-normal'
|
||||
? "opacity-0 max-w-0 invisible w-0 overflow-hidden"
|
||||
: "opacity-100 max-w-full visible flex-1 whitespace-normal",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
@@ -70,7 +70,7 @@ export const SidebarButton = ({
|
||||
"ml-auto flex-shrink-0 justify-end text-white text-xs rounded-full px-1.5 py-0.5 bg-red-600",
|
||||
"flex-shrink-0",
|
||||
"transition-opacity duration-300 ease-in-out",
|
||||
isButtonCollapsed ? 'opacity-0 invisible' : 'opacity-100 visible'
|
||||
isButtonCollapsed ? "opacity-0 invisible" : "opacity-100 visible",
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
@@ -78,4 +78,4 @@ export const SidebarButton = ({
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -36,8 +36,8 @@ export const SidebarButton = ({
|
||||
className={cn(
|
||||
"flex w-full items-center text-wrap",
|
||||
isButtonCollapsed
|
||||
? 'justify-center gap-0 px-2 h-9'
|
||||
: 'justify-start gap-2 min-h-9'
|
||||
? "justify-center gap-0 px-2 h-9"
|
||||
: "justify-start gap-2 min-h-9",
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
@@ -52,13 +52,13 @@ export const SidebarButton = ({
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
'flex flex-wrap justify-start text-left text-wrap break-all',
|
||||
'min-w-0',
|
||||
'px-1',
|
||||
'transition-all duration-300 ease-in-out',
|
||||
"flex flex-wrap justify-start text-left text-wrap break-all",
|
||||
"min-w-0",
|
||||
"px-1",
|
||||
"transition-all duration-300 ease-in-out",
|
||||
isButtonCollapsed
|
||||
? 'opacity-0 max-w-0 invisible w-0 overflow-hidden'
|
||||
: 'opacity-100 max-w-full visible flex-1 whitespace-normal'
|
||||
? "opacity-0 max-w-0 invisible w-0 overflow-hidden"
|
||||
: "opacity-100 max-w-full visible flex-1 whitespace-normal",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
@@ -70,7 +70,7 @@ export const SidebarButton = ({
|
||||
"ml-auto flex-shrink-0 justify-end text-white text-xs rounded-full px-1.5 py-0.5 bg-red-600",
|
||||
"flex-shrink-0",
|
||||
"transition-opacity duration-300 ease-in-out",
|
||||
isButtonCollapsed ? 'opacity-0 invisible' : 'opacity-100 visible'
|
||||
isButtonCollapsed ? "opacity-0 invisible" : "opacity-100 visible",
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
@@ -78,4 +78,4 @@ export const SidebarButton = ({
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,111 +1,122 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { Table } from "@components/generic/Table/index.tsx";
|
||||
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
|
||||
import { Mono } from "@components/generic/Mono.tsx";
|
||||
|
||||
// @ts-types="react"
|
||||
import React from "react";
|
||||
|
||||
describe("Generic Table", () => {
|
||||
it("Can render an empty table.", () => {
|
||||
render(
|
||||
<Table
|
||||
headings={[]}
|
||||
rows={[]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole("table")).toBeInTheDocument();
|
||||
});
|
||||
it("Can render an empty table.", () => {
|
||||
render(
|
||||
<Table
|
||||
headings={[]}
|
||||
rows={[]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("table")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Can render a table with headers and no rows.", async () => {
|
||||
render(
|
||||
<Table
|
||||
headings={[
|
||||
{ title: "", type: "blank", sortable: false },
|
||||
{ title: "Short Name", type: "normal", sortable: true },
|
||||
{ title: "Long Name", type: "normal", sortable: true },
|
||||
{ title: "Model", type: "normal", sortable: true },
|
||||
{ title: "MAC Address", type: "normal", sortable: true },
|
||||
{ title: "Last Heard", type: "normal", sortable: true },
|
||||
{ title: "SNR", type: "normal", sortable: true },
|
||||
{ title: "Encryption", type: "normal", sortable: false },
|
||||
{ title: "Connection", type: "normal", sortable: true },
|
||||
]}
|
||||
rows={[]}
|
||||
/>
|
||||
);
|
||||
await screen.findByRole('table');
|
||||
expect(screen.getAllByRole("columnheader")).toHaveLength(9);
|
||||
});
|
||||
it("Can render a table with headers and no rows.", async () => {
|
||||
render(
|
||||
<Table
|
||||
headings={[
|
||||
{ title: "", type: "blank", sortable: false },
|
||||
{ title: "Short Name", type: "normal", sortable: true },
|
||||
{ title: "Long Name", type: "normal", sortable: true },
|
||||
{ title: "Model", type: "normal", sortable: true },
|
||||
{ title: "MAC Address", type: "normal", sortable: true },
|
||||
{ title: "Last Heard", type: "normal", sortable: true },
|
||||
{ title: "SNR", type: "normal", sortable: true },
|
||||
{ title: "Encryption", type: "normal", sortable: false },
|
||||
{ title: "Connection", type: "normal", sortable: true },
|
||||
]}
|
||||
rows={[]}
|
||||
/>,
|
||||
);
|
||||
await screen.findByRole("table");
|
||||
expect(screen.getAllByRole("columnheader")).toHaveLength(9);
|
||||
});
|
||||
|
||||
// A simplified version of the rows in pages/Nodes.tsx for testing purposes
|
||||
const mockDevicesWithShortNameAndConnection = [
|
||||
{user: {shortName: "TST1"}, hopsAway: 1, lastHeard: Date.now() + 1000 },
|
||||
{user: {shortName: "TST2"}, hopsAway: 0, lastHeard: Date.now() + 4000 },
|
||||
{user: {shortName: "TST3"}, hopsAway: 4, lastHeard: Date.now() },
|
||||
{user: {shortName: "TST4"}, hopsAway: 3, lastHeard: Date.now() + 2000 }
|
||||
];
|
||||
|
||||
const mockRows = mockDevicesWithShortNameAndConnection.map(node => [
|
||||
<h1 data-testshortname> { node.user.shortName } </h1>,
|
||||
<><TimeAgo timestamp={node.lastHeard * 1000} /></>,
|
||||
<Mono key="hops" data-testhops>
|
||||
{node.lastHeard !== 0
|
||||
? node.hopsAway === 0
|
||||
? "Direct"
|
||||
: `${node.hopsAway?.toString()} ${
|
||||
node.hopsAway > 1 ? "hops" : "hop"
|
||||
} away`
|
||||
: "-"}
|
||||
</Mono>
|
||||
])
|
||||
// A simplified version of the rows in pages/Nodes.tsx for testing purposes
|
||||
const mockDevicesWithShortNameAndConnection = [
|
||||
{ user: { shortName: "TST1" }, hopsAway: 1, lastHeard: Date.now() + 1000 },
|
||||
{ user: { shortName: "TST2" }, hopsAway: 0, lastHeard: Date.now() + 4000 },
|
||||
{ user: { shortName: "TST3" }, hopsAway: 4, lastHeard: Date.now() },
|
||||
{ user: { shortName: "TST4" }, hopsAway: 3, lastHeard: Date.now() + 2000 },
|
||||
];
|
||||
|
||||
it("Can sort rows appropriately.", async () => {
|
||||
render(
|
||||
<Table
|
||||
headings={[
|
||||
{ title: "Short Name", type: "normal", sortable: true },
|
||||
{ title: "Last Heard", type: "normal", sortable: true },
|
||||
{ title: "Connection", type: "normal", sortable: true },
|
||||
]}
|
||||
rows={mockRows}
|
||||
/>
|
||||
);
|
||||
const renderedTable = await screen.findByRole('table');
|
||||
const columnHeaders = screen.getAllByRole("columnheader");
|
||||
expect(columnHeaders).toHaveLength(3);
|
||||
const mockRows = mockDevicesWithShortNameAndConnection.map((node) => [
|
||||
<h1 data-testshortname key={node.user.shortName}>{node.user.shortName}</h1>,
|
||||
<React.Fragment key={node.user.shortName}>
|
||||
<TimeAgo timestamp={node.lastHeard * 1000} />
|
||||
</React.Fragment>,
|
||||
<Mono key="hops" data-testhops>
|
||||
{node.lastHeard !== 0
|
||||
? node.hopsAway === 0
|
||||
? "Direct"
|
||||
: `${node.hopsAway?.toString()} ${
|
||||
node.hopsAway > 1 ? "hops" : "hop"
|
||||
} away`
|
||||
: "-"}
|
||||
</Mono>,
|
||||
]);
|
||||
|
||||
// Will be sorted "Last heard" "asc" by default
|
||||
expect( [...renderedTable.querySelectorAll('[data-testshortname]')]
|
||||
.map(el=>el.textContent)
|
||||
.map(v=>v?.trim())
|
||||
.join(','))
|
||||
.toMatch('TST2,TST4,TST1,TST3');
|
||||
|
||||
fireEvent.click(columnHeaders[0]);
|
||||
it("Can sort rows appropriately.", async () => {
|
||||
render(
|
||||
<Table
|
||||
headings={[
|
||||
{ title: "Short Name", type: "normal", sortable: true },
|
||||
{ title: "Last Heard", type: "normal", sortable: true },
|
||||
{ title: "Connection", type: "normal", sortable: true },
|
||||
]}
|
||||
rows={mockRows}
|
||||
/>,
|
||||
);
|
||||
const renderedTable = await screen.findByRole("table");
|
||||
const columnHeaders = screen.getAllByRole("columnheader");
|
||||
expect(columnHeaders).toHaveLength(3);
|
||||
|
||||
// Re-sort by Short Name asc
|
||||
expect( [...renderedTable.querySelectorAll('[data-testshortname]')]
|
||||
.map(el=>el.textContent)
|
||||
.map(v=>v?.trim())
|
||||
.join(','))
|
||||
.toMatch('TST1,TST2,TST3,TST4');
|
||||
// Will be sorted "Last heard" "asc" by default
|
||||
expect(
|
||||
[...renderedTable.querySelectorAll("[data-testshortname]")]
|
||||
.map((el) => el.textContent)
|
||||
.map((v) => v?.trim())
|
||||
.join(","),
|
||||
)
|
||||
.toMatch("TST2,TST4,TST1,TST3");
|
||||
|
||||
fireEvent.click(columnHeaders[0]);
|
||||
fireEvent.click(columnHeaders[0]);
|
||||
|
||||
// Re-sort by Short Name desc
|
||||
expect( [...renderedTable.querySelectorAll('[data-testshortname]')]
|
||||
.map(el=>el.textContent)
|
||||
.map(v=>v?.trim())
|
||||
.join(','))
|
||||
.toMatch('TST4,TST3,TST2,TST1');
|
||||
// Re-sort by Short Name asc
|
||||
expect(
|
||||
[...renderedTable.querySelectorAll("[data-testshortname]")]
|
||||
.map((el) => el.textContent)
|
||||
.map((v) => v?.trim())
|
||||
.join(","),
|
||||
)
|
||||
.toMatch("TST1,TST2,TST3,TST4");
|
||||
|
||||
fireEvent.click(columnHeaders[2]);
|
||||
|
||||
// Re-sort by Hops Away
|
||||
expect( [...renderedTable.querySelectorAll('[data-testshortname]')]
|
||||
.map(el=>el.textContent)
|
||||
.map(v=>v?.trim())
|
||||
.join(','))
|
||||
.toMatch('TST2,TST1,TST4,TST3');
|
||||
});
|
||||
})
|
||||
fireEvent.click(columnHeaders[0]);
|
||||
|
||||
// Re-sort by Short Name desc
|
||||
expect(
|
||||
[...renderedTable.querySelectorAll("[data-testshortname]")]
|
||||
.map((el) => el.textContent)
|
||||
.map((v) => v?.trim())
|
||||
.join(","),
|
||||
)
|
||||
.toMatch("TST4,TST3,TST2,TST1");
|
||||
|
||||
fireEvent.click(columnHeaders[2]);
|
||||
|
||||
// Re-sort by Hops Away
|
||||
expect(
|
||||
[...renderedTable.querySelectorAll("[data-testshortname]")]
|
||||
.map((el) => el.textContent)
|
||||
.map((v) => v?.trim())
|
||||
.join(","),
|
||||
)
|
||||
.toMatch("TST2,TST1,TST4,TST3");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import React from "react";
|
||||
|
||||
export interface TableProps {
|
||||
headings: Heading[];
|
||||
rows: [][];
|
||||
rows: React.ReactNode[][];
|
||||
}
|
||||
|
||||
export interface Heading {
|
||||
@@ -12,18 +13,15 @@ export interface Heading {
|
||||
sortable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param hopsAway String describing the number of hops away the node is from the current node
|
||||
* @returns number of hopsAway or `0` if hopsAway is 'Direct'
|
||||
*/
|
||||
function numericHops(hopsAway: string): number {
|
||||
function numericHops(hopsAway: string | unknown): number {
|
||||
if (typeof hopsAway !== "string") {
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
if (hopsAway.match(/direct/i)) {
|
||||
return 0;
|
||||
}
|
||||
if (hopsAway.match(/\d+\s+hop/gi)) {
|
||||
return Number(hopsAway.match(/(\d+)\s+hop/i)?.[1]);
|
||||
}
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
const match = hopsAway.match(/(\d+)\s+hop/i);
|
||||
return Number(match?.[1] ?? Number.MAX_SAFE_INTEGER);
|
||||
}
|
||||
|
||||
export const Table = ({ headings, rows }: TableProps) => {
|
||||
@@ -39,45 +37,63 @@ export const Table = ({ headings, rows }: TableProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getElement = (cell: React.ReactNode): React.ReactElement | null => {
|
||||
if (!React.isValidElement(cell)) {
|
||||
return null;
|
||||
}
|
||||
if (cell.type === React.Fragment) {
|
||||
const childrenArray = React.Children.toArray(cell.props.children);
|
||||
const firstElement = childrenArray.find((child) =>
|
||||
React.isValidElement(child)
|
||||
);
|
||||
return (firstElement as React.ReactElement) ?? null;
|
||||
}
|
||||
// If not a fragment, return the element itself
|
||||
return cell;
|
||||
};
|
||||
|
||||
const sortedRows = rows.slice().sort((a, b) => {
|
||||
if (!sortColumn) return 0;
|
||||
|
||||
const columnIndex = headings.findIndex((h) => h.title === sortColumn);
|
||||
const aValue = a[columnIndex].props.children;
|
||||
const bValue = b[columnIndex].props.children;
|
||||
if (columnIndex === -1) return 0;
|
||||
|
||||
const elementA = getElement(a[columnIndex]);
|
||||
const elementB = getElement(b[columnIndex]);
|
||||
|
||||
if (sortColumn === "Last Heard") {
|
||||
const aTimestamp = aValue.props.timestamp ?? 0;
|
||||
const bTimestamp = bValue.props.timestamp ?? 0;
|
||||
|
||||
if (aTimestamp < bTimestamp) {
|
||||
return sortOrder === "asc" ? -1 : 1;
|
||||
}
|
||||
if (aTimestamp > bTimestamp) {
|
||||
return sortOrder === "asc" ? 1 : -1;
|
||||
}
|
||||
const aTimestamp = elementA?.props?.timestamp ?? 0;
|
||||
const bTimestamp = elementB?.props?.timestamp ?? 0;
|
||||
if (aTimestamp < bTimestamp) return sortOrder === "asc" ? -1 : 1;
|
||||
if (aTimestamp > bTimestamp) return sortOrder === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (sortColumn === "Connection") {
|
||||
const aNumHops = numericHops(aValue instanceof Array ? aValue[0] : aValue);
|
||||
const bNumHops = numericHops(bValue instanceof Array ? bValue[0] : bValue);
|
||||
|
||||
if (aNumHops < bNumHops) {
|
||||
return sortOrder === "asc" ? -1 : 1;
|
||||
}
|
||||
if (aNumHops > bNumHops) {
|
||||
return sortOrder === "asc" ? 1 : -1;
|
||||
}
|
||||
const aHopsStr = elementA?.props?.children;
|
||||
const bHopsStr = elementB?.props?.children;
|
||||
const aNumHops = numericHops(aHopsStr);
|
||||
const bNumHops = numericHops(bHopsStr);
|
||||
if (aNumHops < bNumHops) return sortOrder === "asc" ? -1 : 1;
|
||||
if (aNumHops > bNumHops) return sortOrder === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (aValue < bValue) {
|
||||
return sortOrder === "asc" ? -1 : 1;
|
||||
}
|
||||
if (aValue > bValue) {
|
||||
return sortOrder === "asc" ? 1 : -1;
|
||||
}
|
||||
const aValue = elementA?.props?.children;
|
||||
const bValue = elementB?.props?.children;
|
||||
const valA = aValue ?? "";
|
||||
const valB = bValue ?? "";
|
||||
|
||||
// Ensure consistent comparison for potentially different types
|
||||
const compareA = typeof valA === "string" || typeof valA === "number"
|
||||
? valA
|
||||
: String(valA);
|
||||
const compareB = typeof valB === "string" || typeof valB === "number"
|
||||
? valB
|
||||
: String(valB);
|
||||
|
||||
if (compareA < compareB) return sortOrder === "asc" ? -1 : 1;
|
||||
if (compareA > compareB) return sortOrder === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
@@ -89,48 +105,76 @@ export const Table = ({ headings, rows }: TableProps) => {
|
||||
<th
|
||||
key={heading.title}
|
||||
scope="col"
|
||||
className={`py-2 pr-3 text-left ${heading.sortable
|
||||
? "cursor-pointer hover:brightness-hover active:brightness-press"
|
||||
: ""
|
||||
}`}
|
||||
className={`py-2 pr-3 text-left ${
|
||||
heading.sortable
|
||||
? "cursor-pointer hover:brightness-hover active:brightness-press"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => heading.sortable && headingSort(heading.title)}
|
||||
onKeyUp={() => heading.sortable && headingSort(heading.title)}
|
||||
onKeyUp={(e) => {
|
||||
if (heading.sortable && (e.key === "Enter" || e.key === " ")) {
|
||||
headingSort(heading.title);
|
||||
}
|
||||
}}
|
||||
tabIndex={heading.sortable ? 0 : -1}
|
||||
role="columnheader"
|
||||
aria-sort={sortColumn === heading.title
|
||||
? sortOrder === "asc" ? "ascending" : "descending"
|
||||
: "none"}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{heading.title}
|
||||
{sortColumn === heading.title &&
|
||||
(sortOrder === "asc"
|
||||
? <ChevronUpIcon size={16} />
|
||||
: <ChevronDownIcon size={16} />)}
|
||||
{heading.sortable && sortColumn === heading.title && (
|
||||
sortOrder === "asc"
|
||||
? <ChevronUpIcon size={16} aria-hidden="true" />
|
||||
: <ChevronDownIcon size={16} aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="max-w-fit">
|
||||
{sortedRows.map((row, index) => {
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: TODO: Once this table is sortable, this should get fixed.
|
||||
return (<tr key={index} className={`${index % 2 ? 'bg-white dark:bg-white/2' : 'bg-slate-50/50 dark:bg-slate-50/5'} border-b-1 border-slate-200 dark:border-slate-900`}>
|
||||
{row.map((item, index) => {
|
||||
return (index === 0 ?
|
||||
<th
|
||||
key={item.key ?? index}
|
||||
className="whitespace-nowrap py-2 text-sm text-text-secondary first:pl-2"
|
||||
scope="row"
|
||||
>
|
||||
{item}
|
||||
</th> :
|
||||
<td
|
||||
key={item.key ?? index}
|
||||
className="whitespace-nowrap py-2 text-sm text-text-secondary first:pl-2"
|
||||
>
|
||||
{item}
|
||||
</td>)
|
||||
})}
|
||||
</tr>
|
||||
{sortedRows.map((row) => {
|
||||
const firstCellKey =
|
||||
(React.isValidElement(row[0]) && row[0].key !== null)
|
||||
? String(row[0].key)
|
||||
: null;
|
||||
const rowKey = firstCellKey ?? Math.random().toString(); // Use random only as last resort
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={rowKey}
|
||||
className={`
|
||||
bg-white dark:bg-white/10
|
||||
odd:bg-slate-800/70 dark:even:bg-slate-900/70
|
||||
`}
|
||||
>
|
||||
{row.map((item, cellIndex) => {
|
||||
const cellKey = `${rowKey}_${cellIndex}`;
|
||||
return cellIndex === 0
|
||||
? (
|
||||
<th
|
||||
key={cellKey}
|
||||
className="whitespace-nowrap px-3 py-2 text-sm text-left text-text-secondary"
|
||||
scope="row"
|
||||
>
|
||||
{item}
|
||||
</th>
|
||||
)
|
||||
: (
|
||||
<td
|
||||
key={cellKey}
|
||||
className="whitespace-nowrap px-3 py-2 text-sm text-text-secondary"
|
||||
>
|
||||
{item}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Protobuf } from "@meshtastic/core";
|
||||
|
||||
class NodeInfoFactory {
|
||||
private static createDefaultUser(num: number): Protobuf.Mesh.User {
|
||||
const userIdHex = num.toString(16).toUpperCase().padStart(2, '0');
|
||||
const userIdHex = num.toString(16).toUpperCase().padStart(2, "0");
|
||||
const userId = `!${userIdHex}`;
|
||||
const last4 = userIdHex.slice(-4);
|
||||
const longName = `Meshtastic ${last4}`;
|
||||
@@ -19,14 +19,19 @@ class NodeInfoFactory {
|
||||
});
|
||||
}
|
||||
|
||||
public static ensureDefaultUser(node: Protobuf.Mesh.NodeInfo): Protobuf.Mesh.NodeInfo {
|
||||
public static ensureDefaultUser(
|
||||
node: Protobuf.Mesh.NodeInfo,
|
||||
): Protobuf.Mesh.NodeInfo {
|
||||
if (!node) {
|
||||
return node;
|
||||
}
|
||||
|
||||
if (!node.user) {
|
||||
if (node.num === undefined || node.num === null) {
|
||||
console.error(`NodeInfoFactory.ensureDefaultUser: Cannot create default user for node because 'num' is missing.`, node);
|
||||
console.error(
|
||||
`NodeInfoFactory.ensureDefaultUser: Cannot create default user for node because 'num' is missing.`,
|
||||
node,
|
||||
);
|
||||
return node;
|
||||
}
|
||||
|
||||
@@ -37,4 +42,4 @@ class NodeInfoFactory {
|
||||
}
|
||||
}
|
||||
|
||||
export default NodeInfoFactory;
|
||||
export default NodeInfoFactory;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { Types } from "@meshtastic/js";
|
||||
import { Message, MessageType, MessageState } from "../stores/messageStore/index.ts";
|
||||
import {
|
||||
Message,
|
||||
MessageState,
|
||||
MessageType,
|
||||
} from "../stores/messageStore/index.ts";
|
||||
|
||||
class PacketToMessageDTO {
|
||||
channel: Types.ChannelNumber;
|
||||
@@ -16,9 +20,13 @@ class PacketToMessageDTO {
|
||||
this.to = data.to;
|
||||
this.from = data.from;
|
||||
this.messageId = data.id;
|
||||
this.state = data.from !== nodeNum ? MessageState.Ack : MessageState.Waiting;
|
||||
this.state = data.from !== nodeNum
|
||||
? MessageState.Ack
|
||||
: MessageState.Waiting;
|
||||
this.message = data.data;
|
||||
this.type = (data.type === 'direct') ? MessageType.Direct : MessageType.Broadcast;
|
||||
this.type = (data.type === "direct")
|
||||
? MessageType.Direct
|
||||
: MessageType.Broadcast;
|
||||
|
||||
let dateTimestamp = Date.now();
|
||||
if (data.rxTime instanceof Date) {
|
||||
@@ -27,9 +35,11 @@ class PacketToMessageDTO {
|
||||
if (!isNaN(timeValue)) {
|
||||
dateTimestamp = timeValue;
|
||||
}
|
||||
}
|
||||
else if (data.rxTime != null) {
|
||||
console.warn(`Received rxTime in PacketToMessageDTO was not a Date object as expected (type: ${typeof data.rxTime}, value: ${data.rxTime}). Using current time as fallback.`);
|
||||
} else if (data.rxTime != null) {
|
||||
console.warn(
|
||||
`Received rxTime in PacketToMessageDTO was not a Date object as expected (type: ${typeof data
|
||||
.rxTime}, value: ${data.rxTime}). Using current time as fallback.`,
|
||||
);
|
||||
}
|
||||
this.date = dateTimestamp;
|
||||
}
|
||||
@@ -48,4 +58,4 @@ class PacketToMessageDTO {
|
||||
}
|
||||
}
|
||||
|
||||
export default PacketToMessageDTO;
|
||||
export default PacketToMessageDTO;
|
||||
|
||||
@@ -4,7 +4,9 @@ interface UseCopyToClipboardProps {
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export function useCopyToClipboard({ timeout = 2000 }: UseCopyToClipboardProps = {}) {
|
||||
export function useCopyToClipboard(
|
||||
{ timeout = 2000 }: UseCopyToClipboardProps = {},
|
||||
) {
|
||||
const [isCopied, setIsCopied] = useState<boolean>(false);
|
||||
const timeoutRef = useRef<number | null>(null);
|
||||
|
||||
@@ -19,7 +21,7 @@ export function useCopyToClipboard({ timeout = 2000 }: UseCopyToClipboardProps =
|
||||
const copy = useCallback(
|
||||
async (text: string) => {
|
||||
if (!navigator?.clipboard) {
|
||||
console.warn('Clipboard API not available');
|
||||
console.warn("Clipboard API not available");
|
||||
setIsCopied(false);
|
||||
return false;
|
||||
}
|
||||
@@ -39,13 +41,13 @@ export function useCopyToClipboard({ timeout = 2000 }: UseCopyToClipboardProps =
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to copy text to clipboard:', error);
|
||||
console.error("Failed to copy text to clipboard:", error);
|
||||
setIsCopied(false);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[timeout]
|
||||
[timeout],
|
||||
);
|
||||
|
||||
return { isCopied, copy };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,14 +33,16 @@ function isReminderExpired(expires?: string): boolean {
|
||||
export function useBackupReminder({
|
||||
enabled,
|
||||
message,
|
||||
onAccept = () => { },
|
||||
onAccept = () => {},
|
||||
reminderInDays = REMINDER_DAYS_ONE_WEEK,
|
||||
}: UseBackupReminderOptions) {
|
||||
const { toast } = useToast();
|
||||
const toastShownRef = useRef(false);
|
||||
const [reminderState, setReminderState] = useLocalStorage<ReminderState | null>(
|
||||
const [reminderState, setReminderState] = useLocalStorage<
|
||||
ReminderState | null
|
||||
>(
|
||||
STORAGE_KEY,
|
||||
null
|
||||
null,
|
||||
);
|
||||
|
||||
const setReminderExpiry = useCallback((days: number) => {
|
||||
@@ -73,7 +75,7 @@ export function useBackupReminder({
|
||||
setReminderExpiry(reminderInDays);
|
||||
}}
|
||||
>
|
||||
Remind me in {reminderInDays} day{reminderInDays > 1 ? 's' : ''}
|
||||
Remind me in {reminderInDays} day{reminderInDays > 1 ? "s" : ""}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -108,6 +110,5 @@ export function useBackupReminder({
|
||||
enabled,
|
||||
message,
|
||||
onAccept,
|
||||
|
||||
]);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,52 +1,52 @@
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import useLocalStorage from './useLocalStorage'
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import useLocalStorage from "./useLocalStorage.ts";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
describe('useLocalStorage', () => {
|
||||
const key = 'test-key'
|
||||
describe("useLocalStorage", () => {
|
||||
const key = "test-key";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('should initialize with initial value if localStorage is empty', () => {
|
||||
const { result } = renderHook(() => useLocalStorage(key, 'initial'))
|
||||
const [value] = result.current
|
||||
expect(value).toBe('initial')
|
||||
})
|
||||
it("should initialize with initial value if localStorage is empty", () => {
|
||||
const { result } = renderHook(() => useLocalStorage(key, "initial"));
|
||||
const [value] = result.current;
|
||||
expect(value).toBe("initial");
|
||||
});
|
||||
|
||||
it('should read existing value from localStorage', () => {
|
||||
localStorage.setItem(key, JSON.stringify('stored'))
|
||||
const { result } = renderHook(() => useLocalStorage(key, 'initial'))
|
||||
const [value] = result.current
|
||||
expect(value).toBe('stored')
|
||||
})
|
||||
it("should read existing value from localStorage", () => {
|
||||
localStorage.setItem(key, JSON.stringify("stored"));
|
||||
const { result } = renderHook(() => useLocalStorage(key, "initial"));
|
||||
const [value] = result.current;
|
||||
expect(value).toBe("stored");
|
||||
});
|
||||
|
||||
it('should update localStorage when setValue is called', () => {
|
||||
const { result } = renderHook(() => useLocalStorage(key, 'initial'))
|
||||
const [, setValue] = result.current
|
||||
it("should update localStorage when setValue is called", () => {
|
||||
const { result } = renderHook(() => useLocalStorage(key, "initial"));
|
||||
const [, setValue] = result.current;
|
||||
|
||||
act(() => {
|
||||
setValue('updated')
|
||||
})
|
||||
setValue("updated");
|
||||
});
|
||||
|
||||
expect(localStorage.getItem(key)).toBe(JSON.stringify('updated'))
|
||||
expect(result.current[0]).toBe('updated')
|
||||
})
|
||||
expect(localStorage.getItem(key)).toBe(JSON.stringify("updated"));
|
||||
expect(result.current[0]).toBe("updated");
|
||||
});
|
||||
|
||||
it('should remove value from localStorage when removeValue is called', () => {
|
||||
const { result } = renderHook(() => useLocalStorage(key, 'initial'))
|
||||
const [, setValue, removeValue] = result.current
|
||||
it("should remove value from localStorage when removeValue is called", () => {
|
||||
const { result } = renderHook(() => useLocalStorage(key, "initial"));
|
||||
const [, setValue, removeValue] = result.current;
|
||||
|
||||
act(() => {
|
||||
setValue('to-be-removed')
|
||||
})
|
||||
setValue("to-be-removed");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
removeValue()
|
||||
})
|
||||
removeValue();
|
||||
});
|
||||
|
||||
expect(localStorage.getItem(key)).toBeNull()
|
||||
expect(result.current[0]).toBe('initial')
|
||||
})
|
||||
})
|
||||
expect(localStorage.getItem(key)).toBeNull();
|
||||
expect(result.current[0]).toBe("initial");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,8 +55,9 @@ export default function useLocalStorage<T>(
|
||||
return undefined as unknown as T;
|
||||
}
|
||||
|
||||
const defaultValue =
|
||||
initialValue instanceof Function ? initialValue() : initialValue;
|
||||
const defaultValue = initialValue instanceof Function
|
||||
? initialValue()
|
||||
: initialValue;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
@@ -74,8 +75,9 @@ export default function useLocalStorage<T>(
|
||||
// Get from local storage then
|
||||
// parse stored json or return initialValue
|
||||
const readValue = useCallback((): T => {
|
||||
const initialValueToUse =
|
||||
initialValue instanceof Function ? initialValue() : initialValue;
|
||||
const initialValueToUse = initialValue instanceof Function
|
||||
? initialValue()
|
||||
: initialValue;
|
||||
|
||||
// Prevent build error "window is undefined" but keep working
|
||||
if (IS_SERVER) {
|
||||
@@ -83,7 +85,7 @@ export default function useLocalStorage<T>(
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
const raw = globalThis.localStorage.getItem(key);
|
||||
return raw ? deserializer(raw) : initialValueToUse;
|
||||
} catch (error) {
|
||||
console.warn(`Error reading localStorage key “${key}”:`, error);
|
||||
@@ -115,13 +117,13 @@ export default function useLocalStorage<T>(
|
||||
const newValue = value instanceof Function ? value(readValue()) : value;
|
||||
|
||||
// Save to local storage
|
||||
window.localStorage.setItem(key, serializer(newValue));
|
||||
globalThis.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 }));
|
||||
globalThis.dispatchEvent(new StorageEvent("local-storage", { key }));
|
||||
} catch (error) {
|
||||
console.warn(`Error setting localStorage key “${key}”:`, error);
|
||||
}
|
||||
@@ -137,17 +139,18 @@ export default function useLocalStorage<T>(
|
||||
);
|
||||
}
|
||||
|
||||
const defaultValue =
|
||||
initialValue instanceof Function ? initialValue() : initialValue;
|
||||
const defaultValue = initialValue instanceof Function
|
||||
? initialValue()
|
||||
: initialValue;
|
||||
|
||||
// Remove the key from local storage
|
||||
window.localStorage.removeItem(key);
|
||||
globalThis.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 }));
|
||||
globalThis.dispatchEvent(new StorageEvent("local-storage", { key }));
|
||||
}, [key]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -176,4 +179,4 @@ export default function useLocalStorage<T>(
|
||||
}, []);
|
||||
|
||||
return [storedValue, setValue, removeValue];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { usePasswordVisibilityToggle } from './usePasswordVisibilityToggle.ts';
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { usePasswordVisibilityToggle } from "./usePasswordVisibilityToggle.ts";
|
||||
|
||||
describe('usePasswordVisibilityToggle Hook', () => {
|
||||
it('should initialize with visibility set to false by default', () => {
|
||||
describe("usePasswordVisibilityToggle Hook", () => {
|
||||
it("should initialize with visibility set to false by default", () => {
|
||||
const { result } = renderHook(() => usePasswordVisibilityToggle());
|
||||
expect(result.current.isVisible).toBe(false);
|
||||
expect(typeof result.current.toggleVisibility).toBe('function');
|
||||
expect(typeof result.current.toggleVisibility).toBe("function");
|
||||
});
|
||||
|
||||
it('should initialize with visibility set to true if initialVisible is true', () => {
|
||||
it("should initialize with visibility set to true if initialVisible is true", () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePasswordVisibilityToggle({ initialVisible: true })
|
||||
);
|
||||
expect(result.current.isVisible).toBe(true);
|
||||
});
|
||||
|
||||
it('should toggle visibility from false to true when toggleVisibility is called', () => {
|
||||
it("should toggle visibility from false to true when toggleVisibility is called", () => {
|
||||
const { result } = renderHook(() => usePasswordVisibilityToggle());
|
||||
expect(result.current.isVisible).toBe(false);
|
||||
act(() => {
|
||||
@@ -25,7 +25,7 @@ describe('usePasswordVisibilityToggle Hook', () => {
|
||||
expect(result.current.isVisible).toBe(true);
|
||||
});
|
||||
|
||||
it('should toggle visibility from true to false when toggleVisibility is called', () => {
|
||||
it("should toggle visibility from true to false when toggleVisibility is called", () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePasswordVisibilityToggle({ initialVisible: true })
|
||||
);
|
||||
@@ -36,7 +36,7 @@ describe('usePasswordVisibilityToggle Hook', () => {
|
||||
expect(result.current.isVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('should toggle visibility correctly multiple times', () => {
|
||||
it("should toggle visibility correctly multiple times", () => {
|
||||
const { result } = renderHook(() => usePasswordVisibilityToggle());
|
||||
expect(result.current.isVisible).toBe(false);
|
||||
act(() => {
|
||||
@@ -53,8 +53,10 @@ describe('usePasswordVisibilityToggle Hook', () => {
|
||||
expect(result.current.isVisible).toBe(true);
|
||||
});
|
||||
|
||||
it('should return a stable toggleVisibility function reference (due to useCallback)', () => {
|
||||
const { result, rerender } = renderHook(() => usePasswordVisibilityToggle());
|
||||
it("should return a stable toggleVisibility function reference (due to useCallback)", () => {
|
||||
const { result, rerender } = renderHook(() =>
|
||||
usePasswordVisibilityToggle()
|
||||
);
|
||||
const initialToggleFunc = result.current.toggleVisibility;
|
||||
rerender();
|
||||
expect(result.current.toggleVisibility).toBe(initialToggleFunc);
|
||||
@@ -63,4 +65,4 @@ describe('usePasswordVisibilityToggle Hook', () => {
|
||||
});
|
||||
expect(result.current.isVisible).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
interface UsePasswordVisibilityToggleProps {
|
||||
initialVisible?: boolean;
|
||||
@@ -6,15 +6,17 @@ interface UsePasswordVisibilityToggleProps {
|
||||
/**
|
||||
* Manages the state for toggling password visibility.
|
||||
*
|
||||
* @param {boolean} [options.initialVisible=false]
|
||||
* @returns {{isVisible: boolean, toggleVisibility: () => void}}
|
||||
* @param {boolean} [options.initialVisible=false]
|
||||
* @returns {{isVisible: boolean, toggleVisibility: () => void}}
|
||||
*/
|
||||
export function usePasswordVisibilityToggle({ initialVisible = false }: UsePasswordVisibilityToggleProps = {}) {
|
||||
export function usePasswordVisibilityToggle(
|
||||
{ initialVisible = false }: UsePasswordVisibilityToggleProps = {},
|
||||
) {
|
||||
const [isVisible, setIsVisible] = useState<boolean>(initialVisible);
|
||||
|
||||
const toggleVisibility = useCallback(() => {
|
||||
setIsVisible(prev => !prev);
|
||||
setIsVisible((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
return { isVisible, toggleVisibility };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { usePinnedItems } from "./usePinnedItems.ts";
|
||||
|
||||
const mockSetPinnedItems = vi.fn();
|
||||
const mockUseLocalStorage = vi.fn();
|
||||
|
||||
vi.mock("@core/hooks/useLocalStorage.ts", () => ({
|
||||
default: (...args: any[]) => mockUseLocalStorage(...args),
|
||||
default: (...args) => mockUseLocalStorage(...args),
|
||||
}));
|
||||
|
||||
describe("usePinnedItems", () => {
|
||||
@@ -45,7 +45,10 @@ describe("usePinnedItems", () => {
|
||||
});
|
||||
|
||||
it("removes an item if it's already pinned", () => {
|
||||
mockUseLocalStorage.mockReturnValue([["item1", "item2"], mockSetPinnedItems]);
|
||||
mockUseLocalStorage.mockReturnValue([
|
||||
["item1", "item2"],
|
||||
mockSetPinnedItems,
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePinnedItems({ storageName: "test-storage" })
|
||||
|
||||
@@ -2,13 +2,14 @@ import useLocalStorage from "@core/hooks/useLocalStorage.ts";
|
||||
import { useCallback } from "react";
|
||||
|
||||
export function usePinnedItems({ storageName }: { storageName: string }) {
|
||||
const [pinnedItems, setPinnedItems] = useLocalStorage<string[]>(storageName, []);
|
||||
const [pinnedItems, setPinnedItems] = useLocalStorage<string[]>(
|
||||
storageName,
|
||||
[],
|
||||
);
|
||||
|
||||
const togglePinnedItem = useCallback((label: string) => {
|
||||
setPinnedItems((prev) =>
|
||||
prev.includes(label)
|
||||
? prev.filter((g) => g !== label)
|
||||
: [...prev, label]
|
||||
prev.includes(label) ? prev.filter((g) => g !== label) : [...prev, label]
|
||||
);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,81 +1,79 @@
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useToast } from "@core/hooks/useToast.ts"
|
||||
import { Button } from '@components/UI/Button.tsx'
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { useToast } from "@core/hooks/useToast.ts";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe('useToast', () => {
|
||||
describe("useToast", () => {
|
||||
beforeEach(() => {
|
||||
// Reset toast memory state before each test
|
||||
// our hook uses global memory to store toasts
|
||||
// @ts-expect-error - internal test reset
|
||||
globalThis.memoryState = { toasts: [] }
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
globalThis.memoryState = { toasts: [] };
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should create a toast with title, description, and action', () => {
|
||||
const { result } = renderHook(() => useToast())
|
||||
it("should create a toast with title, description, and action", () => {
|
||||
const { result } = renderHook(() => useToast());
|
||||
|
||||
act(() => {
|
||||
result.current.toast({
|
||||
title: 'Backup Reminder',
|
||||
description: 'Don\'t forget to backup!',
|
||||
action: <Button>Backup Now</Button>
|
||||
})
|
||||
vi.runAllTimers()
|
||||
})
|
||||
title: "Backup Reminder",
|
||||
description: "Don't forget to backup!",
|
||||
action: <Button>Backup Now</Button>,
|
||||
});
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
const toast = result.current.toasts[0]
|
||||
expect(result.current.toasts.length).toBe(1)
|
||||
expect(toast.title).toBe('Backup Reminder')
|
||||
expect(toast.description).toBe('Don\'t forget to backup!')
|
||||
expect(toast.action).toBeTruthy()
|
||||
expect(toast.open).toBe(true)
|
||||
})
|
||||
it('should dismiss a toast using returned dismiss function', () => {
|
||||
const { result } = renderHook(() => useToast())
|
||||
vi.useFakeTimers()
|
||||
const toast = result.current.toasts[0];
|
||||
expect(result.current.toasts.length).toBe(1);
|
||||
expect(toast.title).toBe("Backup Reminder");
|
||||
expect(toast.description).toBe("Don't forget to backup!");
|
||||
expect(toast.action).toBeTruthy();
|
||||
expect(toast.open).toBe(true);
|
||||
});
|
||||
it("should dismiss a toast using returned dismiss function", () => {
|
||||
const { result } = renderHook(() => useToast());
|
||||
vi.useFakeTimers();
|
||||
|
||||
let toastRef: { id: string, dismiss: () => void }
|
||||
let toastRef: { id: string; dismiss: () => void };
|
||||
|
||||
act(() => {
|
||||
toastRef = result.current.toast({ title: 'Dismiss Me' })
|
||||
vi.runAllTimers() // Flush ADD_TOAST
|
||||
})
|
||||
toastRef = result.current.toast({ title: "Dismiss Me" });
|
||||
vi.runAllTimers(); // Flush ADD_TOAST
|
||||
});
|
||||
|
||||
act(() => {
|
||||
toastRef.dismiss()
|
||||
})
|
||||
toastRef.dismiss();
|
||||
});
|
||||
|
||||
const toast = result.current.toasts.find(t => t.id === toastRef.id)
|
||||
expect(toast?.open).toBe(false)
|
||||
const toast = result.current.toasts.find((t) => t.id === toastRef.id);
|
||||
expect(toast?.open).toBe(false);
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should allow dismiss via hook dismiss function", () => {
|
||||
const { result } = renderHook(() => useToast());
|
||||
vi.useFakeTimers();
|
||||
|
||||
it('should allow dismiss via hook dismiss function', () => {
|
||||
const { result } = renderHook(() => useToast())
|
||||
vi.useFakeTimers()
|
||||
|
||||
let toastRef: { id: string }
|
||||
let toastRef: { id: string };
|
||||
|
||||
act(() => {
|
||||
toastRef = result.current.toast({ title: 'Manual Dismiss' })
|
||||
vi.runAllTimers()
|
||||
})
|
||||
toastRef = result.current.toast({ title: "Manual Dismiss" });
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.dismiss(toastRef.id)
|
||||
})
|
||||
result.current.dismiss(toastRef.id);
|
||||
});
|
||||
|
||||
const toast = result.current.toasts.find(t => t.id === toastRef.id)
|
||||
expect(toast?.open).toBe(false)
|
||||
const toast = result.current.toasts.find((t) => t.id === toastRef.id);
|
||||
expect(toast?.open).toBe(false);
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
})
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
+115
-39
@@ -17,7 +17,7 @@ export type DialogVariant = keyof Device["dialog"];
|
||||
type NodeError = {
|
||||
node: number;
|
||||
error: string;
|
||||
}
|
||||
};
|
||||
|
||||
export interface Device {
|
||||
id: number;
|
||||
@@ -92,7 +92,6 @@ export interface Device {
|
||||
) => Protobuf.Mesh.NodeInfo[];
|
||||
getNodesLength: () => number;
|
||||
getNode: (nodeNum: number) => Protobuf.Mesh.NodeInfo | undefined;
|
||||
|
||||
}
|
||||
|
||||
export interface DeviceState {
|
||||
@@ -110,7 +109,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
remoteDevices: new Map(),
|
||||
|
||||
addDevice: (id: number) => {
|
||||
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
draft.devices.set(id, {
|
||||
@@ -164,14 +162,37 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
const device = draft.devices.get(id);
|
||||
if (device) {
|
||||
switch (config.payloadVariant.case) {
|
||||
case "device": { device.config.device = config.payloadVariant.value; break; }
|
||||
case "position": { device.config.position = config.payloadVariant.value; break; }
|
||||
case "power": { device.config.power = config.payloadVariant.value; break; }
|
||||
case "network": { device.config.network = config.payloadVariant.value; break; }
|
||||
case "display": { device.config.display = config.payloadVariant.value; break; }
|
||||
case "lora": { device.config.lora = config.payloadVariant.value; break; }
|
||||
case "bluetooth": { device.config.bluetooth = config.payloadVariant.value; break; }
|
||||
case "security": { device.config.security = config.payloadVariant.value; }
|
||||
case "device": {
|
||||
device.config.device = config.payloadVariant.value;
|
||||
break;
|
||||
}
|
||||
case "position": {
|
||||
device.config.position = config.payloadVariant.value;
|
||||
break;
|
||||
}
|
||||
case "power": {
|
||||
device.config.power = config.payloadVariant.value;
|
||||
break;
|
||||
}
|
||||
case "network": {
|
||||
device.config.network = config.payloadVariant.value;
|
||||
break;
|
||||
}
|
||||
case "display": {
|
||||
device.config.display = config.payloadVariant.value;
|
||||
break;
|
||||
}
|
||||
case "lora": {
|
||||
device.config.lora = config.payloadVariant.value;
|
||||
break;
|
||||
}
|
||||
case "bluetooth": {
|
||||
device.config.bluetooth = config.payloadVariant.value;
|
||||
break;
|
||||
}
|
||||
case "security": {
|
||||
device.config.security = config.payloadVariant.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
@@ -183,18 +204,63 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
const device = draft.devices.get(id);
|
||||
if (device) {
|
||||
switch (config.payloadVariant.case) {
|
||||
case "mqtt": { device.moduleConfig.mqtt = config.payloadVariant.value; break; }
|
||||
case "serial": { device.moduleConfig.serial = config.payloadVariant.value; break; }
|
||||
case "externalNotification": { device.moduleConfig.externalNotification = config.payloadVariant.value; break; }
|
||||
case "storeForward": { device.moduleConfig.storeForward = config.payloadVariant.value; break; }
|
||||
case "rangeTest": { device.moduleConfig.rangeTest = config.payloadVariant.value; break; }
|
||||
case "telemetry": { device.moduleConfig.telemetry = config.payloadVariant.value; break; }
|
||||
case "cannedMessage": { device.moduleConfig.cannedMessage = config.payloadVariant.value; break; }
|
||||
case "audio": { device.moduleConfig.audio = config.payloadVariant.value; break; }
|
||||
case "neighborInfo": { device.moduleConfig.neighborInfo = config.payloadVariant.value; break; }
|
||||
case "ambientLighting": { device.moduleConfig.ambientLighting = config.payloadVariant.value; break; }
|
||||
case "detectionSensor": { device.moduleConfig.detectionSensor = config.payloadVariant.value; break; }
|
||||
case "paxcounter": { device.moduleConfig.paxcounter = config.payloadVariant.value; break; }
|
||||
case "mqtt": {
|
||||
device.moduleConfig.mqtt = config.payloadVariant.value;
|
||||
break;
|
||||
}
|
||||
case "serial": {
|
||||
device.moduleConfig.serial = config.payloadVariant.value;
|
||||
break;
|
||||
}
|
||||
case "externalNotification": {
|
||||
device.moduleConfig.externalNotification =
|
||||
config.payloadVariant.value;
|
||||
break;
|
||||
}
|
||||
case "storeForward": {
|
||||
device.moduleConfig.storeForward =
|
||||
config.payloadVariant.value;
|
||||
break;
|
||||
}
|
||||
case "rangeTest": {
|
||||
device.moduleConfig.rangeTest =
|
||||
config.payloadVariant.value;
|
||||
break;
|
||||
}
|
||||
case "telemetry": {
|
||||
device.moduleConfig.telemetry =
|
||||
config.payloadVariant.value;
|
||||
break;
|
||||
}
|
||||
case "cannedMessage": {
|
||||
device.moduleConfig.cannedMessage =
|
||||
config.payloadVariant.value;
|
||||
break;
|
||||
}
|
||||
case "audio": {
|
||||
device.moduleConfig.audio = config.payloadVariant.value;
|
||||
break;
|
||||
}
|
||||
case "neighborInfo": {
|
||||
device.moduleConfig.neighborInfo =
|
||||
config.payloadVariant.value;
|
||||
break;
|
||||
}
|
||||
case "ambientLighting": {
|
||||
device.moduleConfig.ambientLighting =
|
||||
config.payloadVariant.value;
|
||||
break;
|
||||
}
|
||||
case "detectionSensor": {
|
||||
device.moduleConfig.detectionSensor =
|
||||
config.payloadVariant.value;
|
||||
break;
|
||||
}
|
||||
case "paxcounter": {
|
||||
device.moduleConfig.paxcounter =
|
||||
config.payloadVariant.value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
@@ -216,13 +282,17 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
}),
|
||||
);
|
||||
},
|
||||
setWorkingModuleConfig: (moduleConfig: Protobuf.ModuleConfig.ModuleConfig) => {
|
||||
setWorkingModuleConfig: (
|
||||
moduleConfig: Protobuf.ModuleConfig.ModuleConfig,
|
||||
) => {
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
const device = draft.devices.get(id);
|
||||
if (!device) return;
|
||||
const index = device.workingModuleConfig.findIndex(
|
||||
(wmc) => wmc.payloadVariant.case === moduleConfig.payloadVariant.case,
|
||||
(wmc) =>
|
||||
wmc.payloadVariant.case ===
|
||||
moduleConfig.payloadVariant.case,
|
||||
);
|
||||
if (index !== -1) {
|
||||
device.workingModuleConfig[index] = moduleConfig;
|
||||
@@ -277,7 +347,9 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
produce<DeviceState>((draft) => {
|
||||
const device = draft.devices.get(id);
|
||||
if (device) {
|
||||
const index = device.waypoints.findIndex((wp) => wp.id === waypoint.id);
|
||||
const index = device.waypoints.findIndex((wp) =>
|
||||
wp.id === waypoint.id
|
||||
);
|
||||
if (index !== -1) {
|
||||
device.waypoints[index] = waypoint;
|
||||
} else {
|
||||
@@ -288,8 +360,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
);
|
||||
},
|
||||
addNodeInfo: (nodeInfo) => {
|
||||
console.log("Node Info", nodeInfo);
|
||||
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
const device = draft.devices.get(id);
|
||||
@@ -316,11 +386,12 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
if (!device) {
|
||||
return;
|
||||
}
|
||||
const currentNode = device.nodesMap.get(user.from) ?? create(Protobuf.Mesh.NodeInfoSchema);
|
||||
const currentNode = device.nodesMap.get(user.from) ??
|
||||
create(Protobuf.Mesh.NodeInfoSchema);
|
||||
currentNode.user = user.data;
|
||||
currentNode.num = user.from;
|
||||
device.nodesMap.set(user.from, currentNode);
|
||||
})
|
||||
}),
|
||||
);
|
||||
},
|
||||
addPosition: (position) => {
|
||||
@@ -330,11 +401,12 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
if (!device) {
|
||||
return;
|
||||
}
|
||||
const currentNode = device.nodesMap.get(position.from) ?? create(Protobuf.Mesh.NodeInfoSchema);
|
||||
const currentNode = device.nodesMap.get(position.from) ??
|
||||
create(Protobuf.Mesh.NodeInfoSchema);
|
||||
currentNode.position = position.data;
|
||||
currentNode.num = position.from;
|
||||
device.nodesMap.set(position.from, currentNode);
|
||||
})
|
||||
}),
|
||||
);
|
||||
},
|
||||
addConnection: (connection) => {
|
||||
@@ -373,10 +445,11 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
produce<DeviceState>((draft) => {
|
||||
const device = draft.devices.get(id);
|
||||
if (!device) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
device.nodesMap.delete(nodeNum);
|
||||
}))
|
||||
}),
|
||||
);
|
||||
},
|
||||
setDialogOpen: (dialog: DialogVariant, open: boolean) => {
|
||||
set(
|
||||
@@ -463,7 +536,7 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
if (!device) return;
|
||||
const currentCount = device.unreadCounts.get(nodeNum) ?? 0;
|
||||
device.unreadCounts.set(nodeNum, currentCount + 1);
|
||||
})
|
||||
}),
|
||||
);
|
||||
},
|
||||
resetUnread: (nodeNum: number) => {
|
||||
@@ -475,16 +548,19 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
if (device.unreadCounts.get(nodeNum) === 0) {
|
||||
device.unreadCounts.delete(nodeNum);
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
},
|
||||
getNodes: (filter?: (node: Protobuf.Mesh.NodeInfo) => boolean): Protobuf.Mesh.NodeInfo[] => {
|
||||
getNodes: (
|
||||
filter?: (node: Protobuf.Mesh.NodeInfo) => boolean,
|
||||
): Protobuf.Mesh.NodeInfo[] => {
|
||||
const device = get().devices.get(id);
|
||||
if (!device) {
|
||||
return [];
|
||||
}
|
||||
const allNodes = Array.from(device.nodesMap.values()).filter(
|
||||
(node) => node.num !== get().devices.get(id)?.hardware.myNodeNum);
|
||||
(node) => node.num !== get().devices.get(id)?.hardware.myNodeNum,
|
||||
);
|
||||
if (filter) {
|
||||
return allNodes.filter(filter);
|
||||
}
|
||||
@@ -505,7 +581,7 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
if (!device) {
|
||||
return 0;
|
||||
}
|
||||
return device.nodesMap.size
|
||||
return device.nodesMap.size;
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { produce } from 'immer';
|
||||
import { Types } from '@meshtastic/core';
|
||||
import { storageWithMapSupport } from "../storage/indexDB.ts";
|
||||
import { ChannelId, ClearMessageParams, ConversationId, GetMessagesParams, Message, MessageId, MessageLogMap, NodeNum, SetMessageStateParams } from "@core/stores/messageStore/types.ts";
|
||||
import { create } from "zustand";
|
||||
// import { persist } from "zustand/middleware";
|
||||
import { produce } from "immer";
|
||||
import { Types } from "@meshtastic/core";
|
||||
// import { storageWithMapSupport } from "../storage/indexDB.ts";
|
||||
import {
|
||||
ChannelId,
|
||||
ClearMessageParams,
|
||||
ConversationId,
|
||||
GetMessagesParams,
|
||||
Message,
|
||||
MessageId,
|
||||
MessageLogMap,
|
||||
NodeNum,
|
||||
SetMessageStateParams,
|
||||
} from "@core/stores/messageStore/types.ts";
|
||||
|
||||
export enum MessageState {
|
||||
Ack = "ack",
|
||||
@@ -16,8 +26,11 @@ export enum MessageType {
|
||||
Broadcast = "broadcast",
|
||||
}
|
||||
|
||||
export function getConversationId(node1: NodeNum, node2: NodeNum): ConversationId {
|
||||
return [node1, node2].sort((a, b) => a - b).join(':');
|
||||
export function getConversationId(
|
||||
node1: NodeNum,
|
||||
node2: NodeNum,
|
||||
): ConversationId {
|
||||
return [node1, node2].sort((a, b) => a - b).join(":");
|
||||
}
|
||||
|
||||
export interface MessageStore {
|
||||
@@ -25,9 +38,9 @@ export interface MessageStore {
|
||||
direct: Map<ConversationId, MessageLogMap>;
|
||||
broadcast: Map<ChannelId, MessageLogMap>;
|
||||
};
|
||||
};
|
||||
}
|
||||
export interface MessageStore {
|
||||
messages: MessageStore['messages'];
|
||||
messages: MessageStore["messages"];
|
||||
draft: Map<Types.Destination, string>;
|
||||
nodeNum: number; // This device's node number
|
||||
activeChat: number; // Represents otherNodeNum for Direct, or channel for Broadcast
|
||||
@@ -47,7 +60,7 @@ export interface MessageStore {
|
||||
clearDraft: (key: Types.Destination) => void;
|
||||
}
|
||||
|
||||
const CURRENT_STORE_VERSION = 0;
|
||||
// const CURRENT_STORE_VERSION = 0;
|
||||
|
||||
export const useMessageStore = create<MessageStore>()(
|
||||
// persist(
|
||||
@@ -82,17 +95,29 @@ export const useMessageStore = create<MessageStore>()(
|
||||
if (message.type === MessageType.Direct) {
|
||||
const conversationId = getConversationId(message.from, message.to);
|
||||
if (!state.messages.direct.has(conversationId)) {
|
||||
state.messages.direct.set(conversationId, new Map<MessageId, Message>());
|
||||
state.messages.direct.set(
|
||||
conversationId,
|
||||
new Map<MessageId, Message>(),
|
||||
);
|
||||
}
|
||||
state.messages.direct.get(conversationId)!.set(message.messageId, message);
|
||||
state.messages.direct.get(conversationId)!.set(
|
||||
message.messageId,
|
||||
message,
|
||||
);
|
||||
} else if (message.type === MessageType.Broadcast) {
|
||||
const channelId = message.channel as ChannelId;
|
||||
if (!state.messages.broadcast.has(channelId)) {
|
||||
state.messages.broadcast.set(channelId, new Map<MessageId, Message>());
|
||||
state.messages.broadcast.set(
|
||||
channelId,
|
||||
new Map<MessageId, Message>(),
|
||||
);
|
||||
}
|
||||
state.messages.broadcast.get(channelId)!.set(message.messageId, message);
|
||||
state.messages.broadcast.get(channelId)!.set(
|
||||
message.messageId,
|
||||
message,
|
||||
);
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -103,7 +128,10 @@ export const useMessageStore = create<MessageStore>()(
|
||||
let targetMessage: Message | undefined;
|
||||
|
||||
if (params.type === MessageType.Direct) {
|
||||
const conversationId = getConversationId(params.nodeA, params.nodeB);
|
||||
const conversationId = getConversationId(
|
||||
params.nodeA,
|
||||
params.nodeB,
|
||||
);
|
||||
messageLog = state.messages.direct.get(conversationId);
|
||||
if (messageLog) {
|
||||
targetMessage = messageLog.get(params.messageId);
|
||||
@@ -118,9 +146,13 @@ export const useMessageStore = create<MessageStore>()(
|
||||
if (targetMessage) {
|
||||
targetMessage.state = params.newState ?? MessageState.Ack;
|
||||
} else {
|
||||
console.warn(`Message or conversation/channel not found for state update. Params: ${JSON.stringify(params)}`);
|
||||
console.warn(
|
||||
`Message or conversation/channel not found for state update. Params: ${
|
||||
JSON.stringify(params)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
},
|
||||
getMessages: (params: GetMessagesParams): Message[] => {
|
||||
@@ -130,7 +162,6 @@ export const useMessageStore = create<MessageStore>()(
|
||||
if (params.type === MessageType.Direct) {
|
||||
const conversationId = getConversationId(params.nodeA, params.nodeB);
|
||||
messageMap = state.messages.direct.get(conversationId);
|
||||
|
||||
} else {
|
||||
messageMap = state.messages.broadcast.get(params.channelId);
|
||||
}
|
||||
@@ -165,23 +196,29 @@ export const useMessageStore = create<MessageStore>()(
|
||||
const deleted = messageLog.delete(params.messageId);
|
||||
|
||||
if (deleted) {
|
||||
console.log(`Deleted message ${params.messageId} from ${params.type} message ${parentKey}`);
|
||||
console.log(
|
||||
`Deleted message ${params.messageId} from ${params.type} message ${parentKey}`,
|
||||
);
|
||||
// Clean up empty MessageLogMap and its entry in the parent map
|
||||
if (messageLog.size === 0) {
|
||||
parentMap.delete(parentKey);
|
||||
console.log(`Cleaned up empty message entry for ${parentKey}`);
|
||||
}
|
||||
} else {
|
||||
console.warn(`Message ${params.messageId} not found in ${params.type} chat ${parentKey} for deletion.`);
|
||||
console.warn(
|
||||
`Message ${params.messageId} not found in ${params.type} chat ${parentKey} for deletion.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.warn(`Message entry ${parentKey} not found for message deletion.`);
|
||||
console.warn(
|
||||
`Message entry ${parentKey} not found for message deletion.`,
|
||||
);
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
},
|
||||
getDraft: (key) => {
|
||||
return get().draft.get(key) ?? '';
|
||||
return get().draft.get(key) ?? "";
|
||||
},
|
||||
setDraft: (key, message) => {
|
||||
set(produce((state: MessageStore) => {
|
||||
@@ -198,7 +235,7 @@ export const useMessageStore = create<MessageStore>()(
|
||||
state.messages.direct = new Map<ConversationId, MessageLogMap>();
|
||||
state.messages.broadcast = new Map<ChannelId, MessageLogMap>();
|
||||
}));
|
||||
}
|
||||
},
|
||||
}),
|
||||
// {
|
||||
// name: 'meshtastic-message-store',
|
||||
@@ -209,4 +246,4 @@ export const useMessageStore = create<MessageStore>()(
|
||||
// nodeNum: state.nodeNum,
|
||||
// }),
|
||||
// })
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
useMessageStore,
|
||||
MessageType,
|
||||
MessageState,
|
||||
getConversationId,
|
||||
} from './index.ts';
|
||||
import type { ConversationId, ChannelId, MessageLogMap, Message } from './types.ts';
|
||||
import { Types } from '@meshtastic/core';
|
||||
MessageState,
|
||||
MessageType,
|
||||
useMessageStore,
|
||||
} from "./index.ts";
|
||||
import type {
|
||||
ChannelId,
|
||||
ConversationId,
|
||||
Message,
|
||||
MessageLogMap,
|
||||
} from "./types.ts";
|
||||
import { Types } from "@meshtastic/core";
|
||||
|
||||
vi.mock('../storage/indexDB.ts', () => {
|
||||
let memoryStorage: Record<string, string> = {};
|
||||
vi.mock("../storage/indexDB.ts", () => {
|
||||
const memoryStorage: Record<string, string> = {};
|
||||
return {
|
||||
storageWithMapSupport: {
|
||||
getItem: vi.fn(async (name: string): Promise<string | null> => {
|
||||
return memoryStorage[name] ?? null;
|
||||
return await memoryStorage[name] ?? null;
|
||||
}),
|
||||
setItem: vi.fn(async (name: string, value: string): Promise<void> => {
|
||||
memoryStorage[name] = value;
|
||||
memoryStorage[name] = await value;
|
||||
}),
|
||||
removeItem: vi.fn(async (name: string): Promise<void> => {
|
||||
delete memoryStorage[name];
|
||||
await delete memoryStorage[name];
|
||||
}),
|
||||
},
|
||||
};
|
||||
@@ -38,7 +43,7 @@ const directMessageToOther1: Message = {
|
||||
date: Date.now(),
|
||||
messageId: 101,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Hello other 1 from me',
|
||||
message: "Hello other 1 from me",
|
||||
};
|
||||
|
||||
const directMessageFromOther1: Message = {
|
||||
@@ -49,7 +54,7 @@ const directMessageFromOther1: Message = {
|
||||
date: Date.now() + 1000,
|
||||
messageId: 102,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Hello me from other 1',
|
||||
message: "Hello me from other 1",
|
||||
};
|
||||
|
||||
const directMessageToOther2: Message = {
|
||||
@@ -60,7 +65,7 @@ const directMessageToOther2: Message = {
|
||||
date: Date.now() + 2000,
|
||||
messageId: 103,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Hello other 2 from me',
|
||||
message: "Hello other 2 from me",
|
||||
};
|
||||
|
||||
const broadcastMessage1: Message = {
|
||||
@@ -71,7 +76,7 @@ const broadcastMessage1: Message = {
|
||||
date: Date.now() + 3000,
|
||||
messageId: 201,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Broadcast message 1',
|
||||
message: "Broadcast message 1",
|
||||
};
|
||||
|
||||
const broadcastMessage2: Message = {
|
||||
@@ -82,10 +87,10 @@ const broadcastMessage2: Message = {
|
||||
date: Date.now() + 4000,
|
||||
messageId: 202,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Broadcast message 2',
|
||||
message: "Broadcast message 2",
|
||||
};
|
||||
|
||||
describe('useMessageStore', () => {
|
||||
describe("useMessageStore", () => {
|
||||
const initialState = useMessageStore.getState();
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -97,10 +102,9 @@ describe('useMessageStore', () => {
|
||||
},
|
||||
draft: new Map<Types.Destination, string>(),
|
||||
}, true);
|
||||
|
||||
});
|
||||
|
||||
it('should have correct initial state', () => {
|
||||
it("should have correct initial state", () => {
|
||||
const state = useMessageStore.getState();
|
||||
expect(state.messages.direct).toBeInstanceOf(Map);
|
||||
expect(state.messages.direct.size).toBe(0);
|
||||
@@ -113,23 +117,26 @@ describe('useMessageStore', () => {
|
||||
expect(state.chatType).toBe(MessageType.Broadcast);
|
||||
});
|
||||
|
||||
it('should set nodeNum', () => {
|
||||
it("should set nodeNum", () => {
|
||||
useMessageStore.getState().setNodeNum(myNodeNum);
|
||||
expect(useMessageStore.getState().nodeNum).toBe(myNodeNum);
|
||||
});
|
||||
|
||||
it('should set activeChat and chatType', () => {
|
||||
it("should set activeChat and chatType", () => {
|
||||
useMessageStore.getState().setActiveChat(otherNodeNum1);
|
||||
useMessageStore.getState().setChatType(MessageType.Direct);
|
||||
expect(useMessageStore.getState().activeChat).toBe(otherNodeNum1);
|
||||
expect(useMessageStore.getState().chatType).toBe(MessageType.Direct);
|
||||
});
|
||||
|
||||
describe('saveMessage', () => {
|
||||
it('should save a direct message with correct Map structure', () => {
|
||||
describe("saveMessage", () => {
|
||||
it("should save a direct message with correct Map structure", () => {
|
||||
useMessageStore.getState().saveMessage(directMessageToOther1);
|
||||
const state = useMessageStore.getState();
|
||||
const conversationId = getConversationId(directMessageToOther1.from, directMessageToOther1.to);
|
||||
const conversationId = getConversationId(
|
||||
directMessageToOther1.from,
|
||||
directMessageToOther1.to,
|
||||
);
|
||||
|
||||
// Check if the conversation Map exists
|
||||
expect(state.messages.direct.has(conversationId)).toBe(true);
|
||||
@@ -139,10 +146,12 @@ describe('useMessageStore', () => {
|
||||
// Check if the message exists within the inner Map
|
||||
expect(conversationLog?.has(directMessageToOther1.messageId)).toBe(true);
|
||||
// Check the message content
|
||||
expect(conversationLog?.get(directMessageToOther1.messageId)).toEqual(directMessageToOther1);
|
||||
expect(conversationLog?.get(directMessageToOther1.messageId)).toEqual(
|
||||
directMessageToOther1,
|
||||
);
|
||||
});
|
||||
|
||||
it('should save a broadcast message with correct Map structure', () => {
|
||||
it("should save a broadcast message with correct Map structure", () => {
|
||||
useMessageStore.getState().saveMessage(broadcastMessage1);
|
||||
const state = useMessageStore.getState();
|
||||
const channelId = broadcastMessage1.channel;
|
||||
@@ -151,10 +160,12 @@ describe('useMessageStore', () => {
|
||||
const channelLog = state.messages.broadcast.get(channelId);
|
||||
expect(channelLog).toBeInstanceOf(Map);
|
||||
expect(channelLog?.has(broadcastMessage1.messageId)).toBe(true);
|
||||
expect(channelLog?.get(broadcastMessage1.messageId)).toEqual(broadcastMessage1);
|
||||
expect(channelLog?.get(broadcastMessage1.messageId)).toEqual(
|
||||
broadcastMessage1,
|
||||
);
|
||||
});
|
||||
|
||||
it('should save multiple messages correctly', () => {
|
||||
it("should save multiple messages correctly", () => {
|
||||
useMessageStore.getState().saveMessage(directMessageToOther1);
|
||||
useMessageStore.getState().saveMessage(directMessageFromOther1);
|
||||
useMessageStore.getState().saveMessage(broadcastMessage1);
|
||||
@@ -162,16 +173,28 @@ describe('useMessageStore', () => {
|
||||
const state = useMessageStore.getState();
|
||||
|
||||
const convId1 = getConversationId(myNodeNum, otherNodeNum1);
|
||||
expect(state.messages.direct.get(convId1)?.get(directMessageToOther1.messageId)).toEqual(directMessageToOther1);
|
||||
expect(
|
||||
state.messages.direct.get(convId1)?.get(
|
||||
directMessageToOther1.messageId,
|
||||
),
|
||||
).toEqual(directMessageToOther1);
|
||||
|
||||
expect(state.messages.direct.get(convId1)?.get(directMessageFromOther1.messageId)).toEqual(directMessageFromOther1);
|
||||
expect(
|
||||
state.messages.direct.get(convId1)?.get(
|
||||
directMessageFromOther1.messageId,
|
||||
),
|
||||
).toEqual(directMessageFromOther1);
|
||||
|
||||
const channelId = broadcastMessage1.channel;
|
||||
expect(state.messages.broadcast.get(channelId)?.get(broadcastMessage1.messageId)).toEqual(broadcastMessage1);
|
||||
expect(
|
||||
state.messages.broadcast.get(channelId)?.get(
|
||||
broadcastMessage1.messageId,
|
||||
),
|
||||
).toEqual(broadcastMessage1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMessages', () => {
|
||||
describe("getMessages", () => {
|
||||
beforeEach(() => {
|
||||
useMessageStore.getState().setNodeNum(myNodeNum);
|
||||
useMessageStore.getState().saveMessage(directMessageToOther1);
|
||||
@@ -181,56 +204,56 @@ describe('useMessageStore', () => {
|
||||
useMessageStore.getState().saveMessage(broadcastMessage2);
|
||||
});
|
||||
|
||||
it('should return broadcast messages for a channel, sorted by date', () => {
|
||||
it("should return broadcast messages for a channel, sorted by date", () => {
|
||||
const messages = useMessageStore.getState().getMessages({
|
||||
type: MessageType.Broadcast,
|
||||
channelId: broadcastChannel
|
||||
channelId: broadcastChannel,
|
||||
});
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toEqual(broadcastMessage1);
|
||||
expect(messages[1]).toEqual(broadcastMessage2);
|
||||
});
|
||||
|
||||
it('should return empty array for broadcast if channel has no messages', () => {
|
||||
it("should return empty array for broadcast if channel has no messages", () => {
|
||||
const messages = useMessageStore.getState().getMessages({
|
||||
type: MessageType.Broadcast,
|
||||
channelId: Types.ChannelNumber.Channel1
|
||||
channelId: Types.ChannelNumber.Channel1,
|
||||
});
|
||||
expect(messages).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return combined direct messages for a specific chat pair, sorted by date', () => {
|
||||
it("should return combined direct messages for a specific chat pair, sorted by date", () => {
|
||||
const messages = useMessageStore.getState().getMessages({
|
||||
type: MessageType.Direct,
|
||||
nodeA: myNodeNum,
|
||||
nodeB: otherNodeNum1
|
||||
nodeB: otherNodeNum1,
|
||||
});
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toEqual(directMessageToOther1);
|
||||
expect(messages[1]).toEqual(directMessageFromOther1);
|
||||
});
|
||||
|
||||
it('should return only relevant direct messages for a different chat pair', () => {
|
||||
it("should return only relevant direct messages for a different chat pair", () => {
|
||||
const messages = useMessageStore.getState().getMessages({
|
||||
type: MessageType.Direct,
|
||||
nodeA: myNodeNum,
|
||||
nodeB: otherNodeNum2
|
||||
nodeB: otherNodeNum2,
|
||||
});
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toEqual(directMessageToOther2);
|
||||
});
|
||||
|
||||
it('should return empty array for direct chat if no messages exist', () => {
|
||||
it("should return empty array for direct chat if no messages exist", () => {
|
||||
const messages = useMessageStore.getState().getMessages({
|
||||
type: MessageType.Direct,
|
||||
nodeA: myNodeNum,
|
||||
nodeB: 999
|
||||
nodeB: 999,
|
||||
});
|
||||
expect(messages).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setMessageState', () => {
|
||||
describe("setMessageState", () => {
|
||||
beforeEach(() => {
|
||||
useMessageStore.getState().setNodeNum(myNodeNum);
|
||||
useMessageStore.getState().saveMessage(directMessageToOther1);
|
||||
@@ -238,7 +261,7 @@ describe('useMessageStore', () => {
|
||||
useMessageStore.getState().saveMessage(broadcastMessage1);
|
||||
});
|
||||
|
||||
it('should update state for a direct message', () => {
|
||||
it("should update state for a direct message", () => {
|
||||
useMessageStore.getState().setMessageState({
|
||||
type: MessageType.Direct,
|
||||
nodeA: directMessageToOther1.from,
|
||||
@@ -246,12 +269,17 @@ describe('useMessageStore', () => {
|
||||
messageId: directMessageToOther1.messageId,
|
||||
newState: MessageState.Ack,
|
||||
});
|
||||
const conversationId = getConversationId(directMessageToOther1.from, directMessageToOther1.to);
|
||||
const message = useMessageStore.getState().messages.direct.get(conversationId)?.get(directMessageToOther1.messageId);
|
||||
const conversationId = getConversationId(
|
||||
directMessageToOther1.from,
|
||||
directMessageToOther1.to,
|
||||
);
|
||||
const message = useMessageStore.getState().messages.direct.get(
|
||||
conversationId,
|
||||
)?.get(directMessageToOther1.messageId);
|
||||
expect(message?.state).toBe(MessageState.Ack);
|
||||
});
|
||||
|
||||
it('should update state for another direct message in the same conversation', () => {
|
||||
it("should update state for another direct message in the same conversation", () => {
|
||||
useMessageStore.getState().setMessageState({
|
||||
type: MessageType.Direct,
|
||||
nodeA: directMessageFromOther1.from,
|
||||
@@ -259,24 +287,31 @@ describe('useMessageStore', () => {
|
||||
messageId: directMessageFromOther1.messageId,
|
||||
newState: MessageState.Failed,
|
||||
});
|
||||
const conversationId = getConversationId(directMessageFromOther1.from, directMessageFromOther1.to);
|
||||
const message = useMessageStore.getState().messages.direct.get(conversationId)?.get(directMessageFromOther1.messageId);
|
||||
const conversationId = getConversationId(
|
||||
directMessageFromOther1.from,
|
||||
directMessageFromOther1.to,
|
||||
);
|
||||
const message = useMessageStore.getState().messages.direct.get(
|
||||
conversationId,
|
||||
)?.get(directMessageFromOther1.messageId);
|
||||
expect(message?.state).toBe(MessageState.Failed);
|
||||
});
|
||||
|
||||
it('should update state for a broadcast message', () => {
|
||||
it("should update state for a broadcast message", () => {
|
||||
useMessageStore.getState().setMessageState({
|
||||
type: MessageType.Broadcast,
|
||||
channelId: broadcastChannel,
|
||||
messageId: broadcastMessage1.messageId,
|
||||
newState: MessageState.Ack,
|
||||
});
|
||||
const message = useMessageStore.getState().messages.broadcast.get(broadcastChannel)?.get(broadcastMessage1.messageId);
|
||||
const message = useMessageStore.getState().messages.broadcast.get(
|
||||
broadcastChannel,
|
||||
)?.get(broadcastMessage1.messageId);
|
||||
expect(message?.state).toBe(MessageState.Ack);
|
||||
});
|
||||
|
||||
it('should warn if message is not found (direct)', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
||||
it("should warn if message is not found (direct)", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
useMessageStore.getState().setMessageState({
|
||||
type: MessageType.Direct,
|
||||
nodeA: myNodeNum,
|
||||
@@ -284,24 +319,32 @@ describe('useMessageStore', () => {
|
||||
messageId: 999,
|
||||
newState: MessageState.Ack,
|
||||
});
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Message or conversation/channel not found for state update'));
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"Message or conversation/channel not found for state update",
|
||||
),
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should warn if message is not found (broadcast)', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
||||
it("should warn if message is not found (broadcast)", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
useMessageStore.getState().setMessageState({
|
||||
type: MessageType.Broadcast,
|
||||
channelId: broadcastChannel,
|
||||
messageId: 999,
|
||||
newState: MessageState.Ack,
|
||||
});
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Message or conversation/channel not found for state update'));
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"Message or conversation/channel not found for state update",
|
||||
),
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should warn if conversation/channel is not found', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
||||
it("should warn if conversation/channel is not found", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
useMessageStore.getState().setMessageState({
|
||||
type: MessageType.Direct,
|
||||
nodeA: myNodeNum,
|
||||
@@ -309,22 +352,30 @@ describe('useMessageStore', () => {
|
||||
messageId: 101,
|
||||
newState: MessageState.Ack,
|
||||
});
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Message or conversation/channel not found for state update'));
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"Message or conversation/channel not found for state update",
|
||||
),
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearMessageByMessageId', () => {
|
||||
describe("clearMessageByMessageId", () => {
|
||||
const extraDirectMessageId = 1011;
|
||||
beforeEach(() => {
|
||||
useMessageStore.getState().setNodeNum(myNodeNum);
|
||||
useMessageStore.getState().saveMessage(directMessageToOther1);
|
||||
useMessageStore.getState().saveMessage(directMessageFromOther1);
|
||||
useMessageStore.getState().saveMessage(broadcastMessage1);
|
||||
useMessageStore.getState().saveMessage({ ...directMessageToOther1, messageId: extraDirectMessageId, date: Date.now() + 50 });
|
||||
useMessageStore.getState().saveMessage({
|
||||
...directMessageToOther1,
|
||||
messageId: extraDirectMessageId,
|
||||
date: Date.now() + 50,
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete a specific direct message', () => {
|
||||
it("should delete a specific direct message", () => {
|
||||
const messageIdToDelete = directMessageToOther1.messageId;
|
||||
const nodeA = directMessageToOther1.from;
|
||||
const nodeB = directMessageToOther1.to;
|
||||
@@ -334,19 +385,20 @@ describe('useMessageStore', () => {
|
||||
type: MessageType.Direct,
|
||||
nodeA: nodeA,
|
||||
nodeB: nodeB,
|
||||
messageId: messageIdToDelete
|
||||
messageId: messageIdToDelete,
|
||||
});
|
||||
|
||||
const state = useMessageStore.getState();
|
||||
const conversationLog = state.messages.direct.get(conversationId);
|
||||
expect(conversationLog?.has(messageIdToDelete)).toBe(false);
|
||||
expect(conversationLog?.has(extraDirectMessageId)).toBe(true);
|
||||
expect(conversationLog?.has(directMessageFromOther1.messageId)).toBe(true);
|
||||
expect(conversationLog?.has(directMessageFromOther1.messageId)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(state.messages.direct.has(conversationId)).toBe(true);
|
||||
|
||||
});
|
||||
|
||||
it('should delete another specific direct message', () => {
|
||||
it("should delete another specific direct message", () => {
|
||||
const messageIdToDelete = directMessageFromOther1.messageId;
|
||||
const nodeA = directMessageFromOther1.from;
|
||||
const nodeB = directMessageFromOther1.to;
|
||||
@@ -356,7 +408,7 @@ describe('useMessageStore', () => {
|
||||
type: MessageType.Direct,
|
||||
nodeA: nodeA,
|
||||
nodeB: nodeB,
|
||||
messageId: messageIdToDelete
|
||||
messageId: messageIdToDelete,
|
||||
});
|
||||
|
||||
const state = useMessageStore.getState();
|
||||
@@ -366,38 +418,63 @@ describe('useMessageStore', () => {
|
||||
expect(conversationLog?.has(extraDirectMessageId)).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
it('should delete a specific broadcast message', () => {
|
||||
it("should delete a specific broadcast message", () => {
|
||||
const messageIdToDelete = broadcastMessage1.messageId;
|
||||
const channelId = broadcastMessage1.channel;
|
||||
|
||||
useMessageStore.getState().clearMessageByMessageId({
|
||||
type: MessageType.Broadcast,
|
||||
channelId: channelId,
|
||||
messageId: messageIdToDelete
|
||||
messageId: messageIdToDelete,
|
||||
});
|
||||
|
||||
const state = useMessageStore.getState();
|
||||
expect(state.messages.broadcast.get(channelId)?.get(messageIdToDelete)).toBeUndefined();
|
||||
expect(state.messages.broadcast.get(channelId)?.get(messageIdToDelete))
|
||||
.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should clean up empty conversation/channel Maps', () => {
|
||||
const directConvId = getConversationId(directMessageFromOther1.from, directMessageFromOther1.to);
|
||||
it("should clean up empty conversation/channel Maps", () => {
|
||||
const directConvId = getConversationId(
|
||||
directMessageFromOther1.from,
|
||||
directMessageFromOther1.to,
|
||||
);
|
||||
const broadcastChanId = broadcastMessage1.channel;
|
||||
|
||||
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, nodeA: directMessageToOther1.from, nodeB: directMessageToOther1.to, messageId: directMessageToOther1.messageId });
|
||||
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, nodeA: directMessageFromOther1.from, nodeB: directMessageFromOther1.to, messageId: directMessageFromOther1.messageId });
|
||||
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, nodeA: directMessageToOther1.from, nodeB: directMessageToOther1.to, messageId: extraDirectMessageId });
|
||||
useMessageStore.getState().clearMessageByMessageId({
|
||||
type: MessageType.Direct,
|
||||
nodeA: directMessageToOther1.from,
|
||||
nodeB: directMessageToOther1.to,
|
||||
messageId: directMessageToOther1.messageId,
|
||||
});
|
||||
useMessageStore.getState().clearMessageByMessageId({
|
||||
type: MessageType.Direct,
|
||||
nodeA: directMessageFromOther1.from,
|
||||
nodeB: directMessageFromOther1.to,
|
||||
messageId: directMessageFromOther1.messageId,
|
||||
});
|
||||
useMessageStore.getState().clearMessageByMessageId({
|
||||
type: MessageType.Direct,
|
||||
nodeA: directMessageToOther1.from,
|
||||
nodeB: directMessageToOther1.to,
|
||||
messageId: extraDirectMessageId,
|
||||
});
|
||||
|
||||
expect(useMessageStore.getState().messages.direct.has(directConvId)).toBe(false);
|
||||
expect(useMessageStore.getState().messages.direct.has(directConvId)).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Broadcast, channelId: broadcastChanId, messageId: broadcastMessage1.messageId });
|
||||
useMessageStore.getState().clearMessageByMessageId({
|
||||
type: MessageType.Broadcast,
|
||||
channelId: broadcastChanId,
|
||||
messageId: broadcastMessage1.messageId,
|
||||
});
|
||||
|
||||
expect(useMessageStore.getState().messages.broadcast.has(broadcastChanId)).toBe(false);
|
||||
expect(useMessageStore.getState().messages.broadcast.has(broadcastChanId))
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
it('should not error when trying to delete non-existent message', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
||||
it("should not error when trying to delete non-existent message", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const conversationId = getConversationId(myNodeNum, otherNodeNum1);
|
||||
|
||||
expect(() => {
|
||||
@@ -405,30 +482,34 @@ describe('useMessageStore', () => {
|
||||
type: MessageType.Direct,
|
||||
nodeA: myNodeNum,
|
||||
nodeB: otherNodeNum1,
|
||||
messageId: 9999
|
||||
messageId: 9999,
|
||||
});
|
||||
}).not.toThrow();
|
||||
|
||||
const state = useMessageStore.getState();
|
||||
const conversationLog = state.messages.direct.get(conversationId);
|
||||
expect(conversationLog?.size).toBe(3); // 101, 102, 1011
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('not found in direct chat'));
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("not found in direct chat"),
|
||||
);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not error when trying to delete from non-existent conversation/channel', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
||||
it("should not error when trying to delete from non-existent conversation/channel", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
expect(() => {
|
||||
useMessageStore.getState().clearMessageByMessageId({
|
||||
type: MessageType.Direct,
|
||||
nodeA: myNodeNum,
|
||||
nodeB: 9998,
|
||||
messageId: 101
|
||||
messageId: 101,
|
||||
});
|
||||
}).not.toThrow();
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Message entry"));
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Message entry"),
|
||||
);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -436,43 +517,54 @@ describe('useMessageStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Drafts', () => {
|
||||
describe("Drafts", () => {
|
||||
const draftKeyDirect = otherNodeNum1;
|
||||
const draftKeyBroadcast = broadcastChannel;
|
||||
const draftMessage = 'This is a draft';
|
||||
const draftMessage = "This is a draft";
|
||||
|
||||
it('should set and get a draft for direct chat', () => {
|
||||
it("should set and get a draft for direct chat", () => {
|
||||
useMessageStore.getState().setDraft(draftKeyDirect, draftMessage);
|
||||
expect(useMessageStore.getState().draft.get(draftKeyDirect)).toBe(draftMessage);
|
||||
expect(useMessageStore.getState().getDraft(draftKeyDirect)).toBe(draftMessage);
|
||||
expect(useMessageStore.getState().draft.get(draftKeyDirect)).toBe(
|
||||
draftMessage,
|
||||
);
|
||||
expect(useMessageStore.getState().getDraft(draftKeyDirect)).toBe(
|
||||
draftMessage,
|
||||
);
|
||||
});
|
||||
|
||||
it('should set and get a draft for broadcast chat', () => {
|
||||
it("should set and get a draft for broadcast chat", () => {
|
||||
useMessageStore.getState().setDraft(draftKeyBroadcast, draftMessage);
|
||||
expect(useMessageStore.getState().draft.get(draftKeyBroadcast)).toBe(draftMessage);
|
||||
expect(useMessageStore.getState().getDraft(draftKeyBroadcast)).toBe(draftMessage);
|
||||
expect(useMessageStore.getState().draft.get(draftKeyBroadcast)).toBe(
|
||||
draftMessage,
|
||||
);
|
||||
expect(useMessageStore.getState().getDraft(draftKeyBroadcast)).toBe(
|
||||
draftMessage,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty string for non-existent draft', () => {
|
||||
expect(useMessageStore.getState().getDraft(999)).toBe('');
|
||||
it("should return empty string for non-existent draft", () => {
|
||||
expect(useMessageStore.getState().getDraft(999)).toBe("");
|
||||
});
|
||||
|
||||
it('should clear a draft', () => {
|
||||
it("should clear a draft", () => {
|
||||
useMessageStore.getState().setDraft(draftKeyDirect, draftMessage);
|
||||
expect(useMessageStore.getState().draft.has(draftKeyDirect)).toBe(true);
|
||||
useMessageStore.getState().clearDraft(draftKeyDirect);
|
||||
expect(useMessageStore.getState().draft.has(draftKeyDirect)).toBe(false);
|
||||
expect(useMessageStore.getState().getDraft(draftKeyDirect)).toBe('');
|
||||
expect(useMessageStore.getState().getDraft(draftKeyDirect)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteAllMessages', () => {
|
||||
it('should clear all direct and broadcast messages, leaving empty Maps', () => {
|
||||
describe("deleteAllMessages", () => {
|
||||
it("should clear all direct and broadcast messages, leaving empty Maps", () => {
|
||||
useMessageStore.getState().saveMessage(directMessageToOther1);
|
||||
useMessageStore.getState().saveMessage(broadcastMessage1);
|
||||
|
||||
expect(useMessageStore.getState().messages.direct.size).toBeGreaterThan(0);
|
||||
expect(useMessageStore.getState().messages.broadcast.size).toBeGreaterThan(0);
|
||||
expect(useMessageStore.getState().messages.direct.size).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
expect(useMessageStore.getState().messages.broadcast.size)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
useMessageStore.getState().deleteAllMessages();
|
||||
|
||||
@@ -483,4 +575,4 @@ describe('useMessageStore', () => {
|
||||
expect(state.messages.broadcast.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,14 +21,14 @@ interface GenericMessage<T extends MessageType> extends MessageBase {
|
||||
type: T;
|
||||
}
|
||||
|
||||
type Message = GenericMessage<MessageType.Direct> | GenericMessage<MessageType.Broadcast>;
|
||||
|
||||
type Message =
|
||||
| GenericMessage<MessageType.Direct>
|
||||
| GenericMessage<MessageType.Broadcast>;
|
||||
|
||||
type GetMessagesParams =
|
||||
| { type: MessageType.Direct; nodeA: NodeNum; nodeB: NodeNum }
|
||||
| { type: MessageType.Broadcast; channelId: ChannelId };
|
||||
|
||||
|
||||
type SetMessageStateParams =
|
||||
| {
|
||||
type: MessageType.Direct;
|
||||
@@ -58,13 +58,13 @@ type ClearMessageParams =
|
||||
};
|
||||
|
||||
export type {
|
||||
Message,
|
||||
ConversationId,
|
||||
NodeNum,
|
||||
MessageLogMap,
|
||||
ChannelId,
|
||||
MessageId,
|
||||
GetMessagesParams,
|
||||
SetMessageStateParams,
|
||||
ClearMessageParams,
|
||||
}
|
||||
ConversationId,
|
||||
GetMessagesParams,
|
||||
Message,
|
||||
MessageId,
|
||||
MessageLogMap,
|
||||
NodeNum,
|
||||
SetMessageStateParams,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { createContext, useState, useContext, useMemo } from 'react';
|
||||
import React, { createContext, useContext, useMemo, useState } from "react";
|
||||
|
||||
interface SidebarContextProps {
|
||||
isCollapsed: boolean;
|
||||
@@ -6,13 +6,17 @@ interface SidebarContextProps {
|
||||
toggleSidebar: () => void;
|
||||
}
|
||||
|
||||
const SidebarContext = createContext<SidebarContextProps | undefined>(undefined);
|
||||
const SidebarContext = createContext<SidebarContextProps | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export const SidebarProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
export const SidebarProvider: React.FC<{ children: React.ReactNode }> = (
|
||||
{ children },
|
||||
) => {
|
||||
const [isCollapsed, setIsCollapsed] = useState<boolean>(false);
|
||||
|
||||
const toggleSidebar = useMemo(() => () => {
|
||||
setIsCollapsed(prev => !prev);
|
||||
setIsCollapsed((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({
|
||||
@@ -22,7 +26,7 @@ export const SidebarProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
||||
}), [isCollapsed, toggleSidebar]);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={value} >
|
||||
<SidebarContext.Provider value={value}>
|
||||
{children}
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
@@ -31,7 +35,7 @@ export const SidebarProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
||||
export const useSidebar = (): SidebarContextProps => {
|
||||
const context = useContext(SidebarContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useSidebar must be used within a SidebarProvider');
|
||||
throw new Error("useSidebar must be used within a SidebarProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PersistStorage, StateStorage, } from "zustand/middleware"; // Added StorageValue for clarity, though not strictly needed in the final signature here
|
||||
import { get, set, del } from "idb-keyval";
|
||||
import { PersistStorage, StateStorage } from "zustand/middleware";
|
||||
import { del, get, set } from "idb-keyval";
|
||||
import { ChannelId, MessageLogMap } from "@core/stores/messageStore/types.ts";
|
||||
|
||||
type PersistedMessageState = {
|
||||
@@ -22,30 +22,31 @@ export const zustandIndexDBStorage: StateStorage = {
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
type SerializedMap<K = unknown, V = unknown> = {
|
||||
__dataType: 'Map';
|
||||
__dataType: "Map";
|
||||
value: Array<[K, V]>;
|
||||
};
|
||||
// deno-lint-ignore no-explicit-any
|
||||
type JsonReplacer = (this: any, key: string, value: unknown) => unknown;
|
||||
const replacer: JsonReplacer = (_, value) => {
|
||||
if (value instanceof Map) {
|
||||
const map = value as Map<unknown, unknown>;
|
||||
const serialized: SerializedMap = {
|
||||
__dataType: 'Map',
|
||||
__dataType: "Map",
|
||||
value: Array.from(map.entries()),
|
||||
};
|
||||
return serialized;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
// deno-lint-ignore no-explicit-any
|
||||
type JsonReviver = (this: any, key: string, value: unknown) => unknown;
|
||||
function isSerializedMap(value: unknown): value is SerializedMap {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
const potentialMap = value as Partial<SerializedMap>;
|
||||
return potentialMap.__dataType === 'Map' && Array.isArray(potentialMap.value);
|
||||
return potentialMap.__dataType === "Map" && Array.isArray(potentialMap.value);
|
||||
}
|
||||
const reviver: JsonReviver = (_, value) => {
|
||||
if (isSerializedMap(value)) {
|
||||
@@ -54,11 +55,10 @@ const reviver: JsonReviver = (_, value) => {
|
||||
return value;
|
||||
};
|
||||
|
||||
|
||||
export const storageWithMapSupport: PersistStorage<PersistedMessageState> = {
|
||||
getItem: async (name): Promise<PersistedMessageState | null> => {
|
||||
const str = await zustandIndexDBStorage.getItem(name);
|
||||
if (!str) { return null; }
|
||||
if (!str) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(str, reviver) as PersistedMessageState;
|
||||
return parsed;
|
||||
@@ -72,10 +72,13 @@ export const storageWithMapSupport: PersistStorage<PersistedMessageState> = {
|
||||
const str = JSON.stringify(newValue, replacer);
|
||||
await zustandIndexDBStorage.setItem(name, str);
|
||||
} catch (error) {
|
||||
console.error(`Error stringifying or setting persisted state (${name}):`, error);
|
||||
console.error(
|
||||
`Error stringifying or setting persisted state (${name}):`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
},
|
||||
removeItem: async (name): Promise<void> => {
|
||||
await zustandIndexDBStorage.removeItem(name);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { Device } from "@core/stores/deviceStore.ts";
|
||||
import { MeshDevice, Protobuf } from "@meshtastic/core";
|
||||
import { MessageState, MessageType, type MessageStore } from "./stores/messageStore/index.ts";
|
||||
import { type MessageStore, MessageType } from "./stores/messageStore/index.ts";
|
||||
import PacketToMessageDTO from "@core/dto/PacketToMessageDTO.ts";
|
||||
import NodeInfoFactory from "@core/dto/NodeNumToNodeInfoDTO.ts";
|
||||
|
||||
export const subscribeAll = (
|
||||
device: Device,
|
||||
connection: MeshDevice,
|
||||
messageStore: MessageStore
|
||||
messageStore: MessageStore,
|
||||
) => {
|
||||
let myNodeNum = 0;
|
||||
|
||||
@@ -79,7 +79,6 @@ export const subscribeAll = (
|
||||
device.setModuleConfig(moduleConfig);
|
||||
});
|
||||
|
||||
|
||||
connection.events.onMessagePacket.subscribe((messagePacket) => {
|
||||
// incoming and outgoing messages are handled by this event listener
|
||||
console.log("Message Packet", messagePacket);
|
||||
@@ -117,7 +116,6 @@ export const subscribeAll = (
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
connection.events.onRoutingPacket.subscribe((routingPacket) => {
|
||||
if (routingPacket.data.variant.case === "errorReason") {
|
||||
switch (routingPacket.data.variant.value) {
|
||||
@@ -126,19 +124,24 @@ export const subscribeAll = (
|
||||
break;
|
||||
case Protobuf.Mesh.Routing_Error.NO_CHANNEL:
|
||||
console.error(`Routing Error: ${routingPacket.data.variant.value}`);
|
||||
device.setNodeError(routingPacket.from, Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value]);
|
||||
device.setNodeError(
|
||||
routingPacket.from,
|
||||
Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value],
|
||||
);
|
||||
device.setDialogOpen("refreshKeys", true);
|
||||
break;
|
||||
case Protobuf.Mesh.Routing_Error.PKI_UNKNOWN_PUBKEY:
|
||||
console.error(`Routing Error: ${routingPacket.data.variant.value}`);
|
||||
device.setNodeError(routingPacket.from, Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value]);
|
||||
device.setNodeError(
|
||||
routingPacket.from,
|
||||
Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value],
|
||||
);
|
||||
device.setDialogOpen("refreshKeys", true);
|
||||
break;
|
||||
default: {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { eventBus } from "@core/utils/eventBus.ts";
|
||||
|
||||
describe("EventBus", () => {
|
||||
beforeEach(() => {
|
||||
// Reset event listeners before each test
|
||||
(eventBus as any).listeners = {};
|
||||
eventBus.listeners = {};
|
||||
});
|
||||
|
||||
it("should register an event listener and trigger it on emit", () => {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
export type EventMap = {
|
||||
'dialog:unsafeRoles': {
|
||||
action: 'confirm' | 'dismiss';
|
||||
"dialog:unsafeRoles": {
|
||||
action: "confirm" | "dismiss";
|
||||
};
|
||||
// add more events as required
|
||||
};
|
||||
|
||||
export type EventName = keyof EventMap;
|
||||
@@ -11,12 +10,15 @@ export type EventCallback<T extends EventName> = (data: EventMap[T]) => void;
|
||||
class EventBus {
|
||||
private listeners: { [K in EventName]?: Array<EventCallback<K>> } = {};
|
||||
|
||||
public on<T extends EventName>(event: T, callback: EventCallback<T>): () => void {
|
||||
public on<T extends EventName>(
|
||||
event: T,
|
||||
callback: EventCallback<T>,
|
||||
): () => void {
|
||||
if (!this.listeners[event]) {
|
||||
this.listeners[event] = [];
|
||||
}
|
||||
|
||||
this.listeners[event]?.push(callback as any);
|
||||
this.listeners[event]?.push(callback);
|
||||
|
||||
return () => {
|
||||
this.off(event, callback);
|
||||
@@ -26,7 +28,7 @@ class EventBus {
|
||||
public off<T extends EventName>(event: T, callback: EventCallback<T>): void {
|
||||
if (!this.listeners[event]) return;
|
||||
|
||||
const callbackIndex = this.listeners[event]?.indexOf(callback as any);
|
||||
const callbackIndex = this.listeners[event]?.indexOf(callback);
|
||||
if (callbackIndex !== undefined && callbackIndex > -1) {
|
||||
this.listeners[event]?.splice(callbackIndex, 1);
|
||||
}
|
||||
@@ -35,10 +37,10 @@ class EventBus {
|
||||
public emit<T extends EventName>(event: T, data: EventMap[T]): void {
|
||||
if (!this.listeners[event]) return;
|
||||
|
||||
this.listeners[event]?.forEach(callback => {
|
||||
this.listeners[event]?.forEach((callback) => {
|
||||
callback(data);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const eventBus = new EventBus();
|
||||
export const eventBus = new EventBus();
|
||||
|
||||
@@ -63,7 +63,6 @@ export default function newGithubIssueUrl(
|
||||
|
||||
function validateOptions(options: GithubIssueUrlOptions): ValidatedOptions {
|
||||
const repoUrl = options.repoUrl ??
|
||||
|
||||
(options.user && options.repo
|
||||
? `https://github.com/${options.user}/${options.repo}`
|
||||
: undefined);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export function convertIntToIpAddress(int: number): string {
|
||||
return `${int & 0xff}.${(int >> 8) & 0xff}.${(int >> 16) & 0xff}.${(int >> 24) & 0xff
|
||||
}`;
|
||||
return `${int & 0xff}.${(int >> 8) & 0xff}.${(int >> 16) & 0xff}.${
|
||||
(int >> 24) & 0xff
|
||||
}`;
|
||||
}
|
||||
|
||||
export function convertIpAddressToInt(ip: string): number | undefined {
|
||||
|
||||
@@ -35,10 +35,18 @@ export interface LengthValidationResult {
|
||||
currentLength: number | null;
|
||||
}
|
||||
|
||||
export function validateMaxByteLength(value: string | null | undefined, maxByteLength: number): LengthValidationResult {
|
||||
export function validateMaxByteLength(
|
||||
value: string | null | undefined,
|
||||
maxByteLength: number,
|
||||
): LengthValidationResult {
|
||||
// Ensure maxByteLength is valid
|
||||
if (typeof maxByteLength !== 'number' || !Number.isInteger(maxByteLength) || maxByteLength < 0) {
|
||||
console.warn('validateMaxByteLength: maxByteLength must be a non-negative integer.');
|
||||
if (
|
||||
typeof maxByteLength !== "number" || !Number.isInteger(maxByteLength) ||
|
||||
maxByteLength < 0
|
||||
) {
|
||||
console.warn(
|
||||
"validateMaxByteLength: maxByteLength must be a non-negative integer.",
|
||||
);
|
||||
return { isValid: false, currentLength: null }; // Cannot validate with invalid limit
|
||||
}
|
||||
|
||||
@@ -48,8 +56,10 @@ export function validateMaxByteLength(value: string | null | undefined, maxByteL
|
||||
}
|
||||
|
||||
// Check for TextEncoder availability
|
||||
if (typeof TextEncoder === 'undefined') {
|
||||
console.error('validateMaxByteLength: TextEncoder API is not available in this environment.');
|
||||
if (typeof TextEncoder === "undefined") {
|
||||
console.error(
|
||||
"validateMaxByteLength: TextEncoder API is not available in this environment.",
|
||||
);
|
||||
return { isValid: false, currentLength: null }; // Cannot determine byte length
|
||||
}
|
||||
|
||||
@@ -64,8 +74,7 @@ export function validateMaxByteLength(value: string | null | undefined, maxByteL
|
||||
return { isValid, currentLength };
|
||||
} catch (error) {
|
||||
// Handle potential errors during encoding
|
||||
console.error('validateMaxByteLength: Error encoding string:', error);
|
||||
console.error("validateMaxByteLength: Error encoding string:", error);
|
||||
return { isValid: false, currentLength: null }; // Encoding failed
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-3
@@ -89,7 +89,6 @@ body {
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
@@ -127,7 +126,6 @@ img {
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
|
||||
@keyframes spin-slower {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
@@ -136,4 +134,4 @@ img {
|
||||
|
||||
.animate-spin-slow {
|
||||
animation: spin-slower 2s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@ export const getChannelName = (channel: Protobuf.Channel.Channel) =>
|
||||
channel.settings?.name.length
|
||||
? channel.settings?.name
|
||||
: channel.index === 0
|
||||
? "Primary"
|
||||
: `Ch ${channel.index}`;
|
||||
? "Primary"
|
||||
: `Ch ${channel.index}`;
|
||||
|
||||
const ChannelsPage = () => {
|
||||
const { channels, setDialogOpen } = useDevice();
|
||||
@@ -33,8 +33,9 @@ const ChannelsPage = () => {
|
||||
<>
|
||||
<PageLayout
|
||||
leftBar={<Sidebar />}
|
||||
label={`Channel: ${currentChannel ? getChannelName(currentChannel) : "Loading..."
|
||||
}`}
|
||||
label={`Channel: ${
|
||||
currentChannel ? getChannelName(currentChannel) : "Loading..."
|
||||
}`}
|
||||
actions={[
|
||||
{
|
||||
key: "search",
|
||||
|
||||
+68
-61
@@ -1,74 +1,81 @@
|
||||
import { describe, it, vi, expect } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MessagesPage } from "./Messages.tsx";
|
||||
import { useDevice } from "../core/stores/deviceStore";
|
||||
import { useDevice } from "../core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
|
||||
vi.mock("../core/stores/deviceStore", () => ({
|
||||
useDevice: vi.fn()
|
||||
useDevice: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockUseDevice = {
|
||||
channels: new Map([
|
||||
[0, {
|
||||
index: 0,
|
||||
settings: { name: "Primary" },
|
||||
role: Protobuf.Channel.Channel_Role.PRIMARY
|
||||
}]
|
||||
]),
|
||||
nodes: new Map([
|
||||
[0, {
|
||||
num: 0,
|
||||
user: { longName: "Test Node 0", shortName: "TN0", publicKey: "0000" }
|
||||
}],
|
||||
[1111, {
|
||||
num: 1111,
|
||||
user: { longName: "Test Node 1", shortName: "TN1", publicKey: "12345" }
|
||||
}],
|
||||
[2222, {
|
||||
num: 2222,
|
||||
user: { longName: "Test Node 2", shortName: "TN2", publicKey: "67890" }
|
||||
}],
|
||||
[3333, {
|
||||
num: 3333,
|
||||
user: { longName: "Test Node 3", shortName: "TN3", publicKey: "11111" }
|
||||
}]
|
||||
]),
|
||||
hardware: { myNodeNum: 1 },
|
||||
messages: { broadcast: new Map(), direct: new Map() },
|
||||
metadata: new Map(),
|
||||
unreadCounts: new Map([[1111, 3], [2222, 10]]),
|
||||
resetUnread: vi.fn(),
|
||||
hasNodeError: vi.fn()
|
||||
channels: new Map([
|
||||
[0, {
|
||||
index: 0,
|
||||
settings: { name: "Primary" },
|
||||
role: Protobuf.Channel.Channel_Role.PRIMARY,
|
||||
}],
|
||||
]),
|
||||
nodes: new Map([
|
||||
[0, {
|
||||
num: 0,
|
||||
user: { longName: "Test Node 0", shortName: "TN0", publicKey: "0000" },
|
||||
}],
|
||||
[1111, {
|
||||
num: 1111,
|
||||
user: { longName: "Test Node 1", shortName: "TN1", publicKey: "12345" },
|
||||
}],
|
||||
[2222, {
|
||||
num: 2222,
|
||||
user: { longName: "Test Node 2", shortName: "TN2", publicKey: "67890" },
|
||||
}],
|
||||
[3333, {
|
||||
num: 3333,
|
||||
user: { longName: "Test Node 3", shortName: "TN3", publicKey: "11111" },
|
||||
}],
|
||||
]),
|
||||
hardware: { myNodeNum: 1 },
|
||||
messages: { broadcast: new Map(), direct: new Map() },
|
||||
metadata: new Map(),
|
||||
unreadCounts: new Map([[1111, 3], [2222, 10]]),
|
||||
resetUnread: vi.fn(),
|
||||
hasNodeError: vi.fn(),
|
||||
};
|
||||
|
||||
|
||||
describe.skip("Messages Page", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useDevice).mockReturnValue(mockUseDevice);
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.mocked(useDevice).mockReturnValue(mockUseDevice);
|
||||
});
|
||||
|
||||
it("sorts unreads to the top", () => {
|
||||
render(<MessagesPage />);
|
||||
const buttonOrder = screen.getAllByRole("button").filter(b => b.textContent.includes("Test Node"));
|
||||
expect(buttonOrder[0].textContent).toContain("TN2Test Node 210");
|
||||
expect(buttonOrder[1].textContent).toContain("TN1Test Node 13");
|
||||
expect(buttonOrder[2].textContent).toContain("TN0Test Node 0");
|
||||
expect(buttonOrder[3].textContent).toContain("TN3Test Node 3");
|
||||
});
|
||||
it("sorts unreads to the top", () => {
|
||||
render(<MessagesPage />);
|
||||
const buttonOrder = screen.getAllByRole("button").filter((b) =>
|
||||
b.textContent.includes("Test Node")
|
||||
);
|
||||
expect(buttonOrder[0].textContent).toContain("TN2Test Node 210");
|
||||
expect(buttonOrder[1].textContent).toContain("TN1Test Node 13");
|
||||
expect(buttonOrder[2].textContent).toContain("TN0Test Node 0");
|
||||
expect(buttonOrder[3].textContent).toContain("TN3Test Node 3");
|
||||
});
|
||||
|
||||
it("updates unread when active chat changes", () => {
|
||||
render(<MessagesPage />);
|
||||
const nodeButton = screen.getAllByRole("button").filter(b => b.textContent.includes("TN1Test Node 13"))[0];
|
||||
fireEvent.click(nodeButton);
|
||||
expect(mockUseDevice.resetUnread).toHaveBeenCalledWith(1111, 0);
|
||||
});
|
||||
it("updates unread when active chat changes", () => {
|
||||
render(<MessagesPage />);
|
||||
const nodeButton =
|
||||
screen.getAllByRole("button").filter((b) =>
|
||||
b.textContent.includes("TN1Test Node 13")
|
||||
)[0];
|
||||
fireEvent.click(nodeButton);
|
||||
expect(mockUseDevice.resetUnread).toHaveBeenCalledWith(1111, 0);
|
||||
});
|
||||
|
||||
it("does not update the incorrect node", async () => {
|
||||
render(<MessagesPage />);
|
||||
const nodeButton = screen.getAllByRole("button").filter(b => b.textContent.includes("TN1Test Node 1"))[0];
|
||||
fireEvent.click(nodeButton);
|
||||
expect(mockUseDevice.resetUnread).toHaveBeenCalledWith(1111, 0);
|
||||
expect(mockUseDevice.unreadCounts.get(2222)).toBe(10);
|
||||
});
|
||||
});
|
||||
it("does not update the incorrect node", () => {
|
||||
render(<MessagesPage />);
|
||||
const nodeButton =
|
||||
screen.getAllByRole("button").filter((b) =>
|
||||
b.textContent.includes("TN1Test Node 1")
|
||||
)[0];
|
||||
fireEvent.click(nodeButton);
|
||||
expect(mockUseDevice.resetUnread).toHaveBeenCalledWith(1111, 0);
|
||||
expect(mockUseDevice.unreadCounts.get(2222)).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
+165
-75
@@ -12,34 +12,55 @@ import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react";
|
||||
import { useCallback, useDeferredValue, useMemo, useState } from "react";
|
||||
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { MessageState, MessageType, useMessageStore } from "@core/stores/messageStore/index.ts";
|
||||
import {
|
||||
MessageState,
|
||||
MessageType,
|
||||
useMessageStore,
|
||||
} from "@core/stores/messageStore/index.ts";
|
||||
import { useSidebar } from "@core/stores/sidebarStore.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
|
||||
type NodeInfoWithUnread = Protobuf.Mesh.NodeInfo & { unreadCount: number };
|
||||
|
||||
export const MessagesPage = () => {
|
||||
const { channels, getNodes, getNode, hasNodeError, unreadCounts, resetUnread, connection } = useDevice();
|
||||
const { getMyNodeNum, getMessages, setActiveChat, chatType, activeChat, setChatType, setMessageState, } = useMessageStore()
|
||||
const {
|
||||
channels,
|
||||
getNodes,
|
||||
getNode,
|
||||
hasNodeError,
|
||||
unreadCounts,
|
||||
resetUnread,
|
||||
connection,
|
||||
} = useDevice();
|
||||
const {
|
||||
getMyNodeNum,
|
||||
getMessages,
|
||||
setActiveChat,
|
||||
chatType,
|
||||
activeChat,
|
||||
setChatType,
|
||||
setMessageState,
|
||||
} = useMessageStore();
|
||||
const { toast } = useToast();
|
||||
const { isCollapsed } = useSidebar()
|
||||
const { isCollapsed } = useSidebar();
|
||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||
const deferredSearch = useDeferredValue(searchTerm);
|
||||
|
||||
const filteredNodes = (): NodeInfoWithUnread[] => {
|
||||
const lowerCaseSearchTerm = deferredSearch.toLowerCase();
|
||||
|
||||
return getNodes(node => {
|
||||
const longName = node.user?.longName?.toLowerCase() ?? '';
|
||||
const shortName = node.user?.shortName?.toLowerCase() ?? '';
|
||||
return longName.includes(lowerCaseSearchTerm) || shortName.includes(lowerCaseSearchTerm)
|
||||
return getNodes((node) => {
|
||||
const longName = node.user?.longName?.toLowerCase() ?? "";
|
||||
const shortName = node.user?.shortName?.toLowerCase() ?? "";
|
||||
return longName.includes(lowerCaseSearchTerm) ||
|
||||
shortName.includes(lowerCaseSearchTerm);
|
||||
})
|
||||
.map((node) => ({
|
||||
...node,
|
||||
unreadCount: unreadCounts.get(node.num) ?? 0,
|
||||
}))
|
||||
.sort((a, b) => b.unreadCount - a.unreadCount);
|
||||
}
|
||||
};
|
||||
|
||||
const allChannels = Array.from(channels.values());
|
||||
const filteredChannels = allChannels.filter(
|
||||
@@ -55,19 +76,35 @@ export const MessagesPage = () => {
|
||||
const isDirect = chatType === MessageType.Direct;
|
||||
const toValue = isDirect ? activeChat : MessageType.Broadcast;
|
||||
|
||||
const channelValue = isDirect ? Types.ChannelNumber.Primary : activeChat ?? 0;
|
||||
|
||||
console.log(`Sending message: "${message}" to: ${toValue}, channel: ${channelValue}, type: ${chatType}`);
|
||||
const channelValue = isDirect
|
||||
? Types.ChannelNumber.Primary
|
||||
: activeChat ?? 0;
|
||||
|
||||
let messageId: number | undefined;
|
||||
|
||||
try {
|
||||
messageId = await connection?.sendText(message, toValue, true, channelValue);
|
||||
messageId = await connection?.sendText(
|
||||
message,
|
||||
toValue,
|
||||
true,
|
||||
channelValue,
|
||||
);
|
||||
if (messageId !== undefined) {
|
||||
if (chatType === MessageType.Broadcast) {
|
||||
setMessageState({ type: chatType, channelId: channelValue, messageId, newState: MessageState.Ack });
|
||||
setMessageState({
|
||||
type: chatType,
|
||||
channelId: channelValue,
|
||||
messageId,
|
||||
newState: MessageState.Ack,
|
||||
});
|
||||
} else {
|
||||
setMessageState({ type: chatType, nodeA: getMyNodeNum(), nodeB: activeChat, messageId, newState: MessageState.Ack });
|
||||
setMessageState({
|
||||
type: chatType,
|
||||
nodeA: getMyNodeNum(),
|
||||
nodeB: activeChat,
|
||||
messageId,
|
||||
newState: MessageState.Ack,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.warn("sendText completed but messageId is undefined");
|
||||
@@ -78,10 +115,21 @@ export const MessagesPage = () => {
|
||||
// Note: messageId might be undefined here if the error occurred before it was assigned
|
||||
if (chatType === MessageType.Broadcast) {
|
||||
const failedId = messageId ?? `failed-${Date.now()}`;
|
||||
setMessageState({ type: chatType, channelId: channelValue, messageId: failedId, newState: MessageState.Failed });
|
||||
setMessageState({
|
||||
type: chatType,
|
||||
channelId: channelValue,
|
||||
messageId: failedId,
|
||||
newState: MessageState.Failed,
|
||||
});
|
||||
} else { // MessageType.Direct
|
||||
const failedId = messageId ?? `failed-${Date.now()}`;
|
||||
setMessageState({ type: chatType, nodeA: getMyNodeNum(), nodeB: activeChat, messageId: failedId, newState: MessageState.Failed });
|
||||
setMessageState({
|
||||
type: chatType,
|
||||
nodeA: getMyNodeNum(),
|
||||
nodeB: activeChat,
|
||||
messageId: failedId,
|
||||
newState: MessageState.Failed,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [activeChat, chatType, connection, getMyNodeNum, setMessageState]);
|
||||
@@ -100,7 +148,11 @@ export const MessagesPage = () => {
|
||||
case MessageType.Direct:
|
||||
return (
|
||||
<ChannelChat
|
||||
messages={getMessages({ type: MessageType.Direct, nodeA: getMyNodeNum(), nodeB: activeChat })}
|
||||
messages={getMessages({
|
||||
type: MessageType.Direct,
|
||||
nodeA: getMyNodeNum(),
|
||||
nodeB: activeChat,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
@@ -110,7 +162,7 @@ export const MessagesPage = () => {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const leftSidebar = useMemo(() => (
|
||||
<Sidebar>
|
||||
@@ -119,72 +171,106 @@ export const MessagesPage = () => {
|
||||
<SidebarButton
|
||||
key={channel.index}
|
||||
count={unreadCounts.get(channel.index)}
|
||||
label={channel.settings?.name || (channel.index === 0 ? "Primary" : `Ch ${channel.index}`)}
|
||||
active={activeChat === channel.index && chatType === MessageType.Broadcast}
|
||||
label={channel.settings?.name ||
|
||||
(channel.index === 0 ? "Primary" : `Ch ${channel.index}`)}
|
||||
active={activeChat === channel.index &&
|
||||
chatType === MessageType.Broadcast}
|
||||
onClick={() => {
|
||||
setChatType(MessageType.Broadcast);
|
||||
setActiveChat(channel.index);
|
||||
resetUnread(channel.index);
|
||||
}}
|
||||
>
|
||||
<HashIcon size={16} className={cn(isCollapsed ? "mr-0 mt-2" : "mr-2")} />
|
||||
<HashIcon
|
||||
size={16}
|
||||
className={cn(isCollapsed ? "mr-0 mt-2" : "mr-2")}
|
||||
/>
|
||||
</SidebarButton>
|
||||
))}
|
||||
</SidebarSection>
|
||||
</Sidebar>
|
||||
), [filteredChannels, unreadCounts, activeChat, chatType, isCollapsed, setActiveChat, setChatType, resetUnread]);
|
||||
), [
|
||||
filteredChannels,
|
||||
unreadCounts,
|
||||
activeChat,
|
||||
chatType,
|
||||
isCollapsed,
|
||||
setActiveChat,
|
||||
setChatType,
|
||||
resetUnread,
|
||||
]);
|
||||
|
||||
const rightSidebar = useMemo(() => (
|
||||
<SidebarSection label="" className="px-0 flex flex-col h-full overflow-y-auto">
|
||||
<label className="p-2 block">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search nodes..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
showClearButton={!!searchTerm}
|
||||
/>
|
||||
</label>
|
||||
<div className={cn(
|
||||
"flex flex-col h-full flex-1 overflow-y-auto gap-2.5 pt-1 ",
|
||||
)}>
|
||||
{filteredNodes()?.map((node) => (
|
||||
<SidebarButton
|
||||
key={node.num}
|
||||
preventCollapse={true}
|
||||
label={node.user?.longName ?? `UNK`}
|
||||
count={node.unreadCount > 0 ? node.unreadCount : undefined}
|
||||
active={activeChat === node.num && chatType === MessageType.Direct}
|
||||
onClick={() => {
|
||||
setChatType(MessageType.Direct);
|
||||
setActiveChat(node.num);
|
||||
resetUnread(node.num);
|
||||
}}>
|
||||
<Avatar
|
||||
text={node.user?.shortName ?? "UNK"}
|
||||
className={cn(hasNodeError(node.num) && "text-red-500")}
|
||||
showError={hasNodeError(node.num)}
|
||||
size="sm"
|
||||
/>
|
||||
</SidebarButton>
|
||||
))}
|
||||
</div>
|
||||
</SidebarSection>
|
||||
), [filteredNodes, searchTerm, activeChat, chatType, setActiveChat, setChatType, resetUnread, hasNodeError]);
|
||||
const rightSidebar = useMemo(
|
||||
() => (
|
||||
<SidebarSection
|
||||
label=""
|
||||
className="px-0 flex flex-col h-full overflow-y-auto"
|
||||
>
|
||||
<label className="p-2 block">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search nodes..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
showClearButton={!!searchTerm}
|
||||
/>
|
||||
</label>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col h-full flex-1 overflow-y-auto gap-2.5 pt-1 ",
|
||||
)}
|
||||
>
|
||||
{filteredNodes()?.map((node) => (
|
||||
<SidebarButton
|
||||
key={node.num}
|
||||
preventCollapse
|
||||
label={node.user?.longName ?? `UNK`}
|
||||
count={node.unreadCount > 0 ? node.unreadCount : undefined}
|
||||
active={activeChat === node.num &&
|
||||
chatType === MessageType.Direct}
|
||||
onClick={() => {
|
||||
setChatType(MessageType.Direct);
|
||||
setActiveChat(node.num);
|
||||
resetUnread(node.num);
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
text={node.user?.shortName ?? "UNK"}
|
||||
className={cn(hasNodeError(node.num) && "text-red-500")}
|
||||
showError={hasNodeError(node.num)}
|
||||
size="sm"
|
||||
/>
|
||||
</SidebarButton>
|
||||
))}
|
||||
</div>
|
||||
</SidebarSection>
|
||||
),
|
||||
[
|
||||
filteredNodes,
|
||||
searchTerm,
|
||||
activeChat,
|
||||
chatType,
|
||||
setActiveChat,
|
||||
setChatType,
|
||||
resetUnread,
|
||||
hasNodeError,
|
||||
],
|
||||
);
|
||||
return (
|
||||
<PageLayout
|
||||
label={`Messages: ${isBroadcast && currentChannel
|
||||
? getChannelName(currentChannel)
|
||||
: isDirect && otherNode
|
||||
label={`Messages: ${
|
||||
isBroadcast && currentChannel
|
||||
? getChannelName(currentChannel)
|
||||
: isDirect && otherNode
|
||||
? (otherNode.user?.longName ?? "Unknown")
|
||||
: "Select a Chat"
|
||||
}`}
|
||||
}`}
|
||||
rightBar={rightSidebar}
|
||||
leftBar={leftSidebar}
|
||||
actions={isDirect && otherNode
|
||||
? [
|
||||
{
|
||||
key: 'encryption',
|
||||
key: "encryption",
|
||||
icon: otherNode.user?.publicKey?.length ? LockIcon : LockOpenIcon,
|
||||
iconClasses: otherNode.user?.publicKey?.length
|
||||
? "text-green-600"
|
||||
@@ -204,19 +290,23 @@ export const MessagesPage = () => {
|
||||
{renderChatContent()}
|
||||
|
||||
<div className="flex-none dark:bg-slate-900 p-2">
|
||||
{(isBroadcast || isDirect) ? (
|
||||
<MessageInput
|
||||
to={isDirect ? activeChat : MessageType.Broadcast}
|
||||
onSend={sendText}
|
||||
maxBytes={200}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 text-center text-slate-400 italic">Select a chat to send a message.</div>
|
||||
)}
|
||||
{(isBroadcast || isDirect)
|
||||
? (
|
||||
<MessageInput
|
||||
to={isDirect ? activeChat : MessageType.Broadcast}
|
||||
onSend={sendText}
|
||||
maxBytes={200}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div className="p-4 text-center text-slate-400 italic">
|
||||
Select a chat to send a message.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessagesPage;
|
||||
export default MessagesPage;
|
||||
|
||||
+16
-5
@@ -10,7 +10,13 @@ import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf, type Types } from "@meshtastic/core";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { LockIcon, LockOpenIcon } from "lucide-react";
|
||||
import { type JSX, useCallback, useDeferredValue, useEffect, useState } from "react";
|
||||
import {
|
||||
type JSX,
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { base16 } from "rfc4648";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import { PageLayout } from "@components/PageLayout.tsx";
|
||||
@@ -34,14 +40,14 @@ const NodesPage = (): JSX.Element => {
|
||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||
const deferredSearch = useDeferredValue(searchTerm);
|
||||
|
||||
const filteredNodes = getNodes(node => {
|
||||
const filteredNodes = getNodes((node) => {
|
||||
if (!node.user) return false;
|
||||
const lowerCaseSearchTerm = deferredSearch.toLowerCase();
|
||||
return (
|
||||
node.user?.longName?.toLowerCase().includes(lowerCaseSearchTerm) ||
|
||||
node.user?.shortName?.toLowerCase().includes(lowerCaseSearchTerm)
|
||||
);
|
||||
})
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!connection) return;
|
||||
@@ -76,7 +82,11 @@ const NodesPage = (): JSX.Element => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageLayout label="" leftBar={<Sidebar />} className="flex flex-col w-full">
|
||||
<PageLayout
|
||||
label=""
|
||||
leftBar={<Sidebar />}
|
||||
className="flex flex-col w-full"
|
||||
>
|
||||
<div className="p-2">
|
||||
<Input
|
||||
placeholder="Search nodes..."
|
||||
@@ -118,7 +128,8 @@ const NodesPage = (): JSX.Element => {
|
||||
{node.lastHeard !== 0
|
||||
? node.viaMqtt === false && node.hopsAway === 0
|
||||
? "Direct"
|
||||
: `${node.hopsAway?.toString()} ${node.hopsAway ?? 0 > 1 ? "hops" : "hop"
|
||||
: `${node.hopsAway?.toString()} ${
|
||||
node.hopsAway ?? 0 > 1 ? "hops" : "hop"
|
||||
} away`
|
||||
: "-"}
|
||||
{node.viaMqtt === true ? ", via MQTT" : ""}
|
||||
|
||||
+12
-13
@@ -1,26 +1,25 @@
|
||||
import { afterEach, vi } from 'vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import { afterEach, vi } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { enableMapSet } from "immer";
|
||||
import "@testing-library/jest-dom";
|
||||
import '@testing-library/user-event';
|
||||
import "@testing-library/user-event";
|
||||
|
||||
enableMapSet();
|
||||
|
||||
vi.mock('idb-keyval', () => ({
|
||||
get: vi.fn((key) => Promise.resolve(undefined)),
|
||||
set: vi.fn((key, value) => Promise.resolve()),
|
||||
del: vi.fn((key) => Promise.resolve()),
|
||||
vi.mock("idb-keyval", () => ({
|
||||
get: vi.fn(() => Promise.resolve(undefined)),
|
||||
set: vi.fn(() => Promise.resolve()),
|
||||
del: vi.fn(() => Promise.resolve()),
|
||||
clear: vi.fn(() => Promise.resolve()),
|
||||
keys: vi.fn(() => Promise.resolve([])),
|
||||
createStore: vi.fn((dbName, storeName) => ({
|
||||
})),
|
||||
createStore: vi.fn(() => ({})),
|
||||
}));
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() { }
|
||||
unobserve() { }
|
||||
disconnect() { }
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { z } from "zod";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
|
||||
const AddressModeEnum = z.nativeEnum(Protobuf.Config.Config_NetworkConfig_AddressMode);
|
||||
const ProtocolFlagsEnum = z.nativeEnum(Protobuf.Config.Config_NetworkConfig_ProtocolFlags);
|
||||
const AddressModeEnum = z.nativeEnum(
|
||||
Protobuf.Config.Config_NetworkConfig_AddressMode,
|
||||
);
|
||||
const ProtocolFlagsEnum = z.nativeEnum(
|
||||
Protobuf.Config.Config_NetworkConfig_ProtocolFlags,
|
||||
);
|
||||
|
||||
export const NetworkValidationIpV4ConfigSchema = z.object({
|
||||
ip: z.string().ip(),
|
||||
@@ -24,4 +28,3 @@ export const NetworkValidationSchema = z.object({
|
||||
});
|
||||
|
||||
export type NetworkValidation = z.infer<typeof NetworkValidationSchema>;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user