From 8baa5d84b9d46f62125a17825d68cb1dfe3fc313 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Fri, 28 Mar 2025 12:12:56 -0400 Subject: [PATCH 1/4] added reboot to command menu --- src/components/CommandPalette.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index 54659fcb..a25d6dd7 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -32,6 +32,7 @@ import { XCircleIcon, } from "lucide-react"; import { useEffect } from "react"; +import { RebootDialog } from "@components/Dialog/RebootDialog.tsx"; export interface Group { label: string; @@ -117,7 +118,7 @@ export const CommandPalette = () => { return { label: device.nodes.get(device.hardware.myNodeNum)?.user?.longName ?? - device.hardware.myNodeNum.toString(), + device.hardware.myNodeNum.toString(), icon: ( { removeDevice(selectedDevice ?? 0); }, }, + { + label: "Reboot", + icon: PowerIcon, + action() { + connection?.reboot(0); + }, + }, { label: "Schedule Shutdown", icon: PowerIcon, From 35aabdc90099e3604dd8575982d98f9b8af98456 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Fri, 28 Mar 2025 21:06:37 -0400 Subject: [PATCH 2/4] feat: added reboot to OTA in command menu --- .../index.tsx} | 116 +++++++++++------- src/components/Dialog/DialogManager.tsx | 7 ++ .../Dialog/RebootOTADialog.test.tsx | 114 +++++++++++++++++ src/components/Dialog/RebootOTADialog.tsx | 104 ++++++++++++++++ src/components/UI/Command.tsx | 2 +- src/core/hooks/usePinnedItems.tsx | 19 +++ src/core/stores/deviceStore.ts | 3 + vitest.config.ts | 2 +- 8 files changed, 320 insertions(+), 47 deletions(-) rename src/components/{CommandPalette.tsx => CommandPalette/index.tsx} (69%) create mode 100644 src/components/Dialog/RebootOTADialog.test.tsx create mode 100644 src/components/Dialog/RebootOTADialog.tsx create mode 100644 src/core/hooks/usePinnedItems.tsx diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette/index.tsx similarity index 69% rename from src/components/CommandPalette.tsx rename to src/components/CommandPalette/index.tsx index a25d6dd7..185a1ee6 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette/index.tsx @@ -1,4 +1,3 @@ -import { Avatar } from "./UI/Avatar.tsx"; import { CommandDialog, CommandEmpty, @@ -18,7 +17,6 @@ import { FactoryIcon, LayersIcon, LinkIcon, - type LucideIcon, MapIcon, MessageSquareIcon, PlusIcon, @@ -29,10 +27,13 @@ import { SmartphoneIcon, TrashIcon, UsersIcon, - XCircleIcon, + Pin, + type LucideIcon, } from "lucide-react"; import { useEffect } from "react"; -import { RebootDialog } from "@components/Dialog/RebootDialog.tsx"; +import { Avatar } from "@components/UI/Avatar.tsx"; +import { cn } from "@core/utils/cn.ts"; +import { usePinnedItems } from "@core/hooks/usePinnedItems.tsx"; export interface Group { label: string; @@ -46,7 +47,6 @@ export interface Command { subItems?: SubItem[]; tags?: string[]; } - export interface SubItem { label: string; icon: React.ReactNode; @@ -58,11 +58,10 @@ export const CommandPalette = () => { commandPaletteOpen, setCommandPaletteOpen, setSelectedDevice, - removeDevice, - selectedDevice, } = useAppStore(); const { getDevices } = useDeviceStore(); const { setDialogOpen, setActivePage, connection } = useDevice(); + const { pinnedItems, togglePinnedItem } = usePinnedItems({ storageName: 'pinnedCommandMenuGroups' }); const groups: Group[] = [ { @@ -114,22 +113,22 @@ export const CommandPalette = () => { { label: "Switch Node", icon: ArrowLeftRightIcon, - subItems: getDevices().map((device) => { - return { - label: - device.nodes.get(device.hardware.myNodeNum)?.user?.longName ?? - device.hardware.myNodeNum.toString(), - icon: ( - - ), - action() { - setSelectedDevice(device.id); - }, - }; - }), + subItems: getDevices().map((device) => ({ + label: + device.nodes.get(device.hardware.myNodeNum)?.user?.longName ?? + device.hardware.myNodeNum.toString(), + icon: ( + + ), + action() { + setSelectedDevice(device.id); + }, + })), }, { label: "Connect New Node", @@ -164,22 +163,6 @@ export const CommandPalette = () => { }, ], }, - { - label: "Disconnect", - icon: XCircleIcon, - action() { - void connection?.disconnect(); - setSelectedDevice(0); - removeDevice(selectedDevice ?? 0); - }, - }, - { - label: "Reboot", - icon: PowerIcon, - action() { - connection?.reboot(0); - }, - }, { label: "Schedule Shutdown", icon: PowerIcon, @@ -194,6 +177,13 @@ export const CommandPalette = () => { setDialogOpen("reboot", true); }, }, + { + label: "Reboot To OTA Mode", + icon: RefreshCwIcon, + action() { + setDialogOpen("rebootOTA", true); + }, + }, { label: "Reset Nodes", icon: TrashIcon, @@ -239,6 +229,12 @@ export const CommandPalette = () => { }, ]; + const sortedGroups = [...groups].sort((a, b) => { + const aPinned = pinnedItems.includes(a.label) ? 1 : 0; + const bPinned = pinnedItems.includes(b.label) ? 1 : 0; + return bPinned - aPinned; + }); + useEffect(() => { const handleKeydown = (e: KeyboardEvent) => { if (e.key === "k" && (e.metaKey || e.ctrlKey)) { @@ -252,15 +248,45 @@ export const CommandPalette = () => { }, [setCommandPaletteOpen]); return ( - + No results found. - {groups.map((group) => ( - + {sortedGroups.map((group) => ( + + {group.label} + + + } + > {group.commands.map((command) => (
{ const { channels, config, dialog, setDialogOpen } = useDevice(); @@ -77,6 +78,12 @@ export const DialogManager = () => { setDialogOpen("refreshKeys", open); }} /> + { + setDialogOpen("rebootOTA", open); + }} + /> ); }; diff --git a/src/components/Dialog/RebootOTADialog.test.tsx b/src/components/Dialog/RebootOTADialog.test.tsx new file mode 100644 index 00000000..5dc3726f --- /dev/null +++ b/src/components/Dialog/RebootOTADialog.test.tsx @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { RebootOTADialog } from './RebootOTADialog.tsx'; +import { ReactNode } from "react"; + +const rebootOtaMock = vi.fn(); +let mockConnection: { rebootOta: (delay: number) => void } | undefined = { + rebootOta: rebootOtaMock, +}; + +vi.mock('@core/stores/deviceStore.ts', () => ({ + useDevice: () => ({ + connection: mockConnection, + }), +})); + +vi.mock('@components/UI/Button.tsx', async () => { + const actual = await vi.importActual('@components/UI/Button.tsx'); + return { + ...actual, + Button: (props: any) => +
+ + + + + ); +}; + diff --git a/src/components/UI/Command.tsx b/src/components/UI/Command.tsx index 3173179c..d230d326 100644 --- a/src/components/UI/Command.tsx +++ b/src/components/UI/Command.tsx @@ -116,7 +116,7 @@ const CommandItem = React.forwardRef< (storageName, []); + + const togglePinnedItem = useCallback((label: string) => { + setPinnedItems((prev) => + prev.includes(label) + ? prev.filter((g) => g !== label) + : [...prev, label] + ); + }, []); + + return { + pinnedItems, + togglePinnedItem, + }; +} diff --git a/src/core/stores/deviceStore.ts b/src/core/stores/deviceStore.ts index 266ff9c8..c6e74551 100644 --- a/src/core/stores/deviceStore.ts +++ b/src/core/stores/deviceStore.ts @@ -23,6 +23,7 @@ export type DialogVariant = | "QR" | "shutdown" | "reboot" + | "rebootOTA" | "deviceName" | "nodeRemoval" | "pkiBackup" @@ -73,6 +74,7 @@ export interface Device { QR: boolean; shutdown: boolean; reboot: boolean; + rebootOTA: boolean; deviceName: boolean; nodeRemoval: boolean; pkiBackup: boolean; @@ -175,6 +177,7 @@ export const useDeviceStore = createStore((set, get) => ({ nodeDetails: false, unsafeRoles: false, refreshKeys: false, + rebootOTA: false, }, pendingSettingsChanges: false, messageDraft: "", diff --git a/vitest.config.ts b/vitest.config.ts index d4542ae3..cbf600bc 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,9 +9,9 @@ export default defineConfig({ resolve: { alias: { '@app': path.resolve(process.cwd(), './src'), + '@core': path.resolve(process.cwd(), './src/core'), '@pages': path.resolve(process.cwd(), './src/pages'), '@components': path.resolve(process.cwd(), './src/components'), - '@core': path.resolve(process.cwd(), './src/core'), '@layouts': path.resolve(process.cwd(), './src/layouts'), }, }, From 6d9a44a0e39a72026049d3c415eb8643da498033 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Fri, 28 Mar 2025 21:11:01 -0400 Subject: [PATCH 3/4] added tests --- src/components/CommandPalette/index.tsx | 2 +- src/core/hooks/usePinnedItems.test.ts | 65 +++++++++++++++++++ .../{usePinnedItems.tsx => usePinnedItems.ts} | 0 3 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 src/core/hooks/usePinnedItems.test.ts rename src/core/hooks/{usePinnedItems.tsx => usePinnedItems.ts} (100%) diff --git a/src/components/CommandPalette/index.tsx b/src/components/CommandPalette/index.tsx index 185a1ee6..289f07fc 100644 --- a/src/components/CommandPalette/index.tsx +++ b/src/components/CommandPalette/index.tsx @@ -33,7 +33,7 @@ import { import { useEffect } from "react"; import { Avatar } from "@components/UI/Avatar.tsx"; import { cn } from "@core/utils/cn.ts"; -import { usePinnedItems } from "@core/hooks/usePinnedItems.tsx"; +import { usePinnedItems } from "@core/hooks/usePinnedItems.ts"; export interface Group { label: string; diff --git a/src/core/hooks/usePinnedItems.test.ts b/src/core/hooks/usePinnedItems.test.ts new file mode 100644 index 00000000..d761fba1 --- /dev/null +++ b/src/core/hooks/usePinnedItems.test.ts @@ -0,0 +1,65 @@ +import { renderHook, act } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { usePinnedItems } from "./usePinnedItems.ts"; + +const mockSetPinnedItems = vi.fn(); +const mockUseLocalStorage = vi.fn(); + +vi.mock("@core/hooks/useLocalStorage.ts", () => ({ + default: (...args: any[]) => mockUseLocalStorage(...args), +})); + +describe("usePinnedItems", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns default pinnedItems and togglePinnedItem", () => { + mockUseLocalStorage.mockReturnValue([[], mockSetPinnedItems]); + + const { result } = renderHook(() => + usePinnedItems({ storageName: "test-storage" }) + ); + + expect(result.current.pinnedItems).toEqual([]); + expect(typeof result.current.togglePinnedItem).toBe("function"); + }); + + it("adds an item if it's not already pinned", () => { + mockUseLocalStorage.mockReturnValue([["item1"], mockSetPinnedItems]); + + const { result } = renderHook(() => + usePinnedItems({ storageName: "test-storage" }) + ); + + act(() => { + result.current.togglePinnedItem("item2"); + }); + + expect(mockSetPinnedItems).toHaveBeenCalledWith(expect.any(Function)); + + const updater = mockSetPinnedItems.mock.calls[0][0]; + const updated = updater(["item1"]); + + expect(updated).toEqual(["item1", "item2"]); + }); + + it("removes an item if it's already pinned", () => { + mockUseLocalStorage.mockReturnValue([["item1", "item2"], mockSetPinnedItems]); + + const { result } = renderHook(() => + usePinnedItems({ storageName: "test-storage" }) + ); + + act(() => { + result.current.togglePinnedItem("item1"); + }); + + expect(mockSetPinnedItems).toHaveBeenCalledWith(expect.any(Function)); + + const updater = mockSetPinnedItems.mock.calls[0][0]; + const updated = updater(["item1", "item2"]); + + expect(updated).toEqual(["item2"]); + }); +}); diff --git a/src/core/hooks/usePinnedItems.tsx b/src/core/hooks/usePinnedItems.ts similarity index 100% rename from src/core/hooks/usePinnedItems.tsx rename to src/core/hooks/usePinnedItems.ts From bf9557040fc94faf538f1196d5d121229a9839d3 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Fri, 28 Mar 2025 21:12:13 -0400 Subject: [PATCH 4/4] fixed: import issue --- src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index 29e80aa1..35152fd1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,5 @@ import { DeviceWrapper } from "@app/DeviceWrapper.tsx"; import { PageRouter } from "@app/PageRouter.tsx"; -import { CommandPalette } from "@components/CommandPalette.tsx"; import { DeviceSelector } from "@components/DeviceSelector.tsx"; import { DialogManager } from "@components/Dialog/DialogManager.tsx"; import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx"; @@ -14,6 +13,7 @@ import type { JSX } from "react"; import { ErrorBoundary } from "react-error-boundary"; import { ErrorPage } from "@components/UI/ErrorPage.tsx"; import { MapProvider } from "react-map-gl/maplibre"; +import { CommandPalette } from "@components/CommandPalette/index.tsx"; export const App = (): JSX.Element => {