Skip to content

Commit e6e0907

Browse files
authored
feat(app): close search & session drawers on outside click (#2682)
## Why When a detail drawer is open on the **Search** page, clicking outside of the logs table should dismiss it. Same for the session drawer on the **Sessions** page. Previously these drawers only closed via the X button, Esc, or breadcrumb navigation. These drawers are Mantine `Drawer`s rendered with `withOverlay={false}` (so the page stays interactive behind them), which means there is no overlay to catch outside clicks — hence Mantine's built-in `closeOnClickOutside` does nothing. ## What Added a small reusable hook, `useCloseOnClickOutside`, and wired it into the row-table / session detail drawers. It's **on by default** (opt out with `closeOnClickOutside={false}`). - **Search**: `DBSqlRowTableWithSidebar` (row detail drawer) and `DirectTraceSidePanel`. Keep-open zone = the results panel (`[data-testid="search-results-panel"]`). - **Sessions**: `SessionSidePanel`. Keep-open zone = the session list (`[data-testid="session-card-list"]`). Gated to the root view (`!selectedEvent`), mirroring the existing Esc behavior so deep in-panel navigation isn't skipped. - **Default keep-open zone**: `DBSqlRowTableWithSidebar` defaults its keep-open zone to its own results table (`[data-testid="search-results-table"]`), so every consumer gets clean row-switching without a page needing to opt in. ### Behavior - Clicking a different row/session switches the panel. - Clicking inside the drawer or any popup it spawns (Select dropdown, date picker, nested modal) keeps it open — the hook treats Mantine floating layers (`role="dialog"`/`tooltip`/`listbox`/`menu`, dropdown classes, overlays) as "inside", which is what makes it robust against portaled content. - Clicking anywhere else on the page (search bar, filters, chart, empty space) closes it. ### Reuse safety `DBSqlRowTableWithSidebar` / `DBRowSidePanel` are reused in several places. The other consumers (`PodDetailsSidePanel`, `PatternSidePanel`, K8s node/namespace/pod detail panels) render them inside their own Mantine `Drawer` (`role="dialog"`), so the floating-layer exemption neutralizes the behavior there — clicks inside the parent drawer count as "inside". Dashboards render the table directly, where click-outside-to-close is the desired behavior and the default table keep-open zone prevents any close/reopen jank. ### Note / possible follow-up On Search, "outside of the logs table" is taken fairly literally: the keep-open zone is the results panel (table + find-in-page), so clicking the histogram/stats above it also closes the drawer. If we'd rather keep it open across the whole results area, that's a one-line change to which element carries the `data-testid`. ## Testing - New unit test `useCloseOnClickOutside.test.tsx` (4 cases) — passing. - Existing `SessionSidePanel`, `DirectTraceSidePanel`, `DBSearchPage.directTrace`, and `DBRowSidePanel` suites — passing (30 tests across 7 suites). - Lint clean on changed files; changeset included. Made with [Cursor](https://cursor.com)
1 parent de2c8a0 commit e6e0907

9 files changed

Lines changed: 253 additions & 2 deletions
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@hyperdx/app': patch
3+
---
4+
5+
Detail drawers now close when you click outside of them. On Search and Sessions,
6+
clicking outside the results table / session list dismisses the open drawer;
7+
clicks inside the drawer, its nested popups/modals, or the results table keep it
8+
open. This is on by default for row-table side panels (opt out with
9+
`closeOnClickOutside={false}`).

‎packages/app/src/DBSearchPage.tsx‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,12 @@ type SearchConfigFromSchema = z.infer<typeof SearchConfigSchema>;
202202

203203
const QUERY_KEY_PREFIX = 'search';
204204

205+
// Clicks inside the results panel keep the row side panel open (so users can
206+
// scroll the table or select a different row); clicks anywhere else on the page
207+
// dismiss it.
208+
const SEARCH_RESULTS_PANEL_KEEP_OPEN_SELECTOR =
209+
'[data-testid="search-results-panel"]';
210+
205211
// Helper function to get the default source id
206212
export function getDefaultSourceId(
207213
sources: { id: string; disabled?: boolean }[] | undefined,
@@ -2297,6 +2303,7 @@ export function DBSearchPage() {
22972303
focusDate={directTraceFocusDate}
22982304
onClose={closeDirectTraceSidePanel}
22992305
onSourceChange={onDirectTraceSourceChange}
2306+
keepOpenSelector={SEARCH_RESULTS_PANEL_KEEP_OPEN_SELECTOR}
23002307
/>
23012308
<Flex
23022309
direction="column"
@@ -2613,7 +2620,12 @@ export function DBSearchPage() {
26132620
</div>
26142621
</>
26152622
) : (
2616-
<Box flex="1" mih="0" px="sm">
2623+
<Box
2624+
flex="1"
2625+
mih="0"
2626+
px="sm"
2627+
data-testid="search-results-panel"
2628+
>
26172629
{chartConfig &&
26182630
searchedConfig.source &&
26192631
dbSqlRowTableConfig && (
@@ -2622,6 +2634,9 @@ export function DBSearchPage() {
26222634
config={dbSqlRowTableConfig}
26232635
sourceId={searchedConfig.source}
26242636
tableId={columnSizeTableId}
2637+
keepOpenSelector={
2638+
SEARCH_RESULTS_PANEL_KEEP_OPEN_SELECTOR
2639+
}
26252640
onSidebarOpen={onSidebarOpen}
26262641
onExpandedRowsChange={onExpandedRowsChange}
26272642
enabled={isReady}

‎packages/app/src/SessionSidePanel.tsx‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
import SidePanelBreadcrumbs, {
3838
BreadcrumbItem,
3939
} from '@/components/SidePanelBreadcrumbs';
40+
import { useCloseOnClickOutside } from '@/hooks/useCloseOnClickOutside';
4041
import useResizable from '@/hooks/useResizable';
4142
import { WithClause } from '@/hooks/useRowWhere';
4243
import {
@@ -79,6 +80,8 @@ export default function SessionSidePanel({
7980
onLanguageChange,
8081
onClose,
8182
zIndex = 100,
83+
closeOnClickOutside = true,
84+
keepOpenSelector,
8285
}: {
8386
traceSource: TTraceSource;
8487
sessionSource: TSessionSource;
@@ -90,6 +93,8 @@ export default function SessionSidePanel({
9093
onLanguageChange?: (lang: 'sql' | 'lucene') => void;
9194
onClose: () => void;
9295
zIndex?: number;
96+
closeOnClickOutside?: boolean;
97+
keepOpenSelector?: string;
9398
}) {
9499
// A single in-place event view (session → event), persisted to the URL so it
95100
// survives reload and shared links. Deeper navigation (View Trace,
@@ -146,6 +151,14 @@ export default function SessionSidePanel({
146151

147152
useHotkeys(['esc'], handleClose, { enabled: !selectedEvent });
148153

154+
// Match the Esc behavior: dismiss on outside click only at the session root,
155+
// so deep in-panel navigation (event → trace → context) isn't skipped.
156+
useCloseOnClickOutside({
157+
enabled: closeOnClickOutside && sessionId != null && !selectedEvent,
158+
keepOpenSelector,
159+
onClose: handleClose,
160+
});
161+
149162
const shareSession = useCallback(async () => {
150163
const ok = await copyTextToClipboard(window.location.href);
151164
notifications.show(

‎packages/app/src/SessionsPage.tsx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,10 @@ function SessionCardList({
215215
);
216216
}
217217

218+
// Clicks inside the session list keep the session side panel open (so users can
219+
// scroll or pick a different session); clicks anywhere else dismiss it.
220+
const SESSION_LIST_KEEP_OPEN_SELECTOR = '[data-testid="session-card-list"]';
221+
218222
// TODO: This is a hack to set the default time range
219223
const defaultTimeRange = parseTimeQuery('Past 1h', false) as [Date, Date];
220224
const selectedSessionQueryStateMap = {
@@ -382,6 +386,7 @@ function SessionsPage() {
382386
onClose={() => {
383387
setSelectedSession(undefined);
384388
}}
389+
keepOpenSelector={SESSION_LIST_KEEP_OPEN_SELECTOR}
385390
whereLanguage={whereLanguage || undefined}
386391
where={where || undefined}
387392
onLanguageChange={lang =>
@@ -474,7 +479,10 @@ function SessionsPage() {
474479
<SessionSetupInstructions />
475480
</Flex>
476481
) : (
477-
<div style={{ minHeight: 0 }}>
482+
<div
483+
style={{ minHeight: 0 }}
484+
data-testid="session-card-list"
485+
>
478486
<SessionCardList
479487
onClick={session => {
480488
setSelectedSession(session);

‎packages/app/src/components/DBRowSidePanel.tsx‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
} from '@mantine/core';
3636
import { IconCopy, IconKeyboard, IconShare, IconX } from '@tabler/icons-react';
3737

38+
import { useCloseOnClickOutside } from '@/hooks/useCloseOnClickOutside';
3839
import useResizable from '@/hooks/useResizable';
3940
import { WithClause } from '@/hooks/useRowWhere';
4041
import useSidePanelStack, {
@@ -201,6 +202,10 @@ type DBRowSidePanelProps = {
201202
rowId: string | undefined;
202203
aliasWith?: WithClause[];
203204
onClose: () => void;
205+
// Clicking outside the drawer (and outside `keepOpenSelector`) closes it.
206+
// Enabled by default; pass `false` to opt out.
207+
closeOnClickOutside?: boolean;
208+
keepOpenSelector?: string;
204209
};
205210

206211
type DBRowSidePanelInnerProps = DBRowSidePanelProps & {
@@ -1067,6 +1072,8 @@ export default function DBRowSidePanelErrorBoundary({
10671072
rowId,
10681073
aliasWith,
10691074
source,
1075+
closeOnClickOutside = true,
1076+
keepOpenSelector,
10701077
}: DBRowSidePanelProps) {
10711078
const contextZIndex = useZIndex();
10721079
const drawerZIndex = contextZIndex + 10;
@@ -1093,6 +1100,20 @@ export default function DBRowSidePanelErrorBoundary({
10931100
onClose();
10941101
}, [sidePanelStack, onClose, clearTraceWaterfallSearchState]);
10951102

1103+
useCloseOnClickOutside({
1104+
// Only close on outside click at the root level. When the user has
1105+
// navigated deeper (e.g. log row -> trace), Esc pops one level at a time
1106+
// via handlePanelBack; an outside click should not skip those levels and
1107+
// close the drawer entirely.
1108+
enabled:
1109+
closeOnClickOutside &&
1110+
rowId != null &&
1111+
sidePanelStack.sourceStack.length === 0 &&
1112+
sidePanelStack.navStack.length === 0,
1113+
keepOpenSelector,
1114+
onClose: _onClose,
1115+
});
1116+
10961117
return (
10971118
<Drawer
10981119
opened={rowId != null}

‎packages/app/src/components/DBSqlRowTableWithSidebar.tsx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,16 @@ interface Props {
4646
tableId?: string;
4747
errorVariant?: ChartErrorStateVariant;
4848
onResolvedColumnsChange?: (meta: ColumnMetaType[]) => void;
49+
// Clicking outside the row side panel (and outside `keepOpenSelector`) closes
50+
// it. Enabled by default; pass `false` to opt out.
51+
closeOnClickOutside?: boolean;
52+
keepOpenSelector?: string;
4953
}
5054

55+
// Clicking the results table (selecting/switching rows, scrolling) keeps the
56+
// row side panel open by default; callers can widen this via `keepOpenSelector`.
57+
const DEFAULT_KEEP_OPEN_SELECTOR = '[data-testid="search-results-table"]';
58+
5159
export default function DBSqlRowTableWithSideBar({
5260
sourceId,
5361
config,
@@ -67,6 +75,8 @@ export default function DBSqlRowTableWithSideBar({
6775
tableId,
6876
errorVariant,
6977
onResolvedColumnsChange,
78+
closeOnClickOutside = true,
79+
keepOpenSelector = DEFAULT_KEEP_OPEN_SELECTOR,
7080
}: Props) {
7181
const { data: sourceData } = useSource({ id: sourceId });
7282
const [rowId, setRowId] = useQueryState('rowWhere', parseAsStringEncoded);
@@ -111,6 +121,8 @@ export default function DBSqlRowTableWithSideBar({
111121
rowId={rowId ?? undefined}
112122
aliasWith={aliasWith}
113123
onClose={onCloseSidebar}
124+
closeOnClickOutside={closeOnClickOutside}
125+
keepOpenSelector={keepOpenSelector}
114126
/>
115127
)}
116128
<DBSqlRowTable

‎packages/app/src/components/Search/DirectTraceSidePanel.tsx‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { IconConnection } from '@tabler/icons-react';
77
import DBTracePanel from '@/components/DBTracePanel';
88
import EmptyState from '@/components/EmptyState';
99
import { SourceSelectControlled } from '@/components/SourceSelect';
10+
import { useCloseOnClickOutside } from '@/hooks/useCloseOnClickOutside';
1011
import { useSource } from '@/source';
1112

1213
interface DirectTraceSidePanelProps {
@@ -17,6 +18,8 @@ interface DirectTraceSidePanelProps {
1718
focusDate: Date;
1819
onClose: () => void;
1920
onSourceChange: (sourceId: string | null) => void;
21+
closeOnClickOutside?: boolean;
22+
keepOpenSelector?: string;
2023
}
2124

2225
export default function DirectTraceSidePanel({
@@ -27,7 +30,15 @@ export default function DirectTraceSidePanel({
2730
focusDate,
2831
onClose,
2932
onSourceChange,
33+
closeOnClickOutside = true,
34+
keepOpenSelector,
3035
}: DirectTraceSidePanelProps) {
36+
useCloseOnClickOutside({
37+
enabled: closeOnClickOutside && opened,
38+
keepOpenSelector,
39+
onClose,
40+
});
41+
3142
const { control, setValue } = useForm<{ source: string | null }>({
3243
defaultValues: {
3344
source: traceSourceId ?? null,
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { act, renderHook } from '@testing-library/react';
2+
3+
import { useCloseOnClickOutside } from '@/hooks/useCloseOnClickOutside';
4+
5+
function mouseDownOn(el: Element) {
6+
act(() => {
7+
el.dispatchEvent(
8+
new MouseEvent('mousedown', { bubbles: true, cancelable: true }),
9+
);
10+
});
11+
}
12+
13+
describe('useCloseOnClickOutside', () => {
14+
afterEach(() => {
15+
document.body.innerHTML = '';
16+
});
17+
18+
it('closes when clicking a plain element outside the safe zones', () => {
19+
const outside = document.createElement('div');
20+
document.body.appendChild(outside);
21+
22+
const onClose = jest.fn();
23+
renderHook(() => useCloseOnClickOutside({ enabled: true, onClose }));
24+
25+
mouseDownOn(outside);
26+
expect(onClose).toHaveBeenCalledTimes(1);
27+
});
28+
29+
it('does not close when clicking inside the keepOpen selector', () => {
30+
const table = document.createElement('div');
31+
table.setAttribute('data-testid', 'results');
32+
const cell = document.createElement('span');
33+
table.appendChild(cell);
34+
document.body.appendChild(table);
35+
36+
const onClose = jest.fn();
37+
renderHook(() =>
38+
useCloseOnClickOutside({
39+
enabled: true,
40+
keepOpenSelector: '[data-testid="results"]',
41+
onClose,
42+
}),
43+
);
44+
45+
mouseDownOn(cell);
46+
expect(onClose).not.toHaveBeenCalled();
47+
});
48+
49+
it('does not close when clicking inside a floating layer (dialog/dropdown)', () => {
50+
const dialog = document.createElement('div');
51+
dialog.setAttribute('role', 'dialog');
52+
const dialogChild = document.createElement('button');
53+
dialog.appendChild(dialogChild);
54+
55+
const dropdown = document.createElement('div');
56+
dropdown.className = 'mantine-Select-dropdown';
57+
const option = document.createElement('div');
58+
dropdown.appendChild(option);
59+
60+
document.body.append(dialog, dropdown);
61+
62+
const onClose = jest.fn();
63+
renderHook(() => useCloseOnClickOutside({ enabled: true, onClose }));
64+
65+
mouseDownOn(dialogChild);
66+
mouseDownOn(option);
67+
expect(onClose).not.toHaveBeenCalled();
68+
});
69+
70+
it('does nothing while disabled and detaches its listener on cleanup', () => {
71+
const outside = document.createElement('div');
72+
document.body.appendChild(outside);
73+
74+
const onClose = jest.fn();
75+
const { rerender, unmount } = renderHook(
76+
({ enabled }) => useCloseOnClickOutside({ enabled, onClose }),
77+
{ initialProps: { enabled: false } },
78+
);
79+
80+
mouseDownOn(outside);
81+
expect(onClose).not.toHaveBeenCalled();
82+
83+
rerender({ enabled: true });
84+
mouseDownOn(outside);
85+
expect(onClose).toHaveBeenCalledTimes(1);
86+
87+
unmount();
88+
mouseDownOn(outside);
89+
expect(onClose).toHaveBeenCalledTimes(1);
90+
});
91+
});

0 commit comments

Comments
 (0)