mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
fix(app): fix session replay sub-event modal stacking and tab conflict (#2068)
## Summary Clicking a log/error event in the session replay event list either reopened the session replay instead of showing event details, or rendered the detail panel behind the replay drawer. Fixed this by ensuring that isSubPanel is correctly set and using the ZIndexProvider to correctly stack the contexts. ## Steps to Reproduce From the Sessions page: 1. Go to /sessions, select a session source, open a session card 2. In the session replay drawer, wait for the event list to load 3. Click any event row (e.g. a console.error) 4. Bug A: The detail panel opens behind the session replay drawer (overlay darkens but panel is inaccessible), or ESC/close doesn't work correctly From the Search page (URL conflict): 1. Go to /search, open any trace row to open the detail side panel 2. Click the Session Replay tab — this sets sidePanelTab=replay in the URL 3. In the session event list, click any event row 4. Bug B: The inner detail panel opens to the Session Replay tab again instead of event details (e.g. Overview/Trace)
This commit is contained in:
parent
74536b3af5
commit
52986a943c
8 changed files with 169 additions and 13 deletions
5
.changeset/tender-monkeys-drop.md
Normal file
5
.changeset/tender-monkeys-drop.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
Fix bug when accessing session replay panel from search page
|
||||
|
|
@ -62,6 +62,7 @@ const EventRow = React.forwardRef(
|
|||
return (
|
||||
<div
|
||||
data-index={dataIndex}
|
||||
data-testid={`session-event-row-${dataIndex}`}
|
||||
ref={ref}
|
||||
className={cx(styles.eventRow, {
|
||||
[styles.eventRowError]: event.isError,
|
||||
|
|
|
|||
|
|
@ -91,7 +91,10 @@ export default function SessionSidePanel({
|
|||
className="border-start"
|
||||
>
|
||||
<ZIndexContext.Provider value={zIndex}>
|
||||
<div className="d-flex flex-column h-100">
|
||||
<div
|
||||
className="d-flex flex-column h-100"
|
||||
data-testid="session-side-panel"
|
||||
>
|
||||
<div>
|
||||
<div className="p-3 d-flex align-items-center justify-content-between border-bottom border-dark">
|
||||
<div style={{ width: '50%', maxWidth: 500 }}>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import {
|
|||
|
||||
import DBRowSidePanel from '@/components/DBRowSidePanel';
|
||||
import { RowWhereResult, WithClause } from '@/hooks/useRowWhere';
|
||||
import { useZIndex, ZIndexContext } from '@/zIndex';
|
||||
|
||||
import SearchWhereInput from './components/SearchInput/SearchWhereInput';
|
||||
import useFieldExpressionGenerator from './hooks/useFieldExpressionGenerator';
|
||||
|
|
@ -267,6 +268,8 @@ export default function SessionSubpanel({
|
|||
whereLanguage?: SearchConditionLanguage;
|
||||
onLanguageChange?: (lang: 'sql' | 'lucene') => void;
|
||||
}) {
|
||||
const contextZIndex = useZIndex();
|
||||
|
||||
const [rowId, setRowId] = useState<string | undefined>(undefined);
|
||||
const [aliasWith, setAliasWith] = useState<WithClause[]>([]);
|
||||
|
||||
|
|
@ -466,15 +469,18 @@ export default function SessionSubpanel({
|
|||
<div className={styles.wrapper}>
|
||||
{rowId != null && traceSource && (
|
||||
<Portal>
|
||||
<DBRowSidePanel
|
||||
source={traceSource}
|
||||
rowId={rowId}
|
||||
aliasWith={aliasWith}
|
||||
onClose={() => {
|
||||
setDrawerOpen(false);
|
||||
setRowId(undefined);
|
||||
}}
|
||||
/>
|
||||
<ZIndexContext.Provider value={contextZIndex}>
|
||||
<DBRowSidePanel
|
||||
source={traceSource}
|
||||
rowId={rowId}
|
||||
aliasWith={aliasWith}
|
||||
isNestedPanel
|
||||
onClose={() => {
|
||||
setDrawerOpen(false);
|
||||
setRowId(undefined);
|
||||
}}
|
||||
/>
|
||||
</ZIndexContext.Provider>
|
||||
</Portal>
|
||||
)}
|
||||
<div className={cx(styles.eventList, { 'd-none': playerFullWidth })}>
|
||||
|
|
|
|||
|
|
@ -506,9 +506,11 @@ const DBRowSidePanel = ({
|
|||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden flex-grow-1">
|
||||
<div
|
||||
className="overflow-hidden flex-grow-1"
|
||||
data-testid="side-panel-tab-replay"
|
||||
>
|
||||
<DBSessionPanel
|
||||
data-testid="side-panel-tab-replay"
|
||||
dateRange={fourHourRange}
|
||||
focusDate={focusDate}
|
||||
setSubDrawerOpen={setSubDrawerOpen}
|
||||
|
|
@ -590,7 +592,6 @@ export default function DBRowSidePanelErrorBoundary({
|
|||
<Drawer
|
||||
opened={rowId != null}
|
||||
withCloseButton={false}
|
||||
withOverlay={!isNestedPanel}
|
||||
onClose={() => {
|
||||
if (!subDrawerOpen) {
|
||||
_onClose();
|
||||
|
|
|
|||
|
|
@ -39,4 +39,81 @@ test.describe('Client Sessions Functionality', { tag: ['@sessions'] }, () => {
|
|||
await sessionsPage.openFirstSession();
|
||||
});
|
||||
});
|
||||
|
||||
test(
|
||||
'clicking a session event opens the event detail panel with tabs, not another session replay',
|
||||
{ tag: ['@full-stack'] },
|
||||
async ({ page }) => {
|
||||
await test.step('Navigate and open a session (with sidePanelTab=replay pre-set in URL to simulate search-page flow)', async () => {
|
||||
// Pre-set sidePanelTab=replay in the URL to simulate navigating from a search page
|
||||
// row detail panel that had the Session Replay tab open. Without isNestedPanel=true,
|
||||
// the inner DBRowSidePanel would inherit this URL param and open to the Replay tab again.
|
||||
await page.goto('/search');
|
||||
await sessionsPage.goto();
|
||||
await sessionsPage.selectDataSource();
|
||||
await expect(sessionsPage.getFirstSessionCard()).toBeVisible();
|
||||
// Inject sidePanelTab=replay into the URL before opening the session
|
||||
const currentUrl = page.url();
|
||||
await page.goto(
|
||||
currentUrl.includes('?')
|
||||
? `${currentUrl}&sidePanelTab=replay`
|
||||
: `${currentUrl}?sidePanelTab=replay`,
|
||||
);
|
||||
await expect(sessionsPage.getFirstSessionCard()).toBeVisible();
|
||||
await sessionsPage.openFirstSession();
|
||||
});
|
||||
|
||||
await test.step('Wait for session replay drawer and event rows to load', async () => {
|
||||
await expect(sessionsPage.sessionSidePanel).toBeVisible();
|
||||
// Wait for the session event list to populate (routeChange/console.error events are seeded)
|
||||
await expect(sessionsPage.getSessionEventRows().first()).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Click a session event row', async () => {
|
||||
await sessionsPage.clickFirstSessionEvent();
|
||||
});
|
||||
|
||||
await test.step('Event detail panel opens alongside the session replay — not replacing it', async () => {
|
||||
// The row-side-panel must be visible (event detail drawer opened on top of session replay)
|
||||
await expect(sessionsPage.rowSidePanel).toBeVisible();
|
||||
|
||||
// The original session replay panel must still be open (not replaced/closed)
|
||||
await expect(sessionsPage.sessionSidePanel).toBeVisible();
|
||||
|
||||
// Only one session-side-panel must exist (not a second replay opened inside the detail panel)
|
||||
await expect(page.getByTestId('session-side-panel')).toHaveCount(1);
|
||||
|
||||
// The row-side-panel must show the event detail TabBar (Overview, Trace, etc.)
|
||||
// This guards against the regression where the inner panel re-opened session replay
|
||||
// instead of showing event details (which has no TabBar, just the replay player)
|
||||
await expect(
|
||||
sessionsPage.rowSidePanel.getByTestId('side-panel-tabs'),
|
||||
).toBeVisible();
|
||||
|
||||
// The inner panel must NOT be showing the Session Replay tab content.
|
||||
// Without isNestedPanel=true (broken), the inner DBRowSidePanel reads sidePanelTab=replay
|
||||
// from the URL (injected above) and renders the Session Replay tab content (side-panel-tab-replay).
|
||||
// With isNestedPanel=true (fixed), the inner panel uses local state and ignores the URL,
|
||||
// opening to its default tab (Trace/Overview) instead.
|
||||
await expect(
|
||||
sessionsPage.rowSidePanel.getByTestId('side-panel-tab-replay'),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step('Clicking the overlay closes the event detail panel but keeps the session replay open', async () => {
|
||||
// Without the fix, withOverlay={!isNestedPanel} removed the overlay on nested panels,
|
||||
// so there was nothing to click to close the panel (it had to be ESC only).
|
||||
// With the fix (withOverlay always true), clicking the Mantine overlay dismisses the inner panel.
|
||||
await sessionsPage.clickTopmostDrawerOverlay();
|
||||
|
||||
// The event detail panel must close
|
||||
await expect(sessionsPage.rowSidePanel).toBeHidden();
|
||||
|
||||
// The session replay drawer must still be open
|
||||
await expect(sessionsPage.sessionSidePanel).toBeVisible();
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -68,6 +68,46 @@ export class SessionsPage {
|
|||
await this.getFirstSessionCard().click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the session side panel (the replay drawer)
|
||||
*/
|
||||
get sessionSidePanel() {
|
||||
return this.page.getByTestId('session-side-panel');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all session event rows inside the replay drawer
|
||||
*/
|
||||
getSessionEventRows() {
|
||||
return this.page.locator('[data-testid^="session-event-row-"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the first session event row to open its detail panel
|
||||
*/
|
||||
async clickFirstSessionEvent() {
|
||||
await this.getSessionEventRows().first().click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the row side panel (event detail drawer opened from within session replay)
|
||||
*/
|
||||
get rowSidePanel() {
|
||||
return this.page.getByTestId('row-side-panel');
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the Mantine overlay of the topmost open drawer to close it.
|
||||
* Mantine renders one overlay per open Drawer. The last one belongs to
|
||||
* the innermost (topmost) drawer.
|
||||
*/
|
||||
async clickTopmostDrawerOverlay() {
|
||||
// Mantine overlays are siblings of the drawer content inside the portal root.
|
||||
// Use the last one since the inner panel's overlay is rendered on top.
|
||||
const overlay = this.page.locator('.mantine-Drawer-overlay').last();
|
||||
await overlay.click({ position: { x: 10, y: 10 } });
|
||||
}
|
||||
|
||||
// Getters for assertions
|
||||
|
||||
get form() {
|
||||
|
|
|
|||
|
|
@ -321,6 +321,29 @@ function generateSessionTraces(
|
|||
`('${timestampNs}', '${traceId}', '${spanId}', '', '', '${spanName}', 'SPAN_KIND_INTERNAL', 'browser', {'rum.sessionId':'${sessionId}','service.name':'browser'}, '', '', {'component':'${component}','page.url':'https://example.com/dashboard','teamId':'${teamId}','teamName':'${teamName}','userEmail':'${userEmail}','userName':'${userName}'}, 0, '${statusCode}', '', [], [], [], [], [], [], [])`,
|
||||
);
|
||||
}
|
||||
|
||||
// Add visible events for the session event list:
|
||||
// - A routeChange (navigation) event — shown in both Highlighted and All Events tabs
|
||||
// - A console.error event — shown in both tabs
|
||||
const sessionUserIndex = i % 5;
|
||||
const sessionUserEmail = `test${sessionUserIndex}@example.com`;
|
||||
const sessionUserName = `Test User ${sessionUserIndex}`;
|
||||
const sessionTeamId = 'test-team-id';
|
||||
const sessionTeamName = 'Test Team';
|
||||
|
||||
const navTimestampNs = (baseTime - 1000) * 1000000;
|
||||
const navTraceId = `session-nav-${i}`;
|
||||
const navSpanId = `session-nav-span-${i}`;
|
||||
rows.push(
|
||||
`('${navTimestampNs}', '${navTraceId}', '${navSpanId}', '', '', 'routeChange', 'SPAN_KIND_INTERNAL', 'browser', {'rum.sessionId':'${sessionId}','service.name':'browser'}, '', '', {'component':'navigation','location.href':'https://example.com/dashboard','teamId':'${sessionTeamId}','teamName':'${sessionTeamName}','userEmail':'${sessionUserEmail}','userName':'${sessionUserName}'}, 0, 'STATUS_CODE_OK', '', [], [], [], [], [], [], [])`,
|
||||
);
|
||||
|
||||
const errTimestampNs = (baseTime - 2000) * 1000000;
|
||||
const errTraceId = `session-err-${i}`;
|
||||
const errSpanId = `session-err-span-${i}`;
|
||||
rows.push(
|
||||
`('${errTimestampNs}', '${errTraceId}', '${errSpanId}', '', '', 'console.error', 'SPAN_KIND_INTERNAL', 'browser', {'rum.sessionId':'${sessionId}','service.name':'browser'}, '', '', {'component':'error','message':'E2E test error ${i}','teamId':'${sessionTeamId}','teamName':'${sessionTeamName}','userEmail':'${sessionUserEmail}','userName':'${sessionUserName}'}, 0, 'STATUS_CODE_ERROR', '', [], [], [], [], [], [], [])`,
|
||||
);
|
||||
}
|
||||
|
||||
return rows.join(',\n');
|
||||
|
|
|
|||
Loading…
Reference in a new issue