diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 643be15f1..a65eeefd4 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,39 +1,36 @@ { "version": 1, - "base_sha": "53ea86983c7c6786eb1a6fe1f3681e4566bc237e", - "verified_at": "2026-09-04T00:29:07Z", + "base_sha": "74c553aae330d2b8a0bfecc22124b40ef10c95d3", + "verified_at": "2026-09-04T02:18:06Z", "result": "passed", - "changed_paths": [ - "frontend-modern/src/types/resource.ts", - "frontend-modern/src/utils/resourceStateAdapters.ts" - ], + "changed_paths": ["frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx"], "content_sha256": { - "frontend-modern/src/types/resource.ts": "c43048a9796babd38f338cff14c21ed2b6dbbf8b16f2bb1fddf89529ef5a65f5", - "frontend-modern/src/utils/resourceStateAdapters.ts": "328efccc8b88b8e590bef9e56568b2f969a06df314d464b5f44b757a2acb189f" + "frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx": "508b628748732e42dee19775fa3e0571fee0617dad8c2d28633bf7201887f904" }, "routes": [ - "/proxmox (managed hot-dev mock with two provider-scoped standalone sites sharing the native node name pve)" + "/settings/infrastructure (managed local backend with a two-node Proxmox cluster fixture)" ], "viewports": [ { "width": 1280, - "height": 800 + "height": 720 }, { - "width": 390, - "height": 844 + "width": 393, + "height": 727 } ], "states": [ - "Initial Proxmox overview showing Nodes 2 and two standalone rows named pve with distinct provider endpoint links", - "Proxmox overview after six mock websocket update intervals with both provider-scoped rows still present", - "Reloaded Proxmox overview with both same-native-name rows restored from the REST bootstrap snapshot", - "Narrow Proxmox overview showing Nodes 2 and two separate pve rows without clipping or navigation overlap" + "Manage dialog open for an expanded two-node Proxmox cluster at desktop and narrow widths", + "Unsaved node name, Host Telemetry Agent section and certificate checkbox retained after a 15-second connection-ledger poll", + "Manage dialog closed with the settings scroll position preserved and deliberate onward focus retained beyond the delayed fallback", + "Manage dialog reopened and a duplicate presentation label saved against the second node's immutable identity" ], "interactions": [ - "Authenticated to the managed development runtime and navigated to the Proxmox overview", - "Verified both distinct provider endpoints and waited 12 seconds across repeated realtime updates", - "Reloaded the route and rechecked both resource rows", - "Resized from 1280 by 800 to 390 by 844 and inspected full-page screenshots for row count, clipping, overflow, and fixed-navigation overlap" + "Expanded the cluster members, opened Manage, inspected the dialog pixels and native-node diagnostics", + "Edited fields, changed the active section and advanced the polling clock while checking the mounted editor state", + "Closed Manage, claimed Add infrastructure focus without scrolling, waited beyond the 250ms fallback and checked focus plus scroll position", + "Reopened Manage, saved the second member's display label and verified the PUT payload retained the cluster host and immutable node identity", + "Inspected the narrow open dialog and the closed desktop and narrow states for placement, clipping, scrolling, fixed-navigation overlap and responsive layout" ] } diff --git a/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx b/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx index 5f085f0ad..655bb2fd8 100644 --- a/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx @@ -321,11 +321,20 @@ const InfrastructureWorkspaceContent: Component = (element) => element.getAttribute('data-infrastructure-manage-id') === focusReturnConnectionId, ); + const activeElement = document.activeElement; + const focusReturnIsStillPending = + !activeElement || + activeElement === document.body || + activeElement === document.documentElement || + !document.contains(activeElement); // The shared dialog restores its captured trigger without scrolling, // but this delayed lookup covers rows recreated while the dialog was - // open. Keep that fallback from pulling a lower infrastructure row - // into view after the operator closes Manage. - focusTarget?.focus({ preventScroll: true }); + // open. Only use that fallback while focus remains unclaimed: the + // operator may already have moved to another control during the delay. + // Keep the fallback from pulling a lower infrastructure row into view. + if (focusReturnIsStillPending) { + focusTarget?.focus({ preventScroll: true }); + } }, 250); } }; diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx index 481cbc48e..709a76fd2 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx @@ -1026,11 +1026,55 @@ describe('InfrastructureWorkspace', () => { const focusSpy = vi.spyOn(manageButton, 'focus'); fireEvent.click(screen.getByRole('button', { name: 'Close edit infrastructure dialog' })); - await waitFor(() => expect(focusSpy).toHaveBeenCalledTimes(2)); - expect(focusSpy.mock.calls).toEqual([[{ preventScroll: true }], [{ preventScroll: true }]]); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + await new Promise((resolve) => window.setTimeout(resolve, 300)); + expect(focusSpy).toHaveBeenCalledTimes(1); + expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true }); expect(manageButton).toHaveFocus(); }); + it('does not steal focus back to Manage after the operator moves on', async () => { + renderWorkspace({ + pveNodes: () => [{ name: 'zeus', host: 'https://10.0.0.1:8006' } as any], + }); + + const manageButton = await screen.findByRole('button', { name: /^Manage$/i }); + fireEvent.click(manageButton); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', { name: 'Close edit infrastructure dialog' })); + const nextAction = screen.getByRole('button', { name: /^Add infrastructure$/i }); + nextAction.focus(); + + await new Promise((resolve) => window.setTimeout(resolve, 300)); + expect(nextAction).toHaveFocus(); + }); + + it('returns focus to a Manage control recreated by a connection poll', async () => { + const [revision, setRevision] = createSignal(0); + connectionState.setObserver(revision, () => setRevision((value) => value + 1)); + renderWorkspace({ + pveNodes: () => [{ name: 'zeus', host: 'https://10.0.0.1:8006' } as any], + }); + + const originalManageButton = await screen.findByRole('button', { name: /^Manage$/i }); + fireEvent.click(originalManageButton); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + + connectionState.connections = [ + connectionFixture({ lastSeen: new Date(Date.now() + 15_000).toISOString() }), + ]; + connectionState.notifyChanged(); + await waitFor(() => + expect(screen.getByRole('button', { name: /^Manage$/i })).not.toBe(originalManageButton), + ); + const replacementManageButton = screen.getByRole('button', { name: /^Manage$/i }); + + fireEvent.click(screen.getByRole('button', { name: 'Close edit infrastructure dialog' })); + await new Promise((resolve) => window.setTimeout(resolve, 300)); + expect(replacementManageButton).toHaveFocus(); + }); + it('keeps the mounted node editor across connection-ledger refreshes', async () => { const [revision, setRevision] = createSignal(0); connectionState.setObserver(revision, () => setRevision((value) => value + 1)); diff --git a/tests/integration/tests/91-proxmox-node-display-names.spec.ts b/tests/integration/tests/91-proxmox-node-display-names.spec.ts index 5f5bd9fa4..6329b06e7 100644 --- a/tests/integration/tests/91-proxmox-node-display-names.spec.ts +++ b/tests/integration/tests/91-proxmox-node-display-names.spec.ts @@ -284,21 +284,26 @@ test.describe('Proxmox cluster node display names', () => { ), { timeout: 2_000 }, ) - .toBe(2); + .toBe(1); + // The row-stability fallback runs later than the shared dialog cleanup. + // Moving on during that window must not pull focus back to Manage. + const nextAction = page.getByRole('button', { name: 'Add infrastructure', exact: true }); + await nextAction.evaluate((element) => element.focus({ preventScroll: true })); + await page.waitForTimeout(350); expect( await page.evaluate( () => (window as Window & { __infrastructureManageFocusCalls?: FocusOptions[] }) .__infrastructureManageFocusCalls, ), - ).toEqual([{ preventScroll: true }, { preventScroll: true }]); + ).toEqual([{ preventScroll: true }]); expect( await page.evaluate(() => { const shell = document.querySelector('.app-scroll-shell'); return shell?.scrollTop ?? window.scrollY; }), ).toBe(scrollTopBeforeClose); - await expect(manageButton).toBeFocused(); + await expect(nextAction).toBeFocused(); await page.screenshot({ path: testInfo.outputPath('manage-dialog-closed.png') }); await manageButton.click();