diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 559fad6f..46e21e11 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,3 @@ { - "recommendations": ["bradlc.vscode-tailwindcss", "denoland.vscode-deno"] + "recommendations": ["bradlc.vscode-tailwindcss", "biomejs.biome"] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 1624cd2e..aa09339f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,5 +2,8 @@ "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.fixAll.biome": "explicit", - } + }, + "search.exclude": { + "**/i18n/locales/*-*/**": true, + }, } diff --git a/packages/core/src/meshDevice.ts b/packages/core/src/meshDevice.ts index 41ada369..37b705c8 100755 --- a/packages/core/src/meshDevice.ts +++ b/packages/core/src/meshDevice.ts @@ -626,7 +626,7 @@ export class MeshDevice { public async reboot(time: number): Promise { this.log.debug( 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, { @@ -649,7 +649,7 @@ export class MeshDevice { public async rebootOta(time: number): Promise { this.log.debug( 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, { diff --git a/packages/web/public/i18n/locales/en/commandPalette.json b/packages/web/public/i18n/locales/en/commandPalette.json index d66bd1d8..e3f80a3a 100644 --- a/packages/web/public/i18n/locales/en/commandPalette.json +++ b/packages/web/public/i18n/locales/en/commandPalette.json @@ -33,8 +33,7 @@ "qrGenerator": "Generator", "qrImport": "Import", "scheduleShutdown": "Schedule Shutdown", - "scheduleReboot": "Schedule Reboot", - "rebootToOtaMode": "Reboot To OTA Mode", + "scheduleReboot": "Reboot Device", "resetNodeDb": "Reset Node DB", "factoryResetDevice": "Factory Reset Device", "factoryResetConfig": "Factory Reset Config", diff --git a/packages/web/public/i18n/locales/en/common.json b/packages/web/public/i18n/locales/en/common.json index 8f40e7c8..d6cd2a17 100644 --- a/packages/web/public/i18n/locales/en/common.json +++ b/packages/web/public/i18n/locales/en/common.json @@ -17,7 +17,6 @@ "now": "Now", "ok": "OK", "print": "Print", - "rebootOtaNow": "Reboot to OTA Mode Now", "remove": "Remove", "requestNewKeys": "Request New Keys", "requestPosition": "Request Position", diff --git a/packages/web/public/i18n/locales/en/dialog.json b/packages/web/public/i18n/locales/en/dialog.json index a5fa2250..c00af2b8 100644 --- a/packages/web/public/i18n/locales/en/dialog.json +++ b/packages/web/public/i18n/locales/en/dialog.json @@ -132,15 +132,15 @@ "sharableUrl": "Sharable URL", "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": { - "title": "Schedule Reboot", - "description": "Reboot the connected node after x minutes." + "title": "Reboot device", + "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": { "description": { diff --git a/packages/web/public/i18n/locales/tr-TR/nodes.json b/packages/web/public/i18n/locales/tr-TR/nodes.json index 812dbf81..8ee00245 100644 --- a/packages/web/public/i18n/locales/tr-TR/nodes.json +++ b/packages/web/public/i18n/locales/tr-TR/nodes.json @@ -7,7 +7,7 @@ "label": "No Public Key" }, "directMessage": { - "label": "Direct Message {{shortName}}" + "label": "Direk Mesaj {{shortName}}" }, "favorite": { "label": "Favori", @@ -50,12 +50,12 @@ "viaMqtt": ", via MQTT" }, "lastHeardStatus": { - "never": "Never" + "never": "Hiçbir zaman" } }, "actions": { - "added": "Added", - "removed": "Removed", + "added": "Eklendi", + "removed": "Kaldırıldı", "ignoreNode": "Ignore Node", "unignoreNode": "Unignore Node", "requestPosition": "Request Position" diff --git a/packages/web/src/components/CommandPalette/index.tsx b/packages/web/src/components/CommandPalette/index.tsx index 3f6bb94a..3c36f336 100644 --- a/packages/web/src/components/CommandPalette/index.tsx +++ b/packages/web/src/components/CommandPalette/index.tsx @@ -189,13 +189,6 @@ export const CommandPalette = () => { setDialogOpen("reboot", true); }, }, - { - label: t("contextual.command.rebootToOtaMode"), - icon: RefreshCwIcon, - action() { - setDialogOpen("rebootOTA", true); - }, - }, { label: t("contextual.command.resetNodeDb"), icon: TrashIcon, diff --git a/packages/web/src/components/Dialog/DialogManager.tsx b/packages/web/src/components/Dialog/DialogManager.tsx index f155523f..0af89f83 100644 --- a/packages/web/src/components/Dialog/DialogManager.tsx +++ b/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 { QRDialog } from "@components/Dialog/QRDialog.tsx"; import { RebootDialog } from "@components/Dialog/RebootDialog.tsx"; -import { RebootOTADialog } from "@components/Dialog/RebootOTADialog.tsx"; import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx"; import { RemoveNodeDialog } from "@components/Dialog/RemoveNodeDialog.tsx"; import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx"; @@ -80,12 +79,6 @@ export const DialogManager = () => { setDialogOpen("refreshKeys", open); }} /> - { - setDialogOpen("rebootOTA", open); - }} - /> { diff --git a/packages/web/src/components/Dialog/RebootOTADialog.test.tsx b/packages/web/src/components/Dialog/RebootDialog.test.tsx similarity index 53% rename from packages/web/src/components/Dialog/RebootOTADialog.test.tsx rename to packages/web/src/components/Dialog/RebootDialog.test.tsx index a1ed9a98..663ca5b6 100644 --- a/packages/web/src/components/Dialog/RebootOTADialog.test.tsx +++ b/packages/web/src/components/Dialog/RebootDialog.test.tsx @@ -7,10 +7,15 @@ import type { } from "react"; import type { JSX } from "react/jsx-runtime"; 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(); -let mockConnection: { rebootOta: (delay: number) => void } | undefined = { +let mockConnection: { + rebootOta: (delay: number) => void, + reboot: (delay: number) => void + } | undefined = { + reboot: rebootMock, rebootOta: rebootOtaMock, }; @@ -61,7 +66,7 @@ vi.mock("@components/UI/Dialog.tsx", () => { }; }); -describe("RebootOTADialog", () => { +describe("RebootDialog", () => { beforeEach(() => { vi.useFakeTimers(); rebootOtaMock.mockClear(); @@ -72,44 +77,68 @@ describe("RebootOTADialog", () => { }); it("renders dialog with default input value", () => { - render( {}} />); + render( {}} />); expect(screen.getByPlaceholderText(/enter delay/i)).toHaveValue(5); expect( - screen.getByRole("heading", { name: /schedule reboot/i, level: 1 }), + screen.getByRole("heading", { name: /reboot device/i, level: 1 }), ).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( {}} />); + + // 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 () => { const onOpenChangeMock = vi.fn(); - render(); + render(); fireEvent.change(screen.getByPlaceholderText(/enter delay/i), { target: { value: "3" }, }); fireEvent.click(screen.getByTestId("scheduleRebootBtn")); + + expect(rebootMock).toHaveBeenCalledWith(3); expect(screen.getByText(/reboot has been scheduled/i)).toBeInTheDocument(); vi.advanceTimersByTime(3000); - await waitFor(() => { - expect(rebootOtaMock).toHaveBeenCalledWith(0); - expect(onOpenChangeMock).toHaveBeenCalledWith(false); - }); + expect(onOpenChangeMock).toHaveBeenCalledWith(false); + }); it("triggers an instant reboot", async () => { const onOpenChangeMock = vi.fn(); - render(); + render(); - fireEvent.click(screen.getByText(/reboot to ota mode now/i)); + fireEvent.click(screen.getByRole("button", { name: /reboot now/i })); - await waitFor(() => { - expect(rebootOtaMock).toHaveBeenCalledWith(5); - expect(onOpenChangeMock).toHaveBeenCalledWith(false); - }); + expect(rebootMock).toHaveBeenCalledWith(0); + expect(onOpenChangeMock).toHaveBeenCalledWith(false); }); it("does not call reboot if connection is undefined", async () => { @@ -117,16 +146,30 @@ describe("RebootOTADialog", () => { mockConnection = undefined; - render(); + render(); fireEvent.click(screen.getByTestId("scheduleRebootBtn")); + vi.advanceTimersByTime(5000); - await waitFor(() => { - expect(rebootOtaMock).not.toHaveBeenCalled(); - expect(onOpenChangeMock).not.toHaveBeenCalled(); + expect(rebootMock).not.toHaveBeenCalled(); + expect(rebootOtaMock).not.toHaveBeenCalled(); + + mockConnection = { reboot: rebootMock, rebootOta: rebootOtaMock }; + }); + + it("cancels a scheduled reboot and calls rebootOta with -1", async () => { + const onOpenChangeMock = vi.fn(); + render(); + + 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(); }); }); diff --git a/packages/web/src/components/Dialog/RebootDialog.tsx b/packages/web/src/components/Dialog/RebootDialog.tsx index b41a8948..a0e6ad1f 100644 --- a/packages/web/src/components/Dialog/RebootDialog.tsx +++ b/packages/web/src/components/Dialog/RebootDialog.tsx @@ -8,21 +8,82 @@ import { DialogTitle, } from "@components/UI/Dialog.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 { RefreshCwIcon } from "lucide-react"; +import { ClockIcon, OctagonXIcon, RefreshCwIcon } from "lucide-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; +import { Checkbox } from "../UI/Checkbox/index.tsx"; export interface RebootDialogProps { open: boolean; onOpenChange: (open: boolean) => void; } +const DEFAULT_REBOOT_DELAY = 5; // seconds + export const RebootDialog = ({ open, onOpenChange }: RebootDialogProps) => { const { t } = useTranslation("dialog"); const { connection } = useDevice(); + const [time, setTime] = useState(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(); + + const handleReboot = (delay: number) => { + if (!connection) { + return; + } + + if (isOTA) { + connection.rebootOta(delay); + } else { + connection.reboot(delay); + } + }; + + const handleSetTime = (e: React.ChangeEvent) => { + 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(5); + const handleInstantReboot = async () => { + handleReboot(0); + onOpenChange(false); + }; return ( @@ -32,24 +93,66 @@ export const RebootDialog = ({ open, onOpenChange }: RebootDialogProps) => { {t("reboot.title")} {t("reboot.description")} -
- setTime(Number.parseInt(e.target.value))} - /> - -
+ + {!isScheduled ? ( + <> + setIsOTA(checked)} + className="px-2" + > + {t("reboot.ota")} + +
+ + +
+
+ +
+ + ) : ( +
+
+ +
+ +
+ )}
); diff --git a/packages/web/src/components/Dialog/RebootOTADialog.tsx b/packages/web/src/components/Dialog/RebootOTADialog.tsx deleted file mode 100644 index 5d1ccd36..00000000 --- a/packages/web/src/components/Dialog/RebootOTADialog.tsx +++ /dev/null @@ -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(DEFAULT_REBOOT_DELAY); - const [isScheduled, setIsScheduled] = useState(false); - const [inputValue, setInputValue] = useState(DEFAULT_REBOOT_DELAY.toString()); - - const handleSetTime = (e: React.ChangeEvent) => { - 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((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 ( - - - - - {t("rebootOta.title")} - {t("rebootOta.description")} - - -
- - -
- - -
-
- ); -}; diff --git a/packages/web/src/components/PageComponents/Config/Security/Security.tsx b/packages/web/src/components/PageComponents/Config/Security/Security.tsx index abdd7377..d47e8bf7 100644 --- a/packages/web/src/components/PageComponents/Config/Security/Security.tsx +++ b/packages/web/src/components/PageComponents/Config/Security/Security.tsx @@ -197,14 +197,12 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => { description: t("security.primaryAdminKey.description"), bits, devicePSKBitCount: 32, - hide: true, actionButtons: [], disabledBy: [ { fieldName: "adminChannelEnabled", invert: true }, ], properties: { showCopyButton: true, - showPasswordToggle: true, }, }, { @@ -215,14 +213,12 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => { description: t("security.secondaryAdminKey.description"), bits, devicePSKBitCount: 32, - hide: true, actionButtons: [], disabledBy: [ { fieldName: "adminChannelEnabled", invert: true }, ], properties: { showCopyButton: true, - showPasswordToggle: true, }, }, { @@ -233,14 +229,12 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => { description: t("security.tertiaryAdminKey.description"), bits, devicePSKBitCount: 32, - hide: true, actionButtons: [], disabledBy: [ { fieldName: "adminChannelEnabled", invert: true }, ], properties: { showCopyButton: true, - showPasswordToggle: true, }, }, { diff --git a/packages/web/src/components/UI/Checkbox/Checkbox.test.tsx b/packages/web/src/components/UI/Checkbox/Checkbox.test.tsx index eab95fa7..0d5bf934 100644 --- a/packages/web/src/components/UI/Checkbox/Checkbox.test.tsx +++ b/packages/web/src/components/UI/Checkbox/Checkbox.test.tsx @@ -1,56 +1,75 @@ import { Checkbox } from "@components/UI/Checkbox/index.tsx"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import type React from "react"; 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; - }) => ( - - ), -})); - describe("Checkbox", () => { beforeEach(cleanup); - it("renders unchecked by default", () => { + it("renders unchecked by default (uncontrolled)", () => { render(); const checkbox = screen.getByRole("checkbox"); + const presentation = screen.getByRole("presentation"); 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", () => { - render(); + it("respects defaultChecked in uncontrolled mode", () => { + render(); expect(screen.getByRole("checkbox")).toBeChecked(); - expect(screen.getByRole("presentation")).toBeInTheDocument(); }); - it("calls onChange when clicked", () => { + it("renders checked when controlled with checked=true", () => { + render(); + 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(); render(); - fireEvent.click(screen.getByRole("presentation")); - expect(onChange).toHaveBeenCalledWith(true); + const checkbox = screen.getByRole("checkbox"); + const presentation = screen.getByRole("presentation"); - fireEvent.click(screen.getByRole("presentation")); - expect(onChange).toHaveBeenCalledWith(false); + fireEvent.click(presentation); + 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(); + + 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(); + + const checkbox = screen.getByRole("checkbox"); + const presentation = screen.getByRole("presentation"); + + fireEvent.click(presentation); + expect(onChange).toHaveBeenLastCalledWith(true); + + // parent updates `checked` based on onChange + rerender(); + expect(checkbox).toBeChecked(); + expect(presentation).toHaveClass("bg-slate-500"); }); it("uses provided id", () => { @@ -58,23 +77,16 @@ describe("Checkbox", () => { expect(screen.getByRole("checkbox").id).toBe("custom-id"); }); - it("renders children in Label component", () => { + it("renders children inside the label", () => { render(Test Label); - expect(screen.getByTestId("label-component")).toHaveTextContent( - "Test Label", - ); + expect(screen.getByTestId("label-component")).toHaveTextContent("Test Label"); }); - it("applies custom className", () => { + it("applies custom className to wrapper label", () => { const { container } = render(); expect(container.firstChild).toHaveClass("custom-class"); }); - it("applies labelClassName to Label", () => { - render(Test); - expect(screen.getByTestId("label-component")).toHaveClass("label-class"); - }); - it("disables checkbox when disabled prop is true", () => { render(); expect(screen.getByRole("checkbox")).toBeDisabled(); @@ -84,7 +96,6 @@ describe("Checkbox", () => { it("does not call onChange when disabled", () => { const onChange = vi.fn(); render(); - fireEvent.click(screen.getByRole("presentation")); expect(onChange).not.toHaveBeenCalled(); }); @@ -99,24 +110,19 @@ describe("Checkbox", () => { expect(screen.getByRole("checkbox")).toHaveAttribute("name", "test-name"); }); - it("passes through additional props", () => { + it("passes through additional props to the input", () => { render(); - expect(screen.getByRole("checkbox")).toHaveAttribute( - "data-testid", - "extra-prop", - ); + expect(screen.getByRole("checkbox")).toHaveAttribute("data-testid", "extra-prop"); }); - it("toggles checked state correctly", () => { + it("uncontrolled: toggles checked state when clicking the visual box", () => { render(); const checkbox = screen.getByRole("checkbox"); const presentation = screen.getByRole("presentation"); expect(checkbox).not.toBeChecked(); - fireEvent.click(presentation); expect(checkbox).toBeChecked(); - fireEvent.click(presentation); expect(checkbox).not.toBeChecked(); }); diff --git a/packages/web/src/components/UI/Checkbox/index.tsx b/packages/web/src/components/UI/Checkbox/index.tsx index eaed84ea..c18f6241 100644 --- a/packages/web/src/components/UI/Checkbox/index.tsx +++ b/packages/web/src/components/UI/Checkbox/index.tsx @@ -1,9 +1,10 @@ import { cn } from "@core/utils/cn.ts"; import { Check } from "lucide-react"; -import { useId } from "react"; +import { useId, useState } from "react"; interface CheckboxProps { checked?: boolean; + defaultChecked?: boolean; onChange?: (checked: boolean) => void; className?: string; labelClassName?: string; @@ -15,7 +16,8 @@ interface CheckboxProps { } export function Checkbox({ - checked = false, + checked, + defaultChecked = false, onChange, className, id: propId, @@ -28,10 +30,21 @@ export function Checkbox({ const generatedId = useId(); const id = propId || generatedId; - const handleToggle = (): void => { - if (!disabled) { - onChange?.(!checked); + const isControlled = checked !== undefined; + const [internal, setInternal] = useState(defaultChecked); + const value = checked ?? internal; + + const handleToggle = (e: React.ChangeEvent) => { + if (disabled) { + return; + } + + const next = e.target.checked; + + if (!isControlled) { + setInternal(next); } + onChange?.(next); }; return ( @@ -41,11 +54,12 @@ export function Checkbox({ disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer", className, )} + data-testid="label-component" > - {checked && ( + {value && (
diff --git a/packages/web/src/components/generic/Filter/FilterComponents.tsx b/packages/web/src/components/generic/Filter/FilterComponents.tsx index ceec2fa1..509b76c7 100644 --- a/packages/web/src/components/generic/Filter/FilterComponents.tsx +++ b/packages/web/src/components/generic/Filter/FilterComponents.tsx @@ -170,6 +170,7 @@ export const FilterMulti = >({ key={val} checked={selected.includes(val)} onChange={(checked) => toggleValue(val, checked)} + className="flex items-center gap-2" > {getLabel(val)}
diff --git a/packages/web/src/core/stores/deviceStore.mock.ts b/packages/web/src/core/stores/deviceStore.mock.ts index 22523511..8fa05670 100644 --- a/packages/web/src/core/stores/deviceStore.mock.ts +++ b/packages/web/src/core/stores/deviceStore.mock.ts @@ -36,7 +36,6 @@ export const mockDeviceStore: Device = { QR: false, shutdown: false, reboot: false, - rebootOTA: false, deviceName: false, nodeRemoval: false, pkiBackup: false, @@ -92,6 +91,6 @@ export const mockDeviceStore: Device = { addClientNotification: vi.fn(), removeClientNotification: vi.fn(), getClientNotification: vi.fn(), - getAllUnreadCount: vi.fn(), - getUnreadCount: vi.fn(), + getAllUnreadCount: vi.fn().mockReturnValue(0), + getUnreadCount: vi.fn().mockReturnValue(0), }; diff --git a/packages/web/src/core/stores/deviceStore.ts b/packages/web/src/core/stores/deviceStore.ts index 8a561b16..9ee04008 100644 --- a/packages/web/src/core/stores/deviceStore.ts +++ b/packages/web/src/core/stores/deviceStore.ts @@ -54,7 +54,6 @@ export interface Device { QR: boolean; shutdown: boolean; reboot: boolean; - rebootOTA: boolean; deviceName: boolean; nodeRemoval: boolean; pkiBackup: boolean; @@ -181,7 +180,6 @@ export const useDeviceStore = createStore((set, get) => ({ nodeDetails: false, unsafeRoles: false, refreshKeys: false, - rebootOTA: false, deleteMessages: false, managedMode: false, clientNotification: false, diff --git a/packages/web/src/core/utils/deepCompareConfig.test.ts b/packages/web/src/core/utils/deepCompareConfig.test.ts index b504d2b7..db664f41 100644 --- a/packages/web/src/core/utils/deepCompareConfig.test.ts +++ b/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], 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); + }); }); diff --git a/packages/web/src/core/utils/deepCompareConfig.ts b/packages/web/src/core/utils/deepCompareConfig.ts index aca12452..c1f5f5f1 100644 --- a/packages/web/src/core/utils/deepCompareConfig.ts +++ b/packages/web/src/core/utils/deepCompareConfig.ts @@ -2,6 +2,22 @@ function isObject(value: unknown): value is Record { 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( a: unknown, b: unknown, @@ -11,7 +27,10 @@ export function deepCompareConfig( 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)) { return true; }