From 6a27de5e23958ffdf7e99c0ee03ad596ca7b95fe Mon Sep 17 00:00:00 2001 From: Omkar Kesarkhane <127078473+omkarK06@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:13:44 +0530 Subject: [PATCH] fix(traces): open the service side panel as a non-modal drawer to avoid flicker (v1.1.0) (#15342) (#15440) --- web/src/lib/overlay/Drawer/ODrawer.spec.ts | 18 ++++++++ web/src/lib/overlay/Drawer/ODrawer.vue | 9 ++-- .../traces/ServiceGraphNodeSidePanel.spec.ts | 9 ++++ .../traces/ServiceGraphNodeSidePanel.vue | 1 + .../traces/ServiceGraphSidePanel.spec.ts | 1 + .../plugins/traces/ServicesCatalog.spec.ts | 42 +++++++++++++++++++ 6 files changed, 76 insertions(+), 4 deletions(-) diff --git a/web/src/lib/overlay/Drawer/ODrawer.spec.ts b/web/src/lib/overlay/Drawer/ODrawer.spec.ts index 60e3fe6b5a..1c8ac145ff 100644 --- a/web/src/lib/overlay/Drawer/ODrawer.spec.ts +++ b/web/src/lib/overlay/Drawer/ODrawer.spec.ts @@ -275,6 +275,24 @@ describe("ODrawer", () => { expect(document.activeElement).toBe(panel.element); wrapper.unmount(); }); + + it("focuses without scrolling so a contained portal target is not dragged sideways", async () => { + const focusSpy = vi.spyOn(HTMLElement.prototype, "focus"); + const wrapper = mount(ODrawer, { + attachTo: document.body, + props: { open: true, title: "Contained" }, + slots: { default: '' }, + }); + const panel = findDrawerPanel(wrapper); + focusSpy.mockClear(); + + await panel.vm.$emit("openAutoFocus", new Event("openAutoFocus", { cancelable: true })); + await new Promise((resolve) => setTimeout(resolve)); + + expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true }); + focusSpy.mockRestore(); + wrapper.unmount(); + }); }); describe("Escape key behaviour", () => { diff --git a/web/src/lib/overlay/Drawer/ODrawer.vue b/web/src/lib/overlay/Drawer/ODrawer.vue index 3264a134f2..8c42a9fa3a 100644 --- a/web/src/lib/overlay/Drawer/ODrawer.vue +++ b/web/src/lib/overlay/Drawer/ODrawer.vue @@ -305,24 +305,25 @@ function findAutoFocusTarget(root: Element): HTMLElement | null { function handleOpenAutoFocus(event: Event) { event.preventDefault(); + // The panel is still translated off-screen by the slide-in, so a scrolling focus would drag a contained portal target sideways. nextTick(() => { const body = bodyRef.value; const field = body ? findAutoFocusTarget(body) : null; if (field) { - field.focus(); + field.focus({ preventScroll: true }); return; } const autofocus = body?.querySelector("[autofocus]"); if (autofocus) { - autofocus.focus(); + autofocus.focus({ preventScroll: true }); return; } const btnEl = (primaryBtnRef.value as any)?.$el as HTMLElement | undefined; if (btnEl) { - btnEl.focus(); + btnEl.focus({ preventScroll: true }); return; } - body?.closest("[data-o2-drawer]")?.focus(); + body?.closest("[data-o2-drawer]")?.focus({ preventScroll: true }); }); } diff --git a/web/src/plugins/traces/ServiceGraphNodeSidePanel.spec.ts b/web/src/plugins/traces/ServiceGraphNodeSidePanel.spec.ts index b1bd96f44f..1a7a59e982 100644 --- a/web/src/plugins/traces/ServiceGraphNodeSidePanel.spec.ts +++ b/web/src/plugins/traces/ServiceGraphNodeSidePanel.spec.ts @@ -164,6 +164,7 @@ const ODrawerStub = { "open", "width", "seamless", + "modal", "portalTarget", "title", "subTitle", @@ -311,6 +312,14 @@ describe("ServiceGraphNodeSidePanel", () => { }); }); + describe("drawer behaviour", () => { + it("renders a non-modal drawer so the page behind stays interactive", () => { + wrapper = mountPanel({ selectedNode: baseNode }); + const drawer = wrapper.findComponent(ODrawerStub); + expect(drawer.props("modal")).toBe(false); + }); + }); + // ------------------------------------------------------------------------- // Service name display // ------------------------------------------------------------------------- diff --git a/web/src/plugins/traces/ServiceGraphNodeSidePanel.vue b/web/src/plugins/traces/ServiceGraphNodeSidePanel.vue index 7a2a94a6c3..8c061a8c8e 100644 --- a/web/src/plugins/traces/ServiceGraphNodeSidePanel.vue +++ b/web/src/plugins/traces/ServiceGraphNodeSidePanel.vue @@ -20,6 +20,7 @@ along with this program. If not, see . :open="visible" :width="60" :seamless="true" + :modal="false" :portal-target="containerEl" :title="selectedNode?.name || selectedNode?.label || selectedNode?.id" data-test="service-graph-side-panel" diff --git a/web/src/plugins/traces/ServiceGraphSidePanel.spec.ts b/web/src/plugins/traces/ServiceGraphSidePanel.spec.ts index c3dc661d9a..ba99937288 100644 --- a/web/src/plugins/traces/ServiceGraphSidePanel.spec.ts +++ b/web/src/plugins/traces/ServiceGraphSidePanel.spec.ts @@ -181,6 +181,7 @@ const ODrawerStub = { "open", "width", "seamless", + "modal", "portalTarget", "title", "subTitle", diff --git a/web/src/plugins/traces/ServicesCatalog.spec.ts b/web/src/plugins/traces/ServicesCatalog.spec.ts index 9575e55d64..1d9aff94e7 100644 --- a/web/src/plugins/traces/ServicesCatalog.spec.ts +++ b/web/src/plugins/traces/ServicesCatalog.spec.ts @@ -1408,6 +1408,48 @@ describe("ServicesCatalog", () => { expect(wrapper.vm.showSidePanel).toBe(false); expect(wrapper.vm.selectedServiceRow).toBeNull(); }); + + it("should switch the open side panel to another row in place", async () => { + mockFetchQueryDataWithHttpStream.mockImplementation((_req: any, callbacks: any) => { + const hits = mockServices.slice(0, 2).map((s) => ({ + service_name: s.service_name, + total_requests: s.total_requests, + error_count: s.error_count, + error_rate: s.error_rate, + avg_duration_ns: s.avg_duration_ns, + max_duration_ns: s.max_duration_ns, + p50_latency_ns: s.p50_latency_ns, + p95_latency_ns: s.p95_latency_ns, + p99_latency_ns: s.p99_latency_ns, + })); + + if (callbacks?.data) { + callbacks.data(null, { + type: "search_response_hits", + content: { results: { hits } }, + }); + } + if (callbacks?.complete) { + callbacks.complete(null, {}); + } + }); + + wrapper = mountServicesCatalog(); + await flushPromises(); + + await wrapper.find('[data-test="services-catalog-row-name-api-gateway"]').trigger("click"); + expect(wrapper.vm.showSidePanel).toBe(true); + expect(wrapper.vm.selectedServiceRow?.service_name).toBe("api-gateway"); + const panelBefore = wrapper.findComponent('[data-test="services-catalog-node-side-panel"]'); + expect(panelBefore.exists()).toBe(true); + + await wrapper.find('[data-test="services-catalog-row-name-auth-service"]').trigger("click"); + expect(wrapper.vm.showSidePanel).toBe(true); + expect(wrapper.vm.selectedServiceRow?.service_name).toBe("auth-service"); + const panelAfter = wrapper.findComponent('[data-test="services-catalog-node-side-panel"]'); + expect(panelAfter.vm).toBe(panelBefore.vm); + expect(panelAfter.props("selectedNode")?.name).toBe("auth-service"); + }); }); // -----------------------------------------------------------------------