From bf90d6312b260792a8376b8e8a7286f64ce81494 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 31 May 2026 16:15:14 +0200 Subject: [PATCH] fix(canvas): include MAC property in approved node sent to canvas (#168) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend stored the MAC, but the frontend built the approved canvas node locally with properties=[] (non-zigbee) and no mac field. On save, canvas/save upserts every node field from the in-memory canvas, so the empty properties overwrote the DB MAC — the address showed after a no-save reload but vanished once saved. Now both approve paths (single + bulk) attach a MAC property row and the mac field to the node added to the canvas, mirroring the backend build_mac_property. Hidden by default, toggleable from the right panel. - new buildMacProperty util + unit tests - bulk-approve modal test asserts MAC propagation (IP device) and absence for a zigbee device ha-relevant: yes --- .../components/modals/PendingDevicesModal.tsx | 7 +++-- .../__tests__/PendingDevicesModal.test.tsx | 31 ++++++++++++++++++- .../src/utils/__tests__/macProperty.test.ts | 16 ++++++++++ frontend/src/utils/macProperty.ts | 9 ++++++ 4 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 frontend/src/utils/__tests__/macProperty.test.ts create mode 100644 frontend/src/utils/macProperty.ts diff --git a/frontend/src/components/modals/PendingDevicesModal.tsx b/frontend/src/components/modals/PendingDevicesModal.tsx index 973b492..1f770ed 100644 --- a/frontend/src/components/modals/PendingDevicesModal.tsx +++ b/frontend/src/components/modals/PendingDevicesModal.tsx @@ -10,6 +10,7 @@ import { toast } from 'sonner' import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal' import type { NodeType, ServiceInfo } from '@/types' import { buildZigbeeProperties, isZigbeeType } from '@/utils/zigbeeProperties' +import { buildMacProperty } from '@/utils/macProperty' interface PendingDevicesModalProps { open: boolean @@ -255,11 +256,12 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus const fallbackLabel = deviceLabel(device) const type = (device.suggested_type ?? 'generic') as NodeType const zigbee = isZigbeeType(type) - const properties = zigbee ? buildZigbeeProperties(device) : [] + const properties = zigbee ? buildZigbeeProperties(device) : buildMacProperty(device.mac) const nodeData = { label: fallbackLabel, type, ip: device.ip ?? undefined, + mac: device.mac ?? undefined, hostname: device.hostname ?? undefined, status: zigbee ? 'online' : 'unknown', services: (device.services ?? []) as ServiceInfo[], @@ -325,10 +327,11 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus label: deviceLabel(d), type, ip: d.ip ?? undefined, + mac: d.mac ?? undefined, hostname: d.hostname ?? undefined, status: zigbee ? ('online' as const) : ('unknown' as const), services: (d.services ?? []) as ServiceInfo[], - properties: zigbee ? buildZigbeeProperties(d) : [], + properties: zigbee ? buildZigbeeProperties(d) : buildMacProperty(d.mac), }, }) }) diff --git a/frontend/src/components/modals/__tests__/PendingDevicesModal.test.tsx b/frontend/src/components/modals/__tests__/PendingDevicesModal.test.tsx index 6164950..4cbcf97 100644 --- a/frontend/src/components/modals/__tests__/PendingDevicesModal.test.tsx +++ b/frontend/src/components/modals/__tests__/PendingDevicesModal.test.tsx @@ -13,6 +13,7 @@ const mockApprove = vi.fn() const mockHide = vi.fn() const mockPending = vi.fn() const mockHidden = vi.fn() +const mockAddNode = vi.fn() vi.mock('@/api/client', () => ({ scanApi: { @@ -69,7 +70,7 @@ const DEVICE_ZIGBEE = { beforeEach(() => { vi.clearAllMocks() vi.mocked(useCanvasStore).mockReturnValue({ - addNode: vi.fn(), + addNode: mockAddNode, scanEventTs: 0, } as unknown as ReturnType) // setState is used by injectAutoEdges @@ -174,6 +175,34 @@ describe('PendingDevicesModal', () => { await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b'])) }) + it('bulk approve carries the scanned MAC onto the canvas node (#168)', async () => { + render() + await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()) + fireEvent.click(screen.getByRole('button', { name: 'Select mode' })) + fireEvent.click(screen.getByTestId('pending-card-dev-a')) + fireEvent.click(screen.getByTestId('pending-card-dev-b')) + fireEvent.click(screen.getByRole('button', { name: /Approve \(2\)/ })) + await waitFor(() => expect(mockAddNode).toHaveBeenCalledTimes(2)) + + // dev-a is an IP device with a MAC → node carries mac + a MAC property row. + const ipNode = mockAddNode.mock.calls + .map((c) => c[0]) + .find((n) => n.id === 'n1') + expect(ipNode.data.mac).toBe('aa:bb:cc:dd:ee:01') + expect(ipNode.data.properties).toContainEqual({ + key: 'MAC', + value: 'aa:bb:cc:dd:ee:01', + icon: null, + visible: false, + }) + + // dev-b is zigbee with no MAC → no MAC property row. + const zbNode = mockAddNode.mock.calls + .map((c) => c[0]) + .find((n) => n.id === 'n2') + expect(zbNode.data.properties.some((p: { key: string }) => p.key === 'MAC')).toBe(false) + }) + it('bulk hide calls API with selected ids', async () => { render() await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()) diff --git a/frontend/src/utils/__tests__/macProperty.test.ts b/frontend/src/utils/__tests__/macProperty.test.ts new file mode 100644 index 0000000..166f3fb --- /dev/null +++ b/frontend/src/utils/__tests__/macProperty.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest' +import { buildMacProperty } from '../macProperty' + +describe('buildMacProperty', () => { + it('returns a hidden MAC property row for a MAC', () => { + expect(buildMacProperty('aa:bb:cc:dd:ee:ff')).toEqual([ + { key: 'MAC', value: 'aa:bb:cc:dd:ee:ff', icon: null, visible: false }, + ]) + }) + + it('returns an empty array when MAC is null/undefined/empty', () => { + expect(buildMacProperty(null)).toEqual([]) + expect(buildMacProperty(undefined)).toEqual([]) + expect(buildMacProperty('')).toEqual([]) + }) +}) diff --git a/frontend/src/utils/macProperty.ts b/frontend/src/utils/macProperty.ts new file mode 100644 index 0000000..969da81 --- /dev/null +++ b/frontend/src/utils/macProperty.ts @@ -0,0 +1,9 @@ +import type { NodeProperty } from '@/types' + +/** Build the MAC address property row shown in the right panel. + * Hidden by default — the user opts in to showing it on the canvas card. + * Matches backend `build_mac_property`. Returns an empty array when no MAC. */ +export function buildMacProperty(mac?: string | null): NodeProperty[] { + if (!mac) return [] + return [{ key: 'MAC', value: mac, icon: null, visible: false }] +}