Browse Source

Merge branch 'main' into add-ClientNotification

pull/771/head
Jeremy Gallant 11 months ago
committed by GitHub
parent
commit
1ef0dae62f
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      .vscode/extensions.json
  2. 5
      .vscode/settings.json
  3. 4
      packages/core/src/meshDevice.ts
  4. 3
      packages/web/public/i18n/locales/en/commandPalette.json
  5. 1
      packages/web/public/i18n/locales/en/common.json
  6. 16
      packages/web/public/i18n/locales/en/dialog.json
  7. 8
      packages/web/public/i18n/locales/tr-TR/nodes.json
  8. 7
      packages/web/src/components/CommandPalette/index.tsx
  9. 7
      packages/web/src/components/Dialog/DialogManager.tsx
  10. 87
      packages/web/src/components/Dialog/RebootDialog.test.tsx
  11. 143
      packages/web/src/components/Dialog/RebootDialog.tsx
  12. 117
      packages/web/src/components/Dialog/RebootOTADialog.tsx
  13. 6
      packages/web/src/components/PageComponents/Config/Security/Security.tsx
  14. 112
      packages/web/src/components/UI/Checkbox/Checkbox.test.tsx
  15. 32
      packages/web/src/components/UI/Checkbox/index.tsx
  16. 1
      packages/web/src/components/generic/Filter/FilterComponents.tsx
  17. 5
      packages/web/src/core/stores/deviceStore.mock.ts
  18. 2
      packages/web/src/core/stores/deviceStore.ts
  19. 45
      packages/web/src/core/utils/deepCompareConfig.test.ts
  20. 21
      packages/web/src/core/utils/deepCompareConfig.ts

2
.vscode/extensions.json

@ -1,3 +1,3 @@
{ {
"recommendations": ["bradlc.vscode-tailwindcss", "denoland.vscode-deno"] "recommendations": ["bradlc.vscode-tailwindcss", "biomejs.biome"]
} }

5
.vscode/settings.json

@ -2,5 +2,8 @@
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit", "source.fixAll.biome": "explicit",
} },
"search.exclude": {
"**/i18n/locales/*-*/**": true,
},
} }

4
packages/core/src/meshDevice.ts

@ -626,7 +626,7 @@ export class MeshDevice {
public async reboot(time: number): Promise<number> { public async reboot(time: number): Promise<number> {
this.log.debug( this.log.debug(
Emitter[Emitter.Reboot], Emitter[Emitter.Reboot],
`🔌 Rebooting node ${time > 0 ? "now" : `in ${time} seconds`}`, `🔌 Rebooting node ${time === 0 ? "now" : `in ${time} seconds`}`,
); );
const reboot = create(Protobuf.Admin.AdminMessageSchema, { const reboot = create(Protobuf.Admin.AdminMessageSchema, {
@ -649,7 +649,7 @@ export class MeshDevice {
public async rebootOta(time: number): Promise<number> { public async rebootOta(time: number): Promise<number> {
this.log.debug( this.log.debug(
Emitter[Emitter.RebootOta], Emitter[Emitter.RebootOta],
`🔌 Rebooting into OTA mode ${time > 0 ? "now" : `in ${time} seconds`}`, `🔌 Rebooting into OTA mode ${time === 0 ? "now" : `in ${time} seconds`}`,
); );
const rebootOta = create(Protobuf.Admin.AdminMessageSchema, { const rebootOta = create(Protobuf.Admin.AdminMessageSchema, {

3
packages/web/public/i18n/locales/en/commandPalette.json

@ -33,8 +33,7 @@
"qrGenerator": "Generator", "qrGenerator": "Generator",
"qrImport": "Import", "qrImport": "Import",
"scheduleShutdown": "Schedule Shutdown", "scheduleShutdown": "Schedule Shutdown",
"scheduleReboot": "Schedule Reboot", "scheduleReboot": "Reboot Device",
"rebootToOtaMode": "Reboot To OTA Mode",
"resetNodeDb": "Reset Node DB", "resetNodeDb": "Reset Node DB",
"factoryResetDevice": "Factory Reset Device", "factoryResetDevice": "Factory Reset Device",
"factoryResetConfig": "Factory Reset Config", "factoryResetConfig": "Factory Reset Config",

1
packages/web/public/i18n/locales/en/common.json

@ -17,7 +17,6 @@
"now": "Now", "now": "Now",
"ok": "OK", "ok": "OK",
"print": "Print", "print": "Print",
"rebootOtaNow": "Reboot to OTA Mode Now",
"remove": "Remove", "remove": "Remove",
"requestNewKeys": "Request New Keys", "requestNewKeys": "Request New Keys",
"requestPosition": "Request Position", "requestPosition": "Request Position",

16
packages/web/public/i18n/locales/en/dialog.json

@ -132,15 +132,15 @@
"sharableUrl": "Sharable URL", "sharableUrl": "Sharable URL",
"title": "Generate QR Code" "title": "Generate QR Code"
}, },
"rebootOta": {
"title": "Schedule Reboot",
"description": "Reboot the connected node after a delay into OTA (Over-the-Air) mode.",
"enterDelay": "Enter delay (sec)",
"scheduled": "Reboot has been scheduled"
},
"reboot": { "reboot": {
"title": "Schedule Reboot", "title": "Reboot device",
"description": "Reboot the connected node after x minutes." "description": "Reboot now or schedule a reboot of the connected node. Optionally, you can choose to reboot into OTA (Over-the-Air) mode.",
"ota": "Reboot into OTA mode",
"enterDelay": "Enter delay",
"scheduled": "Reboot has been scheduled",
"schedule": "Schedule reboot",
"now": "Reboot now",
"cancel": "Cancel scheduled reboot"
}, },
"refreshKeys": { "refreshKeys": {
"description": { "description": {

8
packages/web/public/i18n/locales/tr-TR/nodes.json

@ -7,7 +7,7 @@
"label": "No Public Key" "label": "No Public Key"
}, },
"directMessage": { "directMessage": {
"label": "Direct Message {{shortName}}" "label": "Direk Mesaj {{shortName}}"
}, },
"favorite": { "favorite": {
"label": "Favori", "label": "Favori",
@ -50,12 +50,12 @@
"viaMqtt": ", via MQTT" "viaMqtt": ", via MQTT"
}, },
"lastHeardStatus": { "lastHeardStatus": {
"never": "Never" "never": "Hiçbir zaman"
} }
}, },
"actions": { "actions": {
"added": "Added", "added": "Eklendi",
"removed": "Removed", "removed": "Kaldırıldı",
"ignoreNode": "Ignore Node", "ignoreNode": "Ignore Node",
"unignoreNode": "Unignore Node", "unignoreNode": "Unignore Node",
"requestPosition": "Request Position" "requestPosition": "Request Position"

7
packages/web/src/components/CommandPalette/index.tsx

@ -189,13 +189,6 @@ export const CommandPalette = () => {
setDialogOpen("reboot", true); setDialogOpen("reboot", true);
}, },
}, },
{
label: t("contextual.command.rebootToOtaMode"),
icon: RefreshCwIcon,
action() {
setDialogOpen("rebootOTA", true);
},
},
{ {
label: t("contextual.command.resetNodeDb"), label: t("contextual.command.resetNodeDb"),
icon: TrashIcon, icon: TrashIcon,

7
packages/web/src/components/Dialog/DialogManager.tsx

@ -6,7 +6,6 @@ import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDeta
import { PkiBackupDialog } from "@components/Dialog/PKIBackupDialog.tsx"; import { PkiBackupDialog } from "@components/Dialog/PKIBackupDialog.tsx";
import { QRDialog } from "@components/Dialog/QRDialog.tsx"; import { QRDialog } from "@components/Dialog/QRDialog.tsx";
import { RebootDialog } from "@components/Dialog/RebootDialog.tsx"; import { RebootDialog } from "@components/Dialog/RebootDialog.tsx";
import { RebootOTADialog } from "@components/Dialog/RebootOTADialog.tsx";
import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx"; import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx";
import { RemoveNodeDialog } from "@components/Dialog/RemoveNodeDialog.tsx"; import { RemoveNodeDialog } from "@components/Dialog/RemoveNodeDialog.tsx";
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx"; import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx";
@ -80,12 +79,6 @@ export const DialogManager = () => {
setDialogOpen("refreshKeys", open); setDialogOpen("refreshKeys", open);
}} }}
/> />
<RebootOTADialog
open={dialog.rebootOTA}
onOpenChange={(open) => {
setDialogOpen("rebootOTA", open);
}}
/>
<DeleteMessagesDialog <DeleteMessagesDialog
open={dialog.deleteMessages} open={dialog.deleteMessages}
onOpenChange={(open) => { onOpenChange={(open) => {

87
packages/web/src/components/Dialog/RebootOTADialog.test.tsx → packages/web/src/components/Dialog/RebootDialog.test.tsx

@ -7,10 +7,15 @@ import type {
} from "react"; } from "react";
import type { JSX } from "react/jsx-runtime"; import type { JSX } from "react/jsx-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RebootOTADialog } from "./RebootOTADialog.tsx"; import { RebootDialog } from "./RebootDialog.tsx";
const rebootMock = vi.fn();
const rebootOtaMock = vi.fn(); const rebootOtaMock = vi.fn();
let mockConnection: { rebootOta: (delay: number) => void } | undefined = { let mockConnection: {
rebootOta: (delay: number) => void,
reboot: (delay: number) => void
} | undefined = {
reboot: rebootMock,
rebootOta: rebootOtaMock, rebootOta: rebootOtaMock,
}; };
@ -61,7 +66,7 @@ vi.mock("@components/UI/Dialog.tsx", () => {
}; };
}); });
describe("RebootOTADialog", () => { describe("RebootDialog", () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
rebootOtaMock.mockClear(); rebootOtaMock.mockClear();
@ -72,44 +77,68 @@ describe("RebootOTADialog", () => {
}); });
it("renders dialog with default input value", () => { it("renders dialog with default input value", () => {
render(<RebootOTADialog open onOpenChange={() => {}} />); render(<RebootDialog open onOpenChange={() => {}} />);
expect(screen.getByPlaceholderText(/enter delay/i)).toHaveValue(5); expect(screen.getByPlaceholderText(/enter delay/i)).toHaveValue(5);
expect( expect(
screen.getByRole("heading", { name: /schedule reboot/i, level: 1 }), screen.getByRole("heading", { name: /reboot device/i, level: 1 }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText(/reboot to ota mode now/i)).toBeInTheDocument(); expect(screen.getByRole("button", { name: /reboot now/i })).toBeInTheDocument();
}); });
it("calls correct reboot function based on OTA checkbox state", () => {
render(<RebootDialog open onOpenChange={() => {}} />);
// Schedule non-OTA reboot
fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
expect(rebootMock).toHaveBeenCalledWith(5);
expect(rebootOtaMock).not.toHaveBeenCalled();
rebootMock.mockClear();
rebootOtaMock.mockClear();
// Cancel scheduled
fireEvent.click(screen.getByTestId("cancelRebootBtn"));
expect(rebootMock).toHaveBeenCalledWith(-1);
expect(rebootOtaMock).not.toHaveBeenCalled();
rebootMock.mockClear();
rebootOtaMock.mockClear();
// Schedule OTA reboot
fireEvent.click(screen.getByText(/reboot into ota mode/i));
fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
expect(rebootOtaMock).toHaveBeenCalledWith(5);
expect(rebootMock).not.toHaveBeenCalled();
});
it("schedules a reboot with delay and calls rebootOta", async () => { it("schedules a reboot with delay and calls rebootOta", async () => {
const onOpenChangeMock = vi.fn(); const onOpenChangeMock = vi.fn();
render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />); render(<RebootDialog open onOpenChange={onOpenChangeMock} />);
fireEvent.change(screen.getByPlaceholderText(/enter delay/i), { fireEvent.change(screen.getByPlaceholderText(/enter delay/i), {
target: { value: "3" }, target: { value: "3" },
}); });
fireEvent.click(screen.getByTestId("scheduleRebootBtn")); fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
expect(rebootMock).toHaveBeenCalledWith(3);
expect(screen.getByText(/reboot has been scheduled/i)).toBeInTheDocument(); expect(screen.getByText(/reboot has been scheduled/i)).toBeInTheDocument();
vi.advanceTimersByTime(3000); vi.advanceTimersByTime(3000);
await waitFor(() => { expect(onOpenChangeMock).toHaveBeenCalledWith(false);
expect(rebootOtaMock).toHaveBeenCalledWith(0);
expect(onOpenChangeMock).toHaveBeenCalledWith(false);
});
}); });
it("triggers an instant reboot", async () => { it("triggers an instant reboot", async () => {
const onOpenChangeMock = vi.fn(); const onOpenChangeMock = vi.fn();
render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />); render(<RebootDialog open onOpenChange={onOpenChangeMock} />);
fireEvent.click(screen.getByText(/reboot to ota mode now/i)); fireEvent.click(screen.getByRole("button", { name: /reboot now/i }));
await waitFor(() => { expect(rebootMock).toHaveBeenCalledWith(0);
expect(rebootOtaMock).toHaveBeenCalledWith(5); expect(onOpenChangeMock).toHaveBeenCalledWith(false);
expect(onOpenChangeMock).toHaveBeenCalledWith(false);
});
}); });
it("does not call reboot if connection is undefined", async () => { it("does not call reboot if connection is undefined", async () => {
@ -117,16 +146,30 @@ describe("RebootOTADialog", () => {
mockConnection = undefined; mockConnection = undefined;
render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />); render(<RebootDialog open onOpenChange={onOpenChangeMock} />);
fireEvent.click(screen.getByTestId("scheduleRebootBtn")); fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
vi.advanceTimersByTime(5000); vi.advanceTimersByTime(5000);
await waitFor(() => { expect(rebootMock).not.toHaveBeenCalled();
expect(rebootOtaMock).not.toHaveBeenCalled(); expect(rebootOtaMock).not.toHaveBeenCalled();
expect(onOpenChangeMock).not.toHaveBeenCalled();
mockConnection = { reboot: rebootMock, rebootOta: rebootOtaMock };
});
it("cancels a scheduled reboot and calls rebootOta with -1", async () => {
const onOpenChangeMock = vi.fn();
render(<RebootDialog open onOpenChange={onOpenChangeMock} />);
fireEvent.change(screen.getByPlaceholderText(/enter delay/i), {
target: { value: "4" },
}); });
fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
expect(rebootMock).toHaveBeenCalledWith(4);
mockConnection = { rebootOta: rebootOtaMock }; fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
expect(rebootMock).toHaveBeenCalledWith(-1);
expect(screen.queryByText(/reboot has been scheduled/i)).not.toBeInTheDocument();
}); });
}); });

143
packages/web/src/components/Dialog/RebootDialog.tsx

@ -8,21 +8,82 @@ import {
DialogTitle, DialogTitle,
} from "@components/UI/Dialog.tsx"; } from "@components/UI/Dialog.tsx";
import { Input } from "@components/UI/Input.tsx"; import { Input } from "@components/UI/Input.tsx";
import { Label } from "@components/UI/Label.tsx";
import { Separator } from "@components/UI/Seperator.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { RefreshCwIcon } from "lucide-react"; import { ClockIcon, OctagonXIcon, RefreshCwIcon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Checkbox } from "../UI/Checkbox/index.tsx";
export interface RebootDialogProps { export interface RebootDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
const DEFAULT_REBOOT_DELAY = 5; // seconds
export const RebootDialog = ({ open, onOpenChange }: RebootDialogProps) => { export const RebootDialog = ({ open, onOpenChange }: RebootDialogProps) => {
const { t } = useTranslation("dialog"); const { t } = useTranslation("dialog");
const { connection } = useDevice(); const { connection } = useDevice();
const [time, setTime] = useState<number>(DEFAULT_REBOOT_DELAY);
const [isScheduled, setIsScheduled] = useState(false);
const [isOTA, setIsOTA] = useState(false);
const [inputValue, setInputValue] = useState(DEFAULT_REBOOT_DELAY.toString());
const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | undefined>();
const handleReboot = (delay: number) => {
if (!connection) {
return;
}
if (isOTA) {
connection.rebootOta(delay);
} else {
connection.reboot(delay);
}
};
const handleSetTime = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.validity.valid) {
e.preventDefault();
return;
}
const val = e.target.value;
setInputValue(val);
const parsed = Number(val);
if (!Number.isNaN(parsed) && parsed > 0) {
setTime(parsed);
}
};
const handleRebootWithTimeout = async () => {
setIsScheduled(true);
const delay = time > 0 ? time : DEFAULT_REBOOT_DELAY;
handleReboot(delay);
const id = setTimeout(() => {
setIsScheduled(false);
onOpenChange(false);
setInputValue(DEFAULT_REBOOT_DELAY.toString());
}, delay * 1000);
setTimeoutId(id);
};
const handleCancel = () => {
clearTimeout(timeoutId);
setIsScheduled(false);
handleReboot(-1);
};
const [time, setTime] = useState<number>(5); const handleInstantReboot = async () => {
handleReboot(0);
onOpenChange(false);
};
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
@ -32,24 +93,66 @@ export const RebootDialog = ({ open, onOpenChange }: RebootDialogProps) => {
<DialogTitle>{t("reboot.title")}</DialogTitle> <DialogTitle>{t("reboot.title")}</DialogTitle>
<DialogDescription>{t("reboot.description")}</DialogDescription> <DialogDescription>{t("reboot.description")}</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex gap-2 p-4"> <Separator />
<Input {!isScheduled ? (
type="number" <>
className="dark:text-slate-900" <Checkbox
value={time} checked={isOTA}
onChange={(e) => setTime(Number.parseInt(e.target.value))} onChange={(checked) => setIsOTA(checked)}
/> className="px-2"
<Button >
className="w-24" {t("reboot.ota")}
name="now" </Checkbox>
onClick={() => { <div className="flex gap-2 px-2 items-center relative">
connection?.reboot(2).then(() => onOpenChange(false)); <Input
}} type="number"
> min={1}
<RefreshCwIcon className="mr-2" size={16} /> max={86400}
{t("button.now")} value={inputValue}
</Button> onChange={handleSetTime}
</div> placeholder={t("reboot.enterDelay")}
suffix={t("unit.second.plural")}
/>
<Button
onClick={() => handleRebootWithTimeout()}
data-testid="scheduleRebootBtn"
className="w-9/12"
>
<ClockIcon className="mr-2" size={18} />
{t("reboot.schedule")}
</Button>
</div>
<div className="px-2">
<Button
variant="destructive"
name="rebootNow"
onClick={() => handleInstantReboot()}
className=" w-full"
>
<RefreshCwIcon className="mr-2" size={16} />
{t("reboot.now")}
</Button>
</div>
</>
) : (
<div className="px-2">
<div className="pb-6 pt-2 text-center">
<Label className=" text-gray-700 dark:text-gray-300 ">
{t("reboot.scheduled")}
</Label>
</div>
<Button
variant="destructive"
name="cancelReboot"
onClick={() => handleCancel()}
className=" w-full"
data-testid="cancelRebootBtn"
>
<OctagonXIcon className="mr-2" size={16} />
{t("reboot.cancel")}
</Button>
</div>
)}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

117
packages/web/src/components/Dialog/RebootOTADialog.tsx

@ -1,117 +0,0 @@
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { ClockIcon, RefreshCwIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
export interface RebootOTADialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const DEFAULT_REBOOT_DELAY = 5; // seconds
export const RebootOTADialog = ({
open,
onOpenChange,
}: RebootOTADialogProps) => {
const { t } = useTranslation("dialog");
const { connection } = useDevice();
const [time, setTime] = useState<number>(DEFAULT_REBOOT_DELAY);
const [isScheduled, setIsScheduled] = useState(false);
const [inputValue, setInputValue] = useState(DEFAULT_REBOOT_DELAY.toString());
const handleSetTime = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.validity.valid) {
e.preventDefault();
return;
}
const val = e.target.value;
setInputValue(val);
const parsed = Number(val);
if (!Number.isNaN(parsed) && parsed > 0) {
setTime(parsed);
}
};
const handleRebootWithTimeout = async () => {
if (!connection) {
return;
}
setIsScheduled(true);
const delay = time > 0 ? time : DEFAULT_REBOOT_DELAY;
await new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, delay * 1000);
}).finally(() => {
setIsScheduled(false);
onOpenChange(false);
setInputValue(DEFAULT_REBOOT_DELAY.toString());
});
connection.rebootOta(0);
};
const handleInstantReboot = async () => {
if (!connection) {
return;
}
await connection.rebootOta(DEFAULT_REBOOT_DELAY);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>{t("rebootOta.title")}</DialogTitle>
<DialogDescription>{t("rebootOta.description")}</DialogDescription>
</DialogHeader>
<div className="flex gap-2 p-2 items-center relative">
<Input
type="number"
min={1}
max={86400}
className="dark:text-slate-900 appearance-none"
value={inputValue}
onChange={handleSetTime}
placeholder={t("rebootOta.enterDelay")}
/>
<Button
onClick={() => handleRebootWithTimeout()}
data-testid="scheduleRebootBtn"
className="w-9/12"
>
<ClockIcon className="mr-2" size={18} />
{isScheduled ? t("rebootOta.scheduled") : t("rebootOta.title")}
</Button>
</div>
<Button
variant="destructive"
name="rebootNow"
onClick={() => handleInstantReboot()}
>
<RefreshCwIcon className="mr-2" size={16} />
{t("button.rebootOtaNow")}
</Button>
</DialogContent>
</Dialog>
);
};

6
packages/web/src/components/PageComponents/Config/Security/Security.tsx

@ -197,14 +197,12 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
description: t("security.primaryAdminKey.description"), description: t("security.primaryAdminKey.description"),
bits, bits,
devicePSKBitCount: 32, devicePSKBitCount: 32,
hide: true,
actionButtons: [], actionButtons: [],
disabledBy: [ disabledBy: [
{ fieldName: "adminChannelEnabled", invert: true }, { fieldName: "adminChannelEnabled", invert: true },
], ],
properties: { properties: {
showCopyButton: true, showCopyButton: true,
showPasswordToggle: true,
}, },
}, },
{ {
@ -215,14 +213,12 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
description: t("security.secondaryAdminKey.description"), description: t("security.secondaryAdminKey.description"),
bits, bits,
devicePSKBitCount: 32, devicePSKBitCount: 32,
hide: true,
actionButtons: [], actionButtons: [],
disabledBy: [ disabledBy: [
{ fieldName: "adminChannelEnabled", invert: true }, { fieldName: "adminChannelEnabled", invert: true },
], ],
properties: { properties: {
showCopyButton: true, showCopyButton: true,
showPasswordToggle: true,
}, },
}, },
{ {
@ -233,14 +229,12 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
description: t("security.tertiaryAdminKey.description"), description: t("security.tertiaryAdminKey.description"),
bits, bits,
devicePSKBitCount: 32, devicePSKBitCount: 32,
hide: true,
actionButtons: [], actionButtons: [],
disabledBy: [ disabledBy: [
{ fieldName: "adminChannelEnabled", invert: true }, { fieldName: "adminChannelEnabled", invert: true },
], ],
properties: { properties: {
showCopyButton: true, showCopyButton: true,
showPasswordToggle: true,
}, },
}, },
{ {

112
packages/web/src/components/UI/Checkbox/Checkbox.test.tsx

@ -1,56 +1,75 @@
import { Checkbox } from "@components/UI/Checkbox/index.tsx"; import { Checkbox } from "@components/UI/Checkbox/index.tsx";
import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
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>
),
}));
describe("Checkbox", () => { describe("Checkbox", () => {
beforeEach(cleanup); beforeEach(cleanup);
it("renders unchecked by default", () => { it("renders unchecked by default (uncontrolled)", () => {
render(<Checkbox />); render(<Checkbox />);
const checkbox = screen.getByRole("checkbox"); const checkbox = screen.getByRole("checkbox");
const presentation = screen.getByRole("presentation");
expect(checkbox).not.toBeChecked(); expect(checkbox).not.toBeChecked();
expect(screen.queryByText("Check")).not.toBeInTheDocument(); // unchecked -> no filled bg class
expect(presentation).not.toHaveClass("bg-slate-500");
}); });
it("renders checked when checked prop is true", () => { it("respects defaultChecked in uncontrolled mode", () => {
render(<Checkbox checked />); render(<Checkbox defaultChecked />);
expect(screen.getByRole("checkbox")).toBeChecked(); expect(screen.getByRole("checkbox")).toBeChecked();
expect(screen.getByRole("presentation")).toBeInTheDocument();
}); });
it("calls onChange when clicked", () => { it("renders checked when controlled with checked=true", () => {
render(<Checkbox checked />);
const checkbox = screen.getByRole("checkbox");
const presentation = screen.getByRole("presentation");
expect(checkbox).toBeChecked();
expect(presentation).toHaveClass("bg-slate-500");
});
it("calls onChange when clicked (uncontrolled) and toggles DOM state", () => {
const onChange = vi.fn(); const onChange = vi.fn();
render(<Checkbox onChange={onChange} />); render(<Checkbox onChange={onChange} />);
fireEvent.click(screen.getByRole("presentation")); const checkbox = screen.getByRole("checkbox");
expect(onChange).toHaveBeenCalledWith(true); const presentation = screen.getByRole("presentation");
fireEvent.click(screen.getByRole("presentation")); fireEvent.click(presentation);
expect(onChange).toHaveBeenCalledWith(false); expect(onChange).toHaveBeenLastCalledWith(true);
expect(checkbox).toBeChecked();
fireEvent.click(presentation);
expect(onChange).toHaveBeenLastCalledWith(false);
expect(checkbox).not.toBeChecked();
});
it("controlled: calls onChange but does not toggle without prop update", () => {
const onChange = vi.fn();
render(<Checkbox checked={false} onChange={onChange} />);
const checkbox = screen.getByRole("checkbox");
const presentation = screen.getByRole("presentation");
fireEvent.click(presentation);
expect(onChange).toHaveBeenLastCalledWith(true);
// still unchecked because parent didn't update prop
expect(checkbox).not.toBeChecked();
});
it("controlled: reflects external prop changes after onChange", () => {
const onChange = vi.fn();
const { rerender } = render(<Checkbox checked={false} onChange={onChange} />);
const checkbox = screen.getByRole("checkbox");
const presentation = screen.getByRole("presentation");
fireEvent.click(presentation);
expect(onChange).toHaveBeenLastCalledWith(true);
// parent updates `checked` based on onChange
rerender(<Checkbox checked={true} onChange={onChange} />);
expect(checkbox).toBeChecked();
expect(presentation).toHaveClass("bg-slate-500");
}); });
it("uses provided id", () => { it("uses provided id", () => {
@ -58,23 +77,16 @@ describe("Checkbox", () => {
expect(screen.getByRole("checkbox").id).toBe("custom-id"); expect(screen.getByRole("checkbox").id).toBe("custom-id");
}); });
it("renders children in Label component", () => { it("renders children inside the label", () => {
render(<Checkbox>Test Label</Checkbox>); render(<Checkbox>Test Label</Checkbox>);
expect(screen.getByTestId("label-component")).toHaveTextContent( expect(screen.getByTestId("label-component")).toHaveTextContent("Test Label");
"Test Label",
);
}); });
it("applies custom className", () => { it("applies custom className to wrapper label", () => {
const { container } = render(<Checkbox className="custom-class" />); const { container } = render(<Checkbox className="custom-class" />);
expect(container.firstChild).toHaveClass("custom-class"); expect(container.firstChild).toHaveClass("custom-class");
}); });
it("applies labelClassName to Label", () => {
render(<Checkbox labelClassName="label-class">Test</Checkbox>);
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 />); render(<Checkbox disabled />);
expect(screen.getByRole("checkbox")).toBeDisabled(); expect(screen.getByRole("checkbox")).toBeDisabled();
@ -84,7 +96,6 @@ describe("Checkbox", () => {
it("does not call onChange when disabled", () => { it("does not call onChange when disabled", () => {
const onChange = vi.fn(); const onChange = vi.fn();
render(<Checkbox onChange={onChange} disabled />); render(<Checkbox onChange={onChange} disabled />);
fireEvent.click(screen.getByRole("presentation")); fireEvent.click(screen.getByRole("presentation"));
expect(onChange).not.toHaveBeenCalled(); expect(onChange).not.toHaveBeenCalled();
}); });
@ -99,24 +110,19 @@ describe("Checkbox", () => {
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 to the input", () => {
render(<Checkbox data-testid="extra-prop" />); render(<Checkbox data-testid="extra-prop" />);
expect(screen.getByRole("checkbox")).toHaveAttribute( expect(screen.getByRole("checkbox")).toHaveAttribute("data-testid", "extra-prop");
"data-testid",
"extra-prop",
);
}); });
it("toggles checked state correctly", () => { it("uncontrolled: toggles checked state when clicking the visual box", () => {
render(<Checkbox />); render(<Checkbox />);
const checkbox = screen.getByRole("checkbox"); const checkbox = screen.getByRole("checkbox");
const presentation = screen.getByRole("presentation"); const presentation = screen.getByRole("presentation");
expect(checkbox).not.toBeChecked(); expect(checkbox).not.toBeChecked();
fireEvent.click(presentation); fireEvent.click(presentation);
expect(checkbox).toBeChecked(); expect(checkbox).toBeChecked();
fireEvent.click(presentation); fireEvent.click(presentation);
expect(checkbox).not.toBeChecked(); expect(checkbox).not.toBeChecked();
}); });

32
packages/web/src/components/UI/Checkbox/index.tsx

@ -1,9 +1,10 @@
import { cn } from "@core/utils/cn.ts"; import { cn } from "@core/utils/cn.ts";
import { Check } from "lucide-react"; import { Check } from "lucide-react";
import { useId } from "react"; import { useId, useState } from "react";
interface CheckboxProps { interface CheckboxProps {
checked?: boolean; checked?: boolean;
defaultChecked?: boolean;
onChange?: (checked: boolean) => void; onChange?: (checked: boolean) => void;
className?: string; className?: string;
labelClassName?: string; labelClassName?: string;
@ -15,7 +16,8 @@ interface CheckboxProps {
} }
export function Checkbox({ export function Checkbox({
checked = false, checked,
defaultChecked = false,
onChange, onChange,
className, className,
id: propId, id: propId,
@ -28,10 +30,21 @@ export function Checkbox({
const generatedId = useId(); const generatedId = useId();
const id = propId || generatedId; const id = propId || generatedId;
const handleToggle = (): void => { const isControlled = checked !== undefined;
if (!disabled) { const [internal, setInternal] = useState<boolean>(defaultChecked);
onChange?.(!checked); const value = checked ?? internal;
const handleToggle = (e: React.ChangeEvent<HTMLInputElement>) => {
if (disabled) {
return;
}
const next = e.target.checked;
if (!isControlled) {
setInternal(next);
} }
onChange?.(next);
}; };
return ( return (
@ -41,11 +54,12 @@ export function Checkbox({
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer", disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
className, className,
)} )}
data-testid="label-component"
> >
<input <input
type="checkbox" type="checkbox"
id={id} id={id}
checked={checked} checked={value}
onChange={handleToggle} onChange={handleToggle}
disabled={disabled} disabled={disabled}
required={required} required={required}
@ -57,10 +71,12 @@ export function Checkbox({
className={cn( className={cn(
"flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-2 border-gray-500 transition-colors", "flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-2 border-gray-500 transition-colors",
"peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500 peer-focus-visible:ring-offset-2", "peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500 peer-focus-visible:ring-offset-2",
{ "border-slate-500 bg-slate-500": checked }, { "border-slate-500 bg-slate-500": value },
{ "opacity-50": disabled },
)} )}
role="presentation"
> >
{checked && ( {value && (
<div className="animate-fade-in"> <div className="animate-fade-in">
<Check className="h-4 w-4 text-white" /> <Check className="h-4 w-4 text-white" />
</div> </div>

1
packages/web/src/components/generic/Filter/FilterComponents.tsx

@ -170,6 +170,7 @@ export const FilterMulti = <K extends EnumArrayKeys<FilterState>>({
key={val} key={val}
checked={selected.includes(val)} checked={selected.includes(val)}
onChange={(checked) => toggleValue(val, checked)} onChange={(checked) => toggleValue(val, checked)}
className="flex items-center gap-2"
> >
<span className="dark:text-slate-200">{getLabel(val)}</span> <span className="dark:text-slate-200">{getLabel(val)}</span>
</Checkbox> </Checkbox>

5
packages/web/src/core/stores/deviceStore.mock.ts

@ -36,7 +36,6 @@ export const mockDeviceStore: Device = {
QR: false, QR: false,
shutdown: false, shutdown: false,
reboot: false, reboot: false,
rebootOTA: false,
deviceName: false, deviceName: false,
nodeRemoval: false, nodeRemoval: false,
pkiBackup: false, pkiBackup: false,
@ -92,6 +91,6 @@ export const mockDeviceStore: Device = {
addClientNotification: vi.fn(), addClientNotification: vi.fn(),
removeClientNotification: vi.fn(), removeClientNotification: vi.fn(),
getClientNotification: vi.fn(), getClientNotification: vi.fn(),
getAllUnreadCount: vi.fn(), getAllUnreadCount: vi.fn().mockReturnValue(0),
getUnreadCount: vi.fn(), getUnreadCount: vi.fn().mockReturnValue(0),
}; };

2
packages/web/src/core/stores/deviceStore.ts

@ -54,7 +54,6 @@ export interface Device {
QR: boolean; QR: boolean;
shutdown: boolean; shutdown: boolean;
reboot: boolean; reboot: boolean;
rebootOTA: boolean;
deviceName: boolean; deviceName: boolean;
nodeRemoval: boolean; nodeRemoval: boolean;
pkiBackup: boolean; pkiBackup: boolean;
@ -181,7 +180,6 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
nodeDetails: false, nodeDetails: false,
unsafeRoles: false, unsafeRoles: false,
refreshKeys: false, refreshKeys: false,
rebootOTA: false,
deleteMessages: false, deleteMessages: false,
managedMode: false, managedMode: false,
clientNotification: false, clientNotification: false,

45
packages/web/src/core/utils/deepCompareConfig.test.ts

@ -57,4 +57,49 @@ describe("deepCompareConfig", () => {
expect(deepCompareConfig([1, 2, 3, 4], [1, 2], true)).toBe(true); expect(deepCompareConfig([1, 2, 3, 4], [1, 2], true)).toBe(true);
expect(deepCompareConfig([1, 2, 3, 4], [1, 2], false)).toBe(false); expect(deepCompareConfig([1, 2, 3, 4], [1, 2], false)).toBe(false);
}); });
it("compares Uint8Array strictly: equal bytes -> true", () => {
const a = new Uint8Array([1, 2, 3]);
const b = new Uint8Array([1, 2, 3]);
expect(deepCompareConfig(a, b)).toBe(true);
});
it("compares Uint8Array strictly: different bytes -> false", () => {
const a = new Uint8Array([1, 2, 3]);
const b = new Uint8Array([1, 2, 4]);
expect(deepCompareConfig(a, b)).toBe(false);
});
it("Uint8Array vs undefined is false even when allowUndefined is true", () => {
const a = new Uint8Array([1, 2, 3]);
expect(deepCompareConfig(a, undefined, true)).toBe(false);
expect(deepCompareConfig(undefined, a, true)).toBe(false);
});
it("nested Uint8Array fields must match exactly", () => {
const existing = { data: new Uint8Array([9, 8, 7]) };
const workingEqual = { data: new Uint8Array([9, 8, 7]) };
const workingDiff = { data: new Uint8Array([9, 8, 6]) };
const workingUndef = { data: undefined as unknown };
expect(deepCompareConfig(existing, workingEqual)).toBe(true);
expect(deepCompareConfig(existing, workingDiff)).toBe(false);
// still false even with allowUndefined
expect(deepCompareConfig(existing, workingUndef, true)).toBe(false);
});
it("arrays containing Uint8Array: element must match exactly", () => {
const a = [new Uint8Array([1, 2]), new Uint8Array([3, 4])];
const b = [new Uint8Array([1, 2]), new Uint8Array([3, 4])];
const c = [new Uint8Array([1, 2]), new Uint8Array([3, 9])];
expect(deepCompareConfig(a, b)).toBe(true);
expect(deepCompareConfig(a, c)).toBe(false);
});
it("shorter working array with missing Uint8Array element -> false even with allowUndefined", () => {
const existing = [new Uint8Array([1, 2]), new Uint8Array([3, 4])];
const workingShort = [new Uint8Array([1, 2])]; // missing the second byte array
expect(deepCompareConfig(existing, workingShort, true)).toBe(false);
});
}); });

21
packages/web/src/core/utils/deepCompareConfig.ts

@ -2,6 +2,22 @@ function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value); return typeof value === "object" && value !== null && !Array.isArray(value);
} }
function isUint8Array(v: unknown): v is Uint8Array {
return v instanceof Uint8Array;
}
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.byteLength !== b.byteLength) {
return false;
}
for (let i = 0; i < a.byteLength; i++) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
}
export function deepCompareConfig( export function deepCompareConfig(
a: unknown, a: unknown,
b: unknown, b: unknown,
@ -11,7 +27,10 @@ export function deepCompareConfig(
return true; return true;
} }
// If allowUndefined is true, and one is undefined, they are considered equal. // This check is placed early to simplify subsequent logic. if (isUint8Array(a) || isUint8Array(b)) {
return isUint8Array(a) && isUint8Array(b) && bytesEqual(a, b);
}
if (allowUndefined && (a === undefined || b === undefined)) { if (allowUndefined && (a === undefined || b === undefined)) {
return true; return true;
} }

Loading…
Cancel
Save