From 4565c57fa32d05a0ad1f137510dec65e42bf88af Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Wed, 11 Jun 2025 12:00:12 -0400 Subject: [PATCH] fixing more tsc errors --- .github/workflows/pr.yml | 24 +- deno.json | 4 +- .../Messages/TraceRoute.test.tsx | 4 +- src/components/UI/Checkbox/Checkbox.test.tsx | 17 -- .../generic/Filter/useFilterNode.ts | 34 ++- src/components/generic/Table/index.test.tsx | 178 +++++++------- src/components/generic/Table/index.tsx | 206 ++++++---------- src/core/hooks/usePinnedItems.test.ts | 68 ------ src/core/stores/deviceStore.mock.ts | 82 +++++++ src/core/stores/storage/indexDB.ts | 13 +- src/i18n/config.ts | 2 +- src/index.css | 3 +- src/pages/Messages.test.tsx | 81 ------ src/pages/Nodes/index.tsx | 231 ++++++++++-------- src/routeTree.gen.ts | 24 +- 15 files changed, 442 insertions(+), 529 deletions(-) delete mode 100644 src/core/hooks/usePinnedItems.test.ts create mode 100644 src/core/stores/deviceStore.mock.ts delete mode 100644 src/pages/Messages.test.tsx diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 47fe8c23..6b0c64ce 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -24,18 +24,32 @@ jobs: key: ${{ runner.os }}-deno-${{ hashFiles('**/deno.lock') }} restore-keys: | ${{ runner.os }}-deno- - + - name: Install Dependencies run: deno install - name: Cache Dependencies run: deno cache src/index.tsx - - name: Run linter - run: deno task lint + - name: Get changed files + id: changed-files + uses: tj-actions/changed-files@v44 + with: + files: | + **/*.ts + **/*.tsx + + - name: Type check changed files + if: steps.changed-files.outputs.all_changed_files != '' + run: deno check ${{ steps.changed-files.outputs.all_changed_files }} + + - name: Run linter on changed files + if: steps.changed-files.outputs.all_changed_files != '' + run: deno task lint ${{ steps.changed-files.outputs.all_changed_files }} - - name: Check formatter - run: deno task format --check + - name: Check format on changed files + if: steps.changed-files.outputs.all_changed_files != '' + run: deno task format --check ${{ steps.changed-files.outputs.all_changed_files }} - name: Run tests run: deno task test diff --git a/deno.json b/deno.json index 3306d537..0115bab1 100644 --- a/deno.json +++ b/deno.json @@ -32,14 +32,14 @@ }, "fmt": { "exclude": [ - "src/*.gen.ts", + "src/routeTree.gen.ts", "*.test.ts", "*.test.tsx" ] }, "lint": { "exclude": [ - "src/*.gen.ts", + "src/routeTree.gen.ts", "*.test.ts", "*.test.tsx" ], diff --git a/src/components/PageComponents/Messages/TraceRoute.test.tsx b/src/components/PageComponents/Messages/TraceRoute.test.tsx index 2d23cca6..c3d89de3 100644 --- a/src/components/PageComponents/Messages/TraceRoute.test.tsx +++ b/src/components/PageComponents/Messages/TraceRoute.test.tsx @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; +import { mockDeviceStore } from "@core/stores/deviceStore.mock.ts"; import { Protobuf } from "@meshtastic/core"; vi.mock("@core/stores/deviceStore"); @@ -65,10 +66,11 @@ describe("TraceRoute", () => { beforeEach(() => { vi.resetAllMocks(); vi.mocked(useDevice).mockReturnValue({ + ...mockDeviceStore, getNode: (nodeNum: number): Protobuf.Mesh.NodeInfo | undefined => { return mockNodes.get(nodeNum); }, - } as any); + }); }); it("renders the route to destination with SNR values", () => { diff --git a/src/components/UI/Checkbox/Checkbox.test.tsx b/src/components/UI/Checkbox/Checkbox.test.tsx index 4e6bcb20..e7badabe 100644 --- a/src/components/UI/Checkbox/Checkbox.test.tsx +++ b/src/components/UI/Checkbox/Checkbox.test.tsx @@ -23,18 +23,6 @@ vi.mock("@components/UI/Label.tsx", () => ({ ), })); -vi.mock("@core/utils/cn.ts", () => ({ - cn: (...args: any[]) => args.filter(Boolean).join(" "), -})); - -vi.mock("react", async () => { - const actual = await vi.importActual("react"); - return { - ...actual, - useId: () => "test-id", - }; -}); - describe("Checkbox", () => { beforeEach(cleanup); @@ -67,11 +55,6 @@ describe("Checkbox", () => { expect(screen.getByRole("checkbox").id).toBe("custom-id"); }); - it("generates id when not provided", () => { - render(); - expect(screen.getByRole("checkbox").id).toBe("test-id"); - }); - it("renders children in Label component", () => { render(Test Label); expect(screen.getByTestId("label-component")).toHaveTextContent( diff --git a/src/components/generic/Filter/useFilterNode.ts b/src/components/generic/Filter/useFilterNode.ts index 7aa962a5..a55fbe85 100644 --- a/src/components/generic/Filter/useFilterNode.ts +++ b/src/components/generic/Filter/useFilterNode.ts @@ -17,8 +17,17 @@ export type FilterState = { hwModel: Protobuf.Mesh.HardwareModel[]; }; -const shallowEqualArray = (a: any[], b: any[]) => - a.length === b.length && a.every((v, i) => v === b[i]); +const shallowEqualArray = (a: T[], b: T[]): boolean => { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false; + } + } + return true; +}; export function useFilterNode() { const defaultFilterValues = useMemo( @@ -154,13 +163,20 @@ export function useFilterNode() { ? { ...defaultFilterValues, ...overrides } : defaultFilterValues; - return (Object.keys(base) as (keyof FilterState)[]).some((key) => { - const curr = current[key]; - const def = base[key]; - return Array.isArray(def) && Array.isArray(curr) - ? !shallowEqualArray(curr, def) - : curr !== def; - }); + for (const key of Object.keys(base) as (keyof FilterState)[]) { + const currentValue = current[key]; + const defaultValue = base[key]; + + if (Array.isArray(defaultValue) && Array.isArray(currentValue)) { + if (!shallowEqualArray(currentValue, defaultValue)) { + return true; + } + } else if (currentValue !== defaultValue) { + return true; + } + } + + return false; }, [defaultFilterValues], ); diff --git a/src/components/generic/Table/index.test.tsx b/src/components/generic/Table/index.test.tsx index 727357f4..8062144b 100644 --- a/src/components/generic/Table/index.test.tsx +++ b/src/components/generic/Table/index.test.tsx @@ -1,142 +1,128 @@ import { describe, expect, it } from "vitest"; import { fireEvent, render, screen } from "@testing-library/react"; -import { Table } from "@components/generic/Table/index.tsx"; +import { DataRow, Heading, Table } from "@components/generic/Table/index.tsx"; import { TimeAgo } from "@components/generic/TimeAgo.tsx"; import { Mono } from "@components/generic/Mono.tsx"; // @ts-types="react" describe("Generic Table", () => { it("Can render an empty table.", () => { - render( - , - ); + render(
); expect(screen.getByRole("table")).toBeInTheDocument(); }); it("Can render a table with headers and no rows.", async () => { - render( -
, - ); + const headings: Heading[] = [ + { title: "Short Name", sortable: true }, + { title: "Last Heard", sortable: true }, + { title: "Connection", sortable: true }, + ]; + render(
); await screen.findByRole("table"); - expect(screen.getAllByRole("columnheader")).toHaveLength(9); + expect(screen.getAllByRole("columnheader")).toHaveLength(3); }); - // A simplified version of the rows in pages/Nodes.tsx for testing purposes - const mockDevicesWithShortNameAndConnection = [ + // Mock data representing devices + const mockDevices = [ { - user: { shortName: "TST1" }, + id: "TST1", + shortName: "TST1", hopsAway: 1, - lastHeard: Date.now() + 1000, + lastHeard: Date.now() - 3000, viaMqtt: false, }, { - user: { shortName: "TST2" }, + id: "TST2", + shortName: "TST2", hopsAway: 0, - lastHeard: Date.now() + 4000, + lastHeard: Date.now() - 1000, viaMqtt: true, + isFavorite: true, // Favorite device }, { - user: { shortName: "TST3" }, + id: "TST3", + shortName: "TST3", hopsAway: 4, - lastHeard: Date.now(), + lastHeard: Date.now() - 5000, viaMqtt: false, }, { - user: { shortName: "TST4" }, + id: "TST4", + shortName: "TST4", hopsAway: 3, - lastHeard: Date.now() + 2000, + lastHeard: Date.now() - 2000, viaMqtt: true, }, ]; - const mockRows = mockDevicesWithShortNameAndConnection.map((node) => [ -

{node.user.shortName}

, - - - , - - {node.lastHeard !== 0 - ? node.viaMqtt === false && node.hopsAway === 0 - ? "Direct" - : `${node.hopsAway?.toString()} ${ - node.hopsAway ?? 0 > 1 ? "hops" : "hop" - } away` - : "-"} - {node.viaMqtt === true ? ", via MQTT" : ""} - , - ]); + // Transform mock data into the format expected by the Table component + const mockRows: DataRow[] = mockDevices.map((node) => ({ + id: node.id, + isFavorite: node.isFavorite, + cells: [ + { + content: {node.shortName}, + sortValue: node.shortName, + }, + { + content: ( + + + + ), + sortValue: node.lastHeard, + }, + { + content: ( + + {node.lastHeard !== 0 + ? node.viaMqtt === false && node.hopsAway === 0 + ? "Direct" + : `${node.hopsAway} ${node.hopsAway > 1 ? "hops" : "hop"} away` + : "-"} + {node.viaMqtt ? ", via MQTT" : ""} + + ), + sortValue: node.hopsAway, + }, + ], + })); - it("Can sort rows appropriately.", async () => { - render( -
, - ); + const headings: Heading[] = [ + { title: "Short Name", sortable: true }, + { title: "Last Heard", sortable: true }, + { title: "Connection", sortable: true }, + ]; + + it("Can sort rows, keeping favorites at the top", async () => { + render(
); const renderedTable = await screen.findByRole("table"); const columnHeaders = screen.getAllByRole("columnheader"); expect(columnHeaders).toHaveLength(3); - // Will be sorted "Last heard" "asc" by default - expect( - [...renderedTable.querySelectorAll("[data-testshortname]")] - .map((el) => el.textContent) - .map((v) => v?.trim()) - .join(","), - ) - .toMatch("TST2,TST4,TST1,TST3"); - - fireEvent.click(columnHeaders[0]); + const getRenderedOrder = () => + [...renderedTable.querySelectorAll("[data-testid='short-name']")].map( + (el) => el.textContent?.trim(), + ); - // Re-sort by Short Name asc - expect( - [...renderedTable.querySelectorAll("[data-testshortname]")] - .map((el) => el.textContent) - .map((v) => v?.trim()) - .join(","), - ) - .toMatch("TST1,TST2,TST3,TST4"); + // Default sort: "Last Heard" desc. TST2 is favorite, so it's first. + // Then the rest are sorted by lastHeard timestamp (most recent first). + // Order of timestamps: TST2 (latest, but favorite), TST4, TST1, TST3 (oldest). + expect(getRenderedOrder()).toEqual(["TST2", "TST4", "TST1", "TST3"]); + // Click "Short Name" to sort asc fireEvent.click(columnHeaders[0]); + // TST2 is favorite, so it's first. Then TST1, TST3, TST4 alphabetically. + expect(getRenderedOrder()).toEqual(["TST2", "TST1", "TST3", "TST4"]); - // Re-sort by Short Name desc - expect( - [...renderedTable.querySelectorAll("[data-testshortname]")] - .map((el) => el.textContent) - .map((v) => v?.trim()) - .join(","), - ) - .toMatch("TST4,TST3,TST2,TST1"); + // Click "Short Name" again to sort desc + fireEvent.click(columnHeaders[0]); + // TST2 is favorite, so it's first. Then TST4, TST3, TST1 reverse alphabetically. + expect(getRenderedOrder()).toEqual(["TST2", "TST4", "TST3", "TST1"]); + // Click "Connection" to sort by hops asc fireEvent.click(columnHeaders[2]); - - // Re-sort by Hops Away - expect( - [...renderedTable.querySelectorAll("[data-testshortname]")] - .map((el) => el.textContent) - .map((v) => v?.trim()) - .join(","), - ) - .toMatch("TST2,TST1,TST4,TST3"); + // TST2 is favorite (and also has 0 hops). Then sorted by hops: TST1 (1), TST4 (3), TST3 (4). + expect(getRenderedOrder()).toEqual(["TST2", "TST1", "TST4", "TST3"]); }); }); diff --git a/src/components/generic/Table/index.tsx b/src/components/generic/Table/index.tsx index 1ead83db..af699644 100755 --- a/src/components/generic/Table/index.tsx +++ b/src/components/generic/Table/index.tsx @@ -1,30 +1,32 @@ import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import React from "react"; import { cn } from "@core/utils/cn.ts"; -interface FavoriteIconProps { - showFavorite: boolean; +export interface Heading { + title: string; + sortable: boolean; } -interface AvatarCellProps { - children: React.ReactElement; +interface Cell { + content: React.ReactNode; + sortValue: string | number; } -export interface TableProps { - headings: Heading[]; - rows: React.ReactElement[][]; +export interface DataRow { + id: string | number; + isFavorite?: boolean; + cells: Cell[]; } -export interface Heading { - title: string; - type: "blank" | "normal"; - sortable: boolean; +export interface TableProps { + headings: Heading[]; + rows: DataRow[]; } -function numericHops(hopsAway: string | unknown): number { - if (typeof hopsAway !== "string") { - return Number.MAX_SAFE_INTEGER; +function numericHops(hopsAway: string | number): number { + if (typeof hopsAway === "number") { + return hopsAway; } if (hopsAway.match(/direct/i)) { return 0; @@ -37,7 +39,7 @@ export const Table = ({ headings, rows }: TableProps) => { const [sortColumn, setSortColumn] = useState("Last Heard"); const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); - const headingSort = (title: string) => { + const handleSort = (title: string) => { if (sortColumn === title) { setSortOrder(sortOrder === "asc" ? "desc" : "asc"); } else { @@ -46,72 +48,36 @@ export const Table = ({ headings, rows }: TableProps) => { } }; - const getElement = (cell: React.ReactNode): React.ReactElement | null => { - if (!React.isValidElement(cell)) { - return null; - } - if (cell.type === React.Fragment) { - const childrenArray = React.Children.toArray((cell.props as any).children); - const firstElement = childrenArray.find((child) => - React.isValidElement(child) - ); - return (firstElement as React.ReactElement) ?? null; - } - // If not a fragment, return the element itself - return cell; - }; - - const sortedRows = rows.slice().sort((a, b) => { - if (!sortColumn) return 0; + const sortedRows = useMemo(() => { + if (!sortColumn) return rows; const columnIndex = headings.findIndex((h) => h.title === sortColumn); - if (columnIndex === -1) return 0; + if (columnIndex === -1) return rows; - const elementA = getElement(a[columnIndex]); - const elementB = getElement(b[columnIndex]); + return [...rows].sort((a, b) => { + if (a.isFavorite !== b.isFavorite) { + return a.isFavorite ? -1 : 1; + } - // Avatar contains the prop showFavorite which indicates isFavorite - const favA = (a[0] as any)?.props?.children?.props?.showFavorite ?? false; - const favB = (b[0] as any)?.props?.children?.props?.showFavorite ?? false; + const aCell = a.cells[columnIndex]; + const bCell = b.cells[columnIndex]; - // Always put favorites at the top - if (favA !== favB) return favA ? -1 : 1; + let aValue: string | number; + let bValue: string | number; - if (sortColumn === "Last Heard") { - const aTimestamp = (elementA?.props as any)?.children?.props?.timestamp ?? 0; - const bTimestamp = (elementB?.props as any)?.children?.props?.timestamp ?? 0; - if (aTimestamp < bTimestamp) return sortOrder === "asc" ? -1 : 1; - if (aTimestamp > bTimestamp) return sortOrder === "asc" ? 1 : -1; - return 0; - } + if (sortColumn === "Connection") { + aValue = numericHops(aCell.sortValue); + bValue = numericHops(bCell.sortValue); + } else { + aValue = aCell.sortValue; + bValue = bCell.sortValue; + } - if (sortColumn === "Connection") { - const aHopsStr = (elementA?.props as any)?.children?.[0]; - const bHopsStr = (elementB?.props as any)?.children?.[0]; - const aNumHops = numericHops(aHopsStr); - const bNumHops = numericHops(bHopsStr); - if (aNumHops < bNumHops) return sortOrder === "asc" ? -1 : 1; - if (aNumHops > bNumHops) return sortOrder === "asc" ? 1 : -1; + if (aValue < bValue) return sortOrder === "asc" ? -1 : 1; + if (aValue > bValue) return sortOrder === "asc" ? 1 : -1; return 0; - } - - const aValue = (elementA?.props as any)?.children; - const bValue = (elementB?.props as any)?.children; - const valA = aValue ?? ""; - const valB = bValue ?? ""; - - // Ensure consistent comparison for potentially different types - const compareA = typeof valA === "string" || typeof valA === "number" - ? valA - : String(valA); - const compareB = typeof valB === "string" || typeof valB === "number" - ? valB - : String(valB); - - if (compareA < compareB) return sortOrder === "asc" ? -1 : 1; - if (compareA > compareB) return sortOrder === "asc" ? 1 : -1; - return 0; - }); + }); + }, [rows, sortColumn, sortOrder, headings]); return (
@@ -121,17 +87,15 @@ export const Table = ({ headings, rows }: TableProps) => { - {sortedRows.map((row) => { - const firstCellKey = - (React.isValidElement(row[0]) && row[0].key !== null) - ? String(row[0].key) - : null; - const rowKey = firstCellKey ?? Math.random().toString(); // Use random only as last resort - - const isFavorite = row[0]?.props?.children?.props?.showFavorite ?? - false; - return ( - - {row.map((item, cellIndex) => { - const cellKey = `${rowKey}_${cellIndex}`; - return cellIndex === 0 - ? ( - - ) - : ( - - ); - })} - - ); - })} + {sortedRows.map((row) => ( + + {row.cells.map((cell, cellIndex) => + cellIndex === 0 + ? ( + + ) + : ( + + ) + )} + + ))}
heading.sortable && headingSort(heading.title)} + className={cn( + "py-2 pr-3 text-left", + heading.sortable && + "cursor-pointer hover:brightness-hover active:brightness-press", + )} + onClick={() => heading.sortable && handleSort(heading.title)} onKeyUp={(e) => { - if ( - heading.sortable && (e.key === "Enter" || e.key === " ") - ) { - headingSort(heading.title); + if (heading.sortable && (e.key === "Enter" || e.key === " ")) { + handleSort(heading.title); } }} tabIndex={heading.sortable ? 0 : -1} @@ -153,49 +117,37 @@ export const Table = ({ headings, rows }: TableProps) => {
- {item} - - {item} -
+ {cell.content} + + {cell.content} +
); diff --git a/src/core/hooks/usePinnedItems.test.ts b/src/core/hooks/usePinnedItems.test.ts deleted file mode 100644 index 3b126666..00000000 --- a/src/core/hooks/usePinnedItems.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { act, renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { usePinnedItems } from "./usePinnedItems.ts"; - -const mockSetPinnedItems = vi.fn(); -const mockUseLocalStorage = vi.fn(); - -vi.mock("@core/hooks/useLocalStorage.ts", () => ({ - default: (...args: any[]) => mockUseLocalStorage(...args), -})); - -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/stores/deviceStore.mock.ts b/src/core/stores/deviceStore.mock.ts new file mode 100644 index 00000000..fb97c9d3 --- /dev/null +++ b/src/core/stores/deviceStore.mock.ts @@ -0,0 +1,82 @@ +import { vi } from "vitest"; +import { type Device } from "./deviceStore.ts"; +import { Protobuf } from "@meshtastic/core"; + +/** + * You can spread this base mock in your tests and override only the + * properties relevant to a specific test case. + * + * @example + * vi.mocked(useDevice).mockReturnValue({ + * ...mockDeviceStore, + * getNode: (nodeNum) => mockNodes.get(nodeNum), + * }); + */ +export const mockDeviceStore: Device = { + id: 0, + status: 5 as const, + channels: new Map(), + config: {} as Protobuf.LocalOnly.LocalConfig, + moduleConfig: {} as Protobuf.LocalOnly.LocalModuleConfig, + workingConfig: [], + workingModuleConfig: [], + hardware: {} as Protobuf.Mesh.MyNodeInfo, + metadata: new Map(), + traceroutes: new Map(), + nodeErrors: new Map(), + connection: undefined, + activeNode: 0, + waypoints: [], + pendingSettingsChanges: false, + messageDraft: "", + unreadCounts: new Map(), + nodesMap: new Map(), + dialog: { + import: false, + QR: false, + shutdown: false, + reboot: false, + rebootOTA: false, + deviceName: false, + nodeRemoval: false, + pkiBackup: false, + nodeDetails: false, + unsafeRoles: false, + refreshKeys: false, + deleteMessages: false, + }, + setStatus: vi.fn(), + setConfig: vi.fn(), + setModuleConfig: vi.fn(), + setWorkingConfig: vi.fn(), + setWorkingModuleConfig: vi.fn(), + setHardware: vi.fn(), + setActiveNode: vi.fn(), + setPendingSettingsChanges: vi.fn(), + addChannel: vi.fn(), + addWaypoint: vi.fn(), + addNodeInfo: vi.fn(), + addUser: vi.fn(), + addPosition: vi.fn(), + addConnection: vi.fn(), + addTraceRoute: vi.fn(), + addMetadata: vi.fn(), + removeNode: vi.fn(), + setDialogOpen: vi.fn(), + getDialogOpen: vi.fn().mockReturnValue(false), + processPacket: vi.fn(), + setMessageDraft: vi.fn(), + setNodeError: vi.fn(), + clearNodeError: vi.fn(), + getNodeError: vi.fn().mockReturnValue(undefined), + hasNodeError: vi.fn().mockReturnValue(false), + incrementUnread: vi.fn(), + resetUnread: vi.fn(), + getNodes: vi.fn().mockReturnValue([]), + getNodesLength: vi.fn().mockReturnValue(0), + getNode: vi.fn().mockReturnValue(undefined), + getMyNode: vi.fn(), + sendAdminMessage: vi.fn(), + updateFavorite: vi.fn(), + updateIgnored: vi.fn(), +}; diff --git a/src/core/stores/storage/indexDB.ts b/src/core/stores/storage/indexDB.ts index 129fe9ae..ce2c2790 100644 --- a/src/core/stores/storage/indexDB.ts +++ b/src/core/stores/storage/indexDB.ts @@ -56,18 +56,25 @@ const reviver: JsonReviver = (_, value) => { }; export const storageWithMapSupport: PersistStorage = { - getItem: async (name): Promise | null> => { + getItem: async ( + name, + ): Promise | null> => { const str = await zustandIndexDBStorage.getItem(name); if (!str) return null; try { - const parsed = JSON.parse(str, reviver) as StorageValue; + const parsed = JSON.parse(str, reviver) as StorageValue< + PersistedMessageState + >; return parsed; } catch (error) { console.error(`Error parsing persisted state (${name}):`, error); return null; } }, - setItem: async (name, newValue: StorageValue): Promise => { + setItem: async ( + name, + newValue: StorageValue, + ): Promise => { try { const str = JSON.stringify(newValue, replacer); await zustandIndexDBStorage.setItem(name, str); diff --git a/src/i18n/config.ts b/src/i18n/config.ts index 59f58c7f..f203dab3 100644 --- a/src/i18n/config.ts +++ b/src/i18n/config.ts @@ -35,7 +35,7 @@ i18next "default": ["en"], }, fallbackNS: ["common", "ui", "dialog"], - debug: import.meta.env.MODE === 'development', + debug: import.meta.env.MODE === "development", supportedLngs: supportedLanguages?.map((lang) => lang.code), ns: [ "channels", diff --git a/src/index.css b/src/index.css index 41165535..56103edb 100644 --- a/src/index.css +++ b/src/index.css @@ -93,7 +93,6 @@ body { } @layer base { - *, ::after, ::before, @@ -139,4 +138,4 @@ img { .animate-spin-slow { animation: spin-slower 2s linear infinite; -} \ No newline at end of file +} diff --git a/src/pages/Messages.test.tsx b/src/pages/Messages.test.tsx deleted file mode 100644 index f587ba30..00000000 --- a/src/pages/Messages.test.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { fireEvent, render, screen } from "@testing-library/react"; -import { MessagesPage } from "./Messages.tsx"; -import { useDevice } from "../core/stores/deviceStore.ts"; -import { Protobuf } from "@meshtastic/core"; - -vi.mock("../core/stores/deviceStore", () => ({ - useDevice: vi.fn(), -})); - -const mockUseDevice = { - channels: new Map([ - [0, { - index: 0, - settings: { name: "Primary" }, - role: Protobuf.Channel.Channel_Role.PRIMARY, - }], - ]), - nodes: new Map([ - [0, { - num: 0, - user: { longName: "Test Node 0", shortName: "TN0", publicKey: "0000" }, - }], - [1111, { - num: 1111, - user: { longName: "Test Node 1", shortName: "TN1", publicKey: "12345" }, - }], - [2222, { - num: 2222, - user: { longName: "Test Node 2", shortName: "TN2", publicKey: "67890" }, - }], - [3333, { - num: 3333, - user: { longName: "Test Node 3", shortName: "TN3", publicKey: "11111" }, - }], - ]), - hardware: { myNodeNum: 1 }, - messages: { broadcast: new Map(), direct: new Map() }, - metadata: new Map(), - unreadCounts: new Map([[1111, 3], [2222, 10]]), - resetUnread: vi.fn(), - hasNodeError: vi.fn(), -}; - -describe.skip("Messages Page", () => { - beforeEach(() => { - vi.mocked(useDevice).mockReturnValue(mockUseDevice as any); - }); - - it("sorts unreads to the top", () => { - render(); - const buttonOrder = screen.getAllByRole("button").filter((b) => - b.textContent?.includes("Test Node") - ); - expect(buttonOrder[0].textContent).toContain("TN2Test Node 210"); - expect(buttonOrder[1].textContent).toContain("TN1Test Node 13"); - expect(buttonOrder[2].textContent).toContain("TN0Test Node 0"); - expect(buttonOrder[3].textContent).toContain("TN3Test Node 3"); - }); - - it("updates unread when active chat changes", () => { - render(); - const nodeButton = - screen.getAllByRole("button").filter((b) => - b.textContent?.includes("TN1Test Node 13") - )[0]; - fireEvent.click(nodeButton); - expect(mockUseDevice.resetUnread).toHaveBeenCalledWith(1111, 0); - }); - - it("does not update the incorrect node", () => { - render(); - const nodeButton = - screen.getAllByRole("button").filter((b) => - b.textContent?.includes("TN1Test Node 1") - )[0]; - fireEvent.click(nodeButton); - expect(mockUseDevice.resetUnread).toHaveBeenCalledWith(1111, 0); - expect(mockUseDevice.unreadCounts.get(2222)).toBe(10); - }); -}); diff --git a/src/pages/Nodes/index.tsx b/src/pages/Nodes/index.tsx index 025ea3b0..8400ca2a 100644 --- a/src/pages/Nodes/index.tsx +++ b/src/pages/Nodes/index.tsx @@ -1,9 +1,13 @@ -import { LocationResponseDialog } from "@components/Dialog/LocationResponseDialog.tsx"; -import { TracerouteResponseDialog } from "@components/Dialog/TracerouteResponseDialog.tsx"; +import { LocationResponseDialog } from "@app/components/Dialog/LocationResponseDialog.tsx"; +import { TracerouteResponseDialog } from "@app/components/Dialog/TracerouteResponseDialog.tsx"; import { Sidebar } from "@components/Sidebar.tsx"; import { Avatar } from "@components/UI/Avatar.tsx"; import { Mono } from "@components/generic/Mono.tsx"; -import { Table } from "@components/generic/Table/index.tsx"; +import { + type DataRow, + type Heading, + Table, +} from "@components/generic/Table/index.tsx"; import { TimeAgo } from "@components/generic/TimeAgo.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; import { useAppStore } from "@core/stores/appStore.ts"; @@ -93,6 +97,123 @@ const NodesPage = (): JSX.Element => { setDialogOpen("nodeDetails", true); } + const tableHeadings: Heading[] = [ + { title: "", sortable: false }, + { title: t("nodesTable.headings.longName"), sortable: true }, + { title: t("nodesTable.headings.connection"), sortable: true }, + { title: t("nodesTable.headings.lastHeard"), sortable: true }, + { title: t("nodesTable.headings.encryption"), sortable: false }, + { title: t("unit.snr"), sortable: true }, + { title: t("nodesTable.headings.model"), sortable: true }, + { title: t("nodesTable.headings.macAddress"), sortable: true }, + ]; + + const tableRows: DataRow[] = filteredNodes.map((node) => { + const macAddress = base16 + .stringify(node.user?.macaddr ?? []) + .match(/.{1,2}/g) + ?.join(":") ?? t("unknown.shortName"); + + return { + id: node.num, + isFavorite: node.isFavorite, + cells: [ + { + content: ( + + ), + sortValue: node.user?.shortName ?? "", // Non-sortable column + }, + { + content: ( +

handleNodeInfoDialog(node.num)} + onKeyUp={(evt) => { + evt.key === "Enter" && handleNodeInfoDialog(node.num); + }} + className="cursor-pointer underline ml-2 whitespace-break-spaces" + tabIndex={0} + role="button" + > + {node.user?.longName ?? numberToHexUnpadded(node.num)} +

+ ), + sortValue: node.user?.longName ?? numberToHexUnpadded(node.num), + }, + { + content: ( + + {node.hopsAway !== undefined + ? node?.viaMqtt === false && node.hopsAway === 0 + ? t("nodesTable.connectionStatus.direct") + : `${node.hopsAway?.toString()} ${ + node.hopsAway ?? 0 > 1 + ? t("unit.hop.plural") + : t("unit.hops_one") + } ${t("nodesTable.connectionStatus.away")}` + : t("nodesTable.connectionStatus.unknown")} + {node?.viaMqtt === true + ? t("nodesTable.connectionStatus.viaMqtt") + : ""} + + ), + sortValue: node.hopsAway ?? Number.MAX_SAFE_INTEGER, + }, + { + content: ( + + {node.lastHeard === 0 + ?

{t("nodesTable.lastHeardStatus.never")}

+ : } +
+ ), + sortValue: node.lastHeard, + }, + { + content: ( + + {node.user?.publicKey && node.user?.publicKey.length > 0 + ? + : } + + ), + sortValue: "", // Non-sortable column + }, + { + content: ( + + {node.snr} + {t("unit.dbm")}/ + {Math.min( + Math.max((node.snr + 10) * 5, 0), + 100, + )}%/{/* Percentage */} + {(node.snr + 10) * 5} + {t("unit.raw")} + + ), + sortValue: node.snr, + }, + { + content: ( + + {Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]} + + ), + sortValue: Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0], + }, + { + content: {macAddress}, + sortValue: macAddress, + }, + ], + }; + }); + return ( <> {
[ -
- -
, -

handleNodeInfoDialog(node.num)} - onKeyUp={(evt) => { - evt.key === "Enter" && handleNodeInfoDialog(node.num); - }} - className="cursor-pointer underline ml-2 whitespace-break-spaces" - tabIndex={0} - role="button" - > - {node.user?.longName ?? numberToHexUnpadded(node.num)} -

, - - {node.hopsAway !== undefined - ? node?.viaMqtt === false && node.hopsAway === 0 - ? t("nodesTable.connectionStatus.direct") - : `${node.hopsAway?.toString()} ${ - node.hopsAway ?? 0 > 1 - ? t("unit.hop.plural") - : t("unit.hops_one") - } ${t("nodesTable.connectionStatus.away")}` - : t("nodesTable.connectionStatus.unknown")} - {node?.viaMqtt === true - ? t("nodesTable.connectionStatus.viaMqtt") - : ""} - , - - {node.lastHeard === 0 - ?

{t("nodesTable.lastHeardStatus.never")}

- : } -
, - - {node.user?.publicKey && node.user?.publicKey.length > 0 - ? - : } - , - - {node.snr} - {t("unit.dbm")}/ - {Math.min( - Math.max((node.snr + 10) * 5, 0), - 100, - )}%/{/* Percentage */} - {(node.snr + 10) * 5} - {t("unit.raw")} - , - - {Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]} - , - - {base16 - .stringify(node.user?.macaddr ?? []) - .match(/.{1,2}/g) - ?.join(":") ?? t("unknown.shortName")} - , - ])} + headings={tableHeadings} + rows={tableRows} /> () + ._addFileTypes(); /* ROUTE_MANIFEST_START {