From 8cc451546df0d687fe3528a5768167807f102753 Mon Sep 17 00:00:00 2001 From: Jeremy Gallant <8975765+philon-@users.noreply.github.com> Date: Thu, 18 Sep 2025 14:13:19 +0200 Subject: [PATCH] Command Menu improvements (#857) * Add dialogs for reset and clear actions + clear stores * Catch failures * Improve tests, don't reset store on failure * Remove unnecessary i18n string --------- Co-authored-by: philon- --- .../i18n/locales/en/commandPalette.json | 4 +- .../web/public/i18n/locales/en/dialog.json | 24 +++++ .../src/components/CommandPalette/index.tsx | 27 +++--- .../ClearAllStoresDialog.test.tsx | 69 ++++++++++++++ .../ClearAllStoresDialog.tsx | 35 +++++++ .../src/components/Dialog/DialogManager.tsx | 28 ++++++ .../FactoryResetConfigDialog.test.tsx | 63 ++++++++++++ .../FactoryResetConfigDialog.tsx | 39 ++++++++ .../FactoryResetDeviceDialog.test.tsx | 94 ++++++++++++++++++ .../FactoryResetDeviceDialog.tsx | 48 ++++++++++ .../ResetNodeDbDialog.test.tsx | 95 +++++++++++++++++++ .../ResetNodeDbDialog/ResetNodeDbDialog.tsx | 48 ++++++++++ .../web/src/core/stores/deviceStore/index.ts | 8 ++ packages/web/src/core/stores/index.ts | 3 + 14 files changed, 567 insertions(+), 18 deletions(-) create mode 100644 packages/web/src/components/Dialog/ClearAllStoresDialog/ClearAllStoresDialog.test.tsx create mode 100644 packages/web/src/components/Dialog/ClearAllStoresDialog/ClearAllStoresDialog.tsx create mode 100644 packages/web/src/components/Dialog/FactoryResetConfigDialog/FactoryResetConfigDialog.test.tsx create mode 100644 packages/web/src/components/Dialog/FactoryResetConfigDialog/FactoryResetConfigDialog.tsx create mode 100644 packages/web/src/components/Dialog/FactoryResetDeviceDialog/FactoryResetDeviceDialog.test.tsx create mode 100644 packages/web/src/components/Dialog/FactoryResetDeviceDialog/FactoryResetDeviceDialog.tsx create mode 100644 packages/web/src/components/Dialog/ResetNodeDbDialog/ResetNodeDbDialog.test.tsx create mode 100644 packages/web/src/components/Dialog/ResetNodeDbDialog/ResetNodeDbDialog.tsx diff --git a/packages/web/public/i18n/locales/en/commandPalette.json b/packages/web/public/i18n/locales/en/commandPalette.json index 8c267e29..665adbf3 100644 --- a/packages/web/public/i18n/locales/en/commandPalette.json +++ b/packages/web/public/i18n/locales/en/commandPalette.json @@ -15,7 +15,6 @@ "messages": "Messages", "map": "Map", "config": "Config", - "channels": "Channels", "nodes": "Nodes" } }, @@ -45,7 +44,8 @@ "label": "Debug", "command": { "reconfigure": "Reconfigure", - "clearAllStoredMessages": "Clear All Stored Message" + "clearAllStoredMessages": "Clear All Stored Messages", + "clearAllStores": "Clear All Local Storage" } } } diff --git a/packages/web/public/i18n/locales/en/dialog.json b/packages/web/public/i18n/locales/en/dialog.json index aba3ed59..4ffc3ac2 100644 --- a/packages/web/public/i18n/locales/en/dialog.json +++ b/packages/web/public/i18n/locales/en/dialog.json @@ -194,5 +194,29 @@ "title": "Client Notification", "TraceRoute can only be sent once every 30 seconds": "TraceRoute can only be sent once every 30 seconds", "Compromised keys were detected and regenerated.": "Compromised keys were detected and regenerated." + }, + "resetNodeDb": { + "title": "Reset Node Database", + "description": "This will clear all nodes from the connected device's node database and clear all message history in the client. This cannot be undone. Are you sure you want to continue?", + "confirm": "Reset Node Database", + "failedTitle": "There was an error resetting the Node DB. Please try again." + }, + "clearAllStores": { + "title": "Clear All Local Storage", + "description": "This will clear all locally stored data, including message history and node databases for all previously connected devices. This will require you to reconnect to your node once complete and cannot be undone. Are you sure you want to continue?", + "confirm": "Clear all local storage", + "failedTitle": "There was an error clearing local storage. Please try again." + }, + "factoryResetDevice": { + "title": "Factory Reset Device", + "description": "This will factory reset the connected device, erasing all configurations and data on the device as well as all nodes and messages saved in the client. This cannot be undone. Are you sure you want to continue?", + "confirm": "Factory Reset Device", + "failedTitle": "There was an error performing the factory reset. Please try again." + }, + "factoryResetConfig": { + "title": "Factory Reset Config", + "description": "This will factory reset the configuration on the connected device, erasing all configurations on the device. This cannot be undone. Are you sure you want to continue?", + "confirm": "Factory Reset Config", + "failedTitle": "There was an error performing the factory reset. Please try again." } } diff --git a/packages/web/src/components/CommandPalette/index.tsx b/packages/web/src/components/CommandPalette/index.tsx index e3e2dc63..ba53c980 100644 --- a/packages/web/src/components/CommandPalette/index.tsx +++ b/packages/web/src/components/CommandPalette/index.tsx @@ -25,7 +25,6 @@ import { EraserIcon, FactoryIcon, HardDriveUpload, - LayersIcon, LinkIcon, type LucideIcon, MapIcon, @@ -71,7 +70,7 @@ export const CommandPalette = () => { } = useAppStore(); const { getDevices } = useDeviceStore(); const { setDialogOpen, connection } = useDevice(); - const { getNode, removeAllNodeErrors, removeAllNodes } = useNodeDB(); + const { getNode } = useNodeDB(); const { pinnedItems, togglePinnedItem } = usePinnedItems({ storageName: "pinnedCommandMenuGroups", }); @@ -106,13 +105,6 @@ export const CommandPalette = () => { }, tags: ["settings"], }, - { - label: t("goto.command.channels"), - icon: LayersIcon, - action() { - navigate({ to: "/channels" }); - }, - }, { label: t("goto.command.nodes"), icon: UsersIcon, @@ -206,9 +198,7 @@ export const CommandPalette = () => { label: t("contextual.command.resetNodeDb"), icon: TrashIcon, action() { - connection?.resetNodes(); - removeAllNodeErrors(); - removeAllNodes(true); + setDialogOpen("resetNodeDb", true); }, }, { @@ -224,16 +214,14 @@ export const CommandPalette = () => { label: t("contextual.command.factoryResetDevice"), icon: FactoryIcon, action() { - connection?.factoryResetDevice(); - removeAllNodeErrors(); - removeAllNodes(); + setDialogOpen("factoryResetDevice", true); }, }, { label: t("contextual.command.factoryResetConfig"), icon: FactoryIcon, action() { - connection?.factoryResetConfig(); + setDialogOpen("factoryResetConfig", true); }, }, ], @@ -257,6 +245,13 @@ export const CommandPalette = () => { setDialogOpen("deleteMessages", true); }, }, + { + label: t("debug.command.clearAllStores"), + icon: EraserIcon, + action() { + setDialogOpen("clearAllStores", true); + }, + }, ], }, ]; diff --git a/packages/web/src/components/Dialog/ClearAllStoresDialog/ClearAllStoresDialog.test.tsx b/packages/web/src/components/Dialog/ClearAllStoresDialog/ClearAllStoresDialog.test.tsx new file mode 100644 index 00000000..50e6af3d --- /dev/null +++ b/packages/web/src/components/Dialog/ClearAllStoresDialog/ClearAllStoresDialog.test.tsx @@ -0,0 +1,69 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ClearAllStoresDialog } from "./ClearAllStoresDialog.tsx"; + +const mockClearAllStores = vi.fn(); + +vi.mock("@core/stores", () => ({ + CurrentDeviceContext: { + _currentValue: { deviceId: 1234 }, + }, + clearAllStores: () => mockClearAllStores(), +})); + +describe("ClearAllStoresDialog", () => { + const mockOnOpenChange = vi.fn(); + + // Capture window.location.href assignment without triggering real navigation + const originalLocation = window.location; + let assignedHref: string | undefined; + + beforeEach(() => { + mockOnOpenChange.mockClear(); + mockClearAllStores.mockClear(); + assignedHref = undefined; + + Object.defineProperty(window, "location", { + configurable: true, + value: { + ...originalLocation, + get href() { + return originalLocation.href; + }, + set href(val: string) { + assignedHref = val; + }, + }, + }); + }); + + // restore the real location object after each test + afterEach(() => { + Object.defineProperty(window, "location", { + configurable: true, + value: originalLocation, + }); + }); + + it("calls clearAllStores and navigates to '/' when confirm is clicked", () => { + render(); + fireEvent.click( + screen.getByRole("button", { name: "Clear all local storage" }), + ); + + expect(mockClearAllStores).toHaveBeenCalledTimes(1); + expect(assignedHref).toBe("/"); // forced reload target + // We reload instead of toggling the dialog, so ensure we didn't call onOpenChange + expect(mockOnOpenChange).not.toHaveBeenCalled(); + }); + + it("calls onOpenChange with false when cancel is clicked", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + + expect(mockClearAllStores).not.toHaveBeenCalled(); + expect(assignedHref).toBeUndefined(); // no navigation + expect(mockOnOpenChange).toHaveBeenCalledTimes(1); + expect(mockOnOpenChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/packages/web/src/components/Dialog/ClearAllStoresDialog/ClearAllStoresDialog.tsx b/packages/web/src/components/Dialog/ClearAllStoresDialog/ClearAllStoresDialog.tsx new file mode 100644 index 00000000..731e0e8c --- /dev/null +++ b/packages/web/src/components/Dialog/ClearAllStoresDialog/ClearAllStoresDialog.tsx @@ -0,0 +1,35 @@ +import { clearAllStores } from "@core/stores"; +import { useTranslation } from "react-i18next"; +import { DialogWrapper } from "../DialogWrapper.tsx"; + +export interface ClearAllStoresDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const ClearAllStoresDialog = ({ + open, + onOpenChange, +}: ClearAllStoresDialogProps) => { + const { t } = useTranslation("dialog"); + + const handleClearAllStores = () => { + clearAllStores(); + + // Reload the app to ensure all state is cleared + window.location.href = "/"; + }; + + return ( + + ); +}; diff --git a/packages/web/src/components/Dialog/DialogManager.tsx b/packages/web/src/components/Dialog/DialogManager.tsx index 48af8290..484cc261 100644 --- a/packages/web/src/components/Dialog/DialogManager.tsx +++ b/packages/web/src/components/Dialog/DialogManager.tsx @@ -1,3 +1,6 @@ +import { FactoryResetConfigDialog } from "@app/components/Dialog/FactoryResetConfigDialog/FactoryResetConfigDialog"; +import { FactoryResetDeviceDialog } from "@app/components/Dialog/FactoryResetDeviceDialog/FactoryResetDeviceDialog"; +import { ClearAllStoresDialog } from "@components/Dialog/ClearAllStoresDialog/ClearAllStoresDialog.tsx"; import { ClientNotificationDialog } from "@components/Dialog/ClientNotificationDialog/ClientNotificationDialog.tsx"; import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx"; import { DeviceNameDialog } from "@components/Dialog/DeviceNameDialog.tsx"; @@ -8,6 +11,7 @@ import { QRDialog } from "@components/Dialog/QRDialog.tsx"; import { RebootDialog } from "@components/Dialog/RebootDialog.tsx"; import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx"; import { RemoveNodeDialog } from "@components/Dialog/RemoveNodeDialog.tsx"; +import { ResetNodeDbDialog } from "@components/Dialog/ResetNodeDbDialog/ResetNodeDbDialog.tsx"; import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx"; import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx"; import { useDevice } from "@core/stores"; @@ -91,6 +95,30 @@ export const DialogManager = () => { setDialogOpen("clientNotification", open); }} /> + { + setDialogOpen("resetNodeDb", open); + }} + /> + { + setDialogOpen("clearAllStores", open); + }} + /> + { + setDialogOpen("factoryResetDevice", open); + }} + /> + { + setDialogOpen("factoryResetConfig", open); + }} + /> ); }; diff --git a/packages/web/src/components/Dialog/FactoryResetConfigDialog/FactoryResetConfigDialog.test.tsx b/packages/web/src/components/Dialog/FactoryResetConfigDialog/FactoryResetConfigDialog.test.tsx new file mode 100644 index 00000000..ec205e93 --- /dev/null +++ b/packages/web/src/components/Dialog/FactoryResetConfigDialog/FactoryResetConfigDialog.test.tsx @@ -0,0 +1,63 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { FactoryResetConfigDialog } from "./FactoryResetConfigDialog.tsx"; + +const mockFactoryReset = vi.fn(); +const mockToast = vi.fn(); + +vi.mock("@core/stores", () => ({ + CurrentDeviceContext: { + _currentValue: { deviceId: 1234 }, + }, + useDevice: () => ({ + connection: { + factoryResetConfig: mockFactoryReset, + }, + }), +})); + +vi.mock("@core/hooks/useToast.ts", () => ({ + toast: (...args: unknown[]) => mockToast(...args), +})); + +describe("FactoryResetConfigDialog", () => { + const mockOnOpenChange = vi.fn(); + + beforeEach(() => { + mockOnOpenChange.mockClear(); + mockFactoryReset.mockReset(); + mockToast.mockClear(); + mockFactoryReset.mockResolvedValue(undefined); + }); + + it("calls factoryResetConfig and then closes the dialog on confirm", async () => { + render(); + + fireEvent.click( + screen.getByRole("button", { name: "Factory Reset Config" }), + ); + + expect(mockFactoryReset).toHaveBeenCalledTimes(1); + + await waitFor(() => { + expect(mockOnOpenChange).toHaveBeenCalledTimes(1); + expect(mockOnOpenChange).toHaveBeenCalledWith(false); + }); + + // success path: no toast + expect(mockToast).not.toHaveBeenCalled(); + }); + + it("calls onOpenChange(false) and does not call factoryResetConfig when cancel is clicked", async () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + + await waitFor(() => { + expect(mockOnOpenChange).toHaveBeenCalledTimes(1); + expect(mockOnOpenChange).toHaveBeenCalledWith(false); + }); + + expect(mockFactoryReset).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web/src/components/Dialog/FactoryResetConfigDialog/FactoryResetConfigDialog.tsx b/packages/web/src/components/Dialog/FactoryResetConfigDialog/FactoryResetConfigDialog.tsx new file mode 100644 index 00000000..b350447d --- /dev/null +++ b/packages/web/src/components/Dialog/FactoryResetConfigDialog/FactoryResetConfigDialog.tsx @@ -0,0 +1,39 @@ +import { toast } from "@core/hooks/useToast.ts"; +import { useDevice } from "@core/stores"; +import { useTranslation } from "react-i18next"; +import { DialogWrapper } from "../DialogWrapper.tsx"; + +export interface FactoryResetConfigDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const FactoryResetConfigDialog = ({ + open, + onOpenChange, +}: FactoryResetConfigDialogProps) => { + const { t } = useTranslation("dialog"); + const { connection } = useDevice(); + + const handleFactoryResetConfig = () => { + connection?.factoryResetConfig().catch((error) => { + toast({ + title: t("factoryResetConfig.failedTitle"), + }); + console.error("Failed to factory reset config:", error); + }); + }; + + return ( + + ); +}; diff --git a/packages/web/src/components/Dialog/FactoryResetDeviceDialog/FactoryResetDeviceDialog.test.tsx b/packages/web/src/components/Dialog/FactoryResetDeviceDialog/FactoryResetDeviceDialog.test.tsx new file mode 100644 index 00000000..848e802f --- /dev/null +++ b/packages/web/src/components/Dialog/FactoryResetDeviceDialog/FactoryResetDeviceDialog.test.tsx @@ -0,0 +1,94 @@ +// FactoryResetDeviceDialog.test.tsx +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { FactoryResetDeviceDialog } from "./FactoryResetDeviceDialog.tsx"; + +const mockFactoryResetDevice = vi.fn(); +const mockDeleteAllMessages = vi.fn(); +const mockRemoveAllNodeErrors = vi.fn(); +const mockRemoveAllNodes = vi.fn(); + +vi.mock("@core/stores", () => ({ + CurrentDeviceContext: { + _currentValue: { deviceId: 1234 }, + }, + useDevice: () => ({ + connection: { + factoryResetDevice: mockFactoryResetDevice, + }, + }), + useMessages: () => ({ + deleteAllMessages: mockDeleteAllMessages, + }), + useNodeDB: () => ({ + removeAllNodeErrors: mockRemoveAllNodeErrors, + removeAllNodes: mockRemoveAllNodes, + }), +})); + +describe("FactoryResetDeviceDialog", () => { + const mockOnOpenChange = vi.fn(); + + beforeEach(() => { + mockOnOpenChange.mockClear(); + mockFactoryResetDevice.mockClear(); + mockDeleteAllMessages.mockClear(); + mockRemoveAllNodeErrors.mockClear(); + mockRemoveAllNodes.mockClear(); + }); + + it("calls factoryResetDevice, closes dialog, and after reset resolves clears messages and node DB", async () => { + // Control the promise returned by factoryResetDevice + let resolveReset: (() => void) | undefined; + mockFactoryResetDevice.mockImplementation( + () => + new Promise((resolve) => { + resolveReset = resolve; + }), + ); + + render(); + fireEvent.click( + screen.getByRole("button", { name: "Factory Reset Device" }), + ); + + // Called immediately + expect(mockFactoryResetDevice).toHaveBeenCalledTimes(1); + + // DialogWrapper awaits onConfirm (which returns undefined), so close happens on next microtask + await waitFor(() => { + expect(mockOnOpenChange).toHaveBeenCalledTimes(1); + expect(mockOnOpenChange).toHaveBeenCalledWith(false); + }); + + // Nothing else should have happened yet (the promise hasn't resolved) + expect(mockDeleteAllMessages).not.toHaveBeenCalled(); + expect(mockRemoveAllNodeErrors).not.toHaveBeenCalled(); + expect(mockRemoveAllNodes).not.toHaveBeenCalled(); + + // Resolve the reset + resolveReset?.(); + + // Now the .then() chain should fire + await waitFor(() => { + expect(mockDeleteAllMessages).toHaveBeenCalledTimes(1); + expect(mockRemoveAllNodeErrors).toHaveBeenCalledTimes(1); + expect(mockRemoveAllNodes).toHaveBeenCalledTimes(1); + }); + }); + + it("calls onOpenChange(false) and does not call factoryResetDevice when cancel is clicked", async () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + + await waitFor(() => { + expect(mockOnOpenChange).toHaveBeenCalledTimes(1); + expect(mockOnOpenChange).toHaveBeenCalledWith(false); + }); + + expect(mockFactoryResetDevice).not.toHaveBeenCalled(); + expect(mockDeleteAllMessages).not.toHaveBeenCalled(); + expect(mockRemoveAllNodeErrors).not.toHaveBeenCalled(); + expect(mockRemoveAllNodes).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web/src/components/Dialog/FactoryResetDeviceDialog/FactoryResetDeviceDialog.tsx b/packages/web/src/components/Dialog/FactoryResetDeviceDialog/FactoryResetDeviceDialog.tsx new file mode 100644 index 00000000..a261a025 --- /dev/null +++ b/packages/web/src/components/Dialog/FactoryResetDeviceDialog/FactoryResetDeviceDialog.tsx @@ -0,0 +1,48 @@ +import { toast } from "@core/hooks/useToast.ts"; +import { useDevice, useMessages, useNodeDB } from "@core/stores"; +import { useTranslation } from "react-i18next"; +import { DialogWrapper } from "../DialogWrapper.tsx"; + +export interface FactoryResetDeviceDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const FactoryResetDeviceDialog = ({ + open, + onOpenChange, +}: FactoryResetDeviceDialogProps) => { + const { t } = useTranslation("dialog"); + const { connection } = useDevice(); + const { removeAllNodeErrors, removeAllNodes } = useNodeDB(); + const { deleteAllMessages } = useMessages(); + + const handleFactoryResetDevice = () => { + connection + ?.factoryResetDevice() + .then(() => { + deleteAllMessages(); + removeAllNodeErrors(); + removeAllNodes(); + }) + .catch((error) => { + toast({ + title: t("factoryResetDevice.failedTitle"), + }); + console.error("Failed to factory reset device:", error); + }); + }; + + return ( + + ); +}; diff --git a/packages/web/src/components/Dialog/ResetNodeDbDialog/ResetNodeDbDialog.test.tsx b/packages/web/src/components/Dialog/ResetNodeDbDialog/ResetNodeDbDialog.test.tsx new file mode 100644 index 00000000..0898578d --- /dev/null +++ b/packages/web/src/components/Dialog/ResetNodeDbDialog/ResetNodeDbDialog.test.tsx @@ -0,0 +1,95 @@ +// ResetNodeDbDialog.test.tsx +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ResetNodeDbDialog } from "./ResetNodeDbDialog.tsx"; + +const mockResetNodes = vi.fn(); +const mockDeleteAllMessages = vi.fn(); +const mockRemoveAllNodeErrors = vi.fn(); +const mockRemoveAllNodes = vi.fn(); + +vi.mock("@core/stores", () => ({ + CurrentDeviceContext: { + _currentValue: { deviceId: 1234 }, + }, + useDevice: () => ({ + connection: { + resetNodes: mockResetNodes, + }, + }), + useMessages: () => ({ + deleteAllMessages: mockDeleteAllMessages, + }), + useNodeDB: () => ({ + removeAllNodeErrors: mockRemoveAllNodeErrors, + removeAllNodes: mockRemoveAllNodes, + }), +})); + +describe("ResetNodeDbDialog", () => { + const mockOnOpenChange = vi.fn(); + + beforeEach(() => { + mockOnOpenChange.mockClear(); + mockResetNodes.mockClear(); + mockDeleteAllMessages.mockClear(); + mockRemoveAllNodeErrors.mockClear(); + mockRemoveAllNodes.mockClear(); + }); + + it("calls resetNodes, closes dialog, and after resolve clears messages and node DB (with true flag)", async () => { + // Control the promise returned by resetNodes + let resolveReset: (() => void) | undefined; + mockResetNodes.mockImplementation( + () => + new Promise((resolve) => { + resolveReset = resolve; + }), + ); + + render(); + fireEvent.click( + screen.getByRole("button", { name: "Reset Node Database" }), + ); + + // Called immediately + expect(mockResetNodes).toHaveBeenCalledTimes(1); + + // DialogWrapper awaits onConfirm (which returns undefined), so close happens on next microtask + await waitFor(() => { + expect(mockOnOpenChange).toHaveBeenCalledTimes(1); + expect(mockOnOpenChange).toHaveBeenCalledWith(false); + }); + + // Nothing else should have happened yet (the promise hasn't resolved) + expect(mockDeleteAllMessages).not.toHaveBeenCalled(); + expect(mockRemoveAllNodeErrors).not.toHaveBeenCalled(); + expect(mockRemoveAllNodes).not.toHaveBeenCalled(); + + // Resolve the reset + resolveReset?.(); + + // Now the .then() chain should fire + await waitFor(() => { + expect(mockDeleteAllMessages).toHaveBeenCalledTimes(1); + expect(mockRemoveAllNodeErrors).toHaveBeenCalledTimes(1); + expect(mockRemoveAllNodes).toHaveBeenCalledTimes(1); + expect(mockRemoveAllNodes).toHaveBeenCalledWith(true); + }); + }); + + it("calls onOpenChange(false) and does not call resetNodes when cancel is clicked", async () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + + await waitFor(() => { + expect(mockOnOpenChange).toHaveBeenCalledTimes(1); + expect(mockOnOpenChange).toHaveBeenCalledWith(false); + }); + + expect(mockResetNodes).not.toHaveBeenCalled(); + expect(mockDeleteAllMessages).not.toHaveBeenCalled(); + expect(mockRemoveAllNodeErrors).not.toHaveBeenCalled(); + expect(mockRemoveAllNodes).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web/src/components/Dialog/ResetNodeDbDialog/ResetNodeDbDialog.tsx b/packages/web/src/components/Dialog/ResetNodeDbDialog/ResetNodeDbDialog.tsx new file mode 100644 index 00000000..def4ff4f --- /dev/null +++ b/packages/web/src/components/Dialog/ResetNodeDbDialog/ResetNodeDbDialog.tsx @@ -0,0 +1,48 @@ +import { toast } from "@core/hooks/useToast.ts"; +import { useDevice, useMessages, useNodeDB } from "@core/stores"; +import { useTranslation } from "react-i18next"; +import { DialogWrapper } from "../DialogWrapper.tsx"; + +export interface ResetNodeDbDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const ResetNodeDbDialog = ({ + open, + onOpenChange, +}: ResetNodeDbDialogProps) => { + const { t } = useTranslation("dialog"); + const { connection } = useDevice(); + const { removeAllNodeErrors, removeAllNodes } = useNodeDB(); + const { deleteAllMessages } = useMessages(); + + const handleResetNodeDb = () => { + connection + ?.resetNodes() + .then(() => { + deleteAllMessages(); + removeAllNodeErrors(); + removeAllNodes(true); + }) + .catch((error) => { + toast({ + title: t("resetNodeDb.failedTitle"), + }); + console.error("Failed to reset Node DB:", error); + }); + }; + + return ( + + ); +}; diff --git a/packages/web/src/core/stores/deviceStore/index.ts b/packages/web/src/core/stores/deviceStore/index.ts index 61569f09..d1f21d93 100644 --- a/packages/web/src/core/stores/deviceStore/index.ts +++ b/packages/web/src/core/stores/deviceStore/index.ts @@ -57,6 +57,10 @@ export interface Device { deleteMessages: boolean; managedMode: boolean; clientNotification: boolean; + resetNodeDb: boolean; + clearAllStores: boolean; + factoryResetDevice: boolean; + factoryResetConfig: boolean; }; clientNotifications: Protobuf.Mesh.ClientNotification[]; @@ -166,6 +170,10 @@ export const useDeviceStore = createStore((set, get) => ({ deleteMessages: false, managedMode: false, clientNotification: false, + resetNodeDb: false, + clearAllStores: false, + factoryResetDevice: false, + factoryResetConfig: false, }, pendingSettingsChanges: false, messageDraft: "", diff --git a/packages/web/src/core/stores/index.ts b/packages/web/src/core/stores/index.ts index 077188b7..6a43e296 100644 --- a/packages/web/src/core/stores/index.ts +++ b/packages/web/src/core/stores/index.ts @@ -30,6 +30,9 @@ export { useSidebar, // TODO: Bring hook into this file } from "@core/stores/sidebarStore"; +// Re-export idb-keyval functions for clearing all stores, expand this if we add more local storage types +export { clear as clearAllStores } from "idb-keyval"; + // Define hooks to access the stores export const useNodeDB = bindStoreToDevice( useNodeDBStore,