Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/close-drawer-on-outside-click.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@hyperdx/app': patch
---

Detail drawers now close when you click outside of them. On Search and Sessions,
clicking outside the results table / session list dismisses the open drawer;
clicks inside the drawer, its nested popups/modals, or the results table keep it
open. This is on by default for row-table side panels (opt out with
`closeOnClickOutside={false}`).
17 changes: 16 additions & 1 deletion packages/app/src/DBSearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,12 @@ type SearchConfigFromSchema = z.infer<typeof SearchConfigSchema>;

const QUERY_KEY_PREFIX = 'search';

// Clicks inside the results panel keep the row side panel open (so users can
// scroll the table or select a different row); clicks anywhere else on the page
// dismiss it.
const SEARCH_RESULTS_PANEL_KEEP_OPEN_SELECTOR =
'[data-testid="search-results-panel"]';

// Helper function to get the default source id
export function getDefaultSourceId(
sources: { id: string; disabled?: boolean }[] | undefined,
Expand Down Expand Up @@ -2297,6 +2303,7 @@ export function DBSearchPage() {
focusDate={directTraceFocusDate}
onClose={closeDirectTraceSidePanel}
onSourceChange={onDirectTraceSourceChange}
keepOpenSelector={SEARCH_RESULTS_PANEL_KEEP_OPEN_SELECTOR}
/>
<Flex
direction="column"
Expand Down Expand Up @@ -2613,7 +2620,12 @@ export function DBSearchPage() {
</div>
</>
) : (
<Box flex="1" mih="0" px="sm">
<Box
flex="1"
mih="0"
px="sm"
data-testid="search-results-panel"
>
{chartConfig &&
searchedConfig.source &&
dbSqlRowTableConfig && (
Expand All @@ -2622,6 +2634,9 @@ export function DBSearchPage() {
config={dbSqlRowTableConfig}
sourceId={searchedConfig.source}
tableId={columnSizeTableId}
keepOpenSelector={
SEARCH_RESULTS_PANEL_KEEP_OPEN_SELECTOR
}
onSidebarOpen={onSidebarOpen}
onExpandedRowsChange={onExpandedRowsChange}
enabled={isReady}
Expand Down
13 changes: 13 additions & 0 deletions packages/app/src/SessionSidePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
import SidePanelBreadcrumbs, {
BreadcrumbItem,
} from '@/components/SidePanelBreadcrumbs';
import { useCloseOnClickOutside } from '@/hooks/useCloseOnClickOutside';
import useResizable from '@/hooks/useResizable';
import { WithClause } from '@/hooks/useRowWhere';
import {
Expand Down Expand Up @@ -79,6 +80,8 @@ export default function SessionSidePanel({
onLanguageChange,
onClose,
zIndex = 100,
closeOnClickOutside = true,
keepOpenSelector,
}: {
traceSource: TTraceSource;
sessionSource: TSessionSource;
Expand All @@ -90,6 +93,8 @@ export default function SessionSidePanel({
onLanguageChange?: (lang: 'sql' | 'lucene') => void;
onClose: () => void;
zIndex?: number;
closeOnClickOutside?: boolean;
keepOpenSelector?: string;
}) {
// A single in-place event view (session → event), persisted to the URL so it
// survives reload and shared links. Deeper navigation (View Trace,
Expand Down Expand Up @@ -146,6 +151,14 @@ export default function SessionSidePanel({

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

// Match the Esc behavior: dismiss on outside click only at the session root,
// so deep in-panel navigation (event → trace → context) isn't skipped.
useCloseOnClickOutside({
enabled: closeOnClickOutside && sessionId != null && !selectedEvent,
keepOpenSelector,
onClose: handleClose,
});

const shareSession = useCallback(async () => {
const ok = await copyTextToClipboard(window.location.href);
notifications.show(
Expand Down
10 changes: 9 additions & 1 deletion packages/app/src/SessionsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,10 @@ function SessionCardList({
);
}

// Clicks inside the session list keep the session side panel open (so users can
// scroll or pick a different session); clicks anywhere else dismiss it.
const SESSION_LIST_KEEP_OPEN_SELECTOR = '[data-testid="session-card-list"]';

// TODO: This is a hack to set the default time range
const defaultTimeRange = parseTimeQuery('Past 1h', false) as [Date, Date];
const selectedSessionQueryStateMap = {
Expand Down Expand Up @@ -382,6 +386,7 @@ function SessionsPage() {
onClose={() => {
setSelectedSession(undefined);
}}
keepOpenSelector={SESSION_LIST_KEEP_OPEN_SELECTOR}
whereLanguage={whereLanguage || undefined}
where={where || undefined}
onLanguageChange={lang =>
Expand Down Expand Up @@ -474,7 +479,10 @@ function SessionsPage() {
<SessionSetupInstructions />
</Flex>
) : (
<div style={{ minHeight: 0 }}>
<div
style={{ minHeight: 0 }}
data-testid="session-card-list"
>
<SessionCardList
onClick={session => {
setSelectedSession(session);
Expand Down
21 changes: 21 additions & 0 deletions packages/app/src/components/DBRowSidePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
} from '@mantine/core';
import { IconCopy, IconKeyboard, IconShare, IconX } from '@tabler/icons-react';

import { useCloseOnClickOutside } from '@/hooks/useCloseOnClickOutside';
import useResizable from '@/hooks/useResizable';
import { WithClause } from '@/hooks/useRowWhere';
import useSidePanelStack, {
Expand Down Expand Up @@ -201,6 +202,10 @@ type DBRowSidePanelProps = {
rowId: string | undefined;
aliasWith?: WithClause[];
onClose: () => void;
// Clicking outside the drawer (and outside `keepOpenSelector`) closes it.
// Enabled by default; pass `false` to opt out.
closeOnClickOutside?: boolean;
keepOpenSelector?: string;
};

type DBRowSidePanelInnerProps = DBRowSidePanelProps & {
Expand Down Expand Up @@ -1067,6 +1072,8 @@ export default function DBRowSidePanelErrorBoundary({
rowId,
aliasWith,
source,
closeOnClickOutside = true,
keepOpenSelector,
}: DBRowSidePanelProps) {
const contextZIndex = useZIndex();
const drawerZIndex = contextZIndex + 10;
Expand All @@ -1093,6 +1100,20 @@ export default function DBRowSidePanelErrorBoundary({
onClose();
}, [sidePanelStack, onClose, clearTraceWaterfallSearchState]);

useCloseOnClickOutside({
// Only close on outside click at the root level. When the user has
// navigated deeper (e.g. log row -> trace), Esc pops one level at a time
// via handlePanelBack; an outside click should not skip those levels and
// close the drawer entirely.
enabled:
closeOnClickOutside &&
rowId != null &&
sidePanelStack.sourceStack.length === 0 &&
sidePanelStack.navStack.length === 0,
keepOpenSelector,
onClose: _onClose,
});
Comment thread
greptile-apps[bot] marked this conversation as resolved.

return (
<Drawer
opened={rowId != null}
Expand Down
12 changes: 12 additions & 0 deletions packages/app/src/components/DBSqlRowTableWithSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,16 @@ interface Props {
tableId?: string;
errorVariant?: ChartErrorStateVariant;
onResolvedColumnsChange?: (meta: ColumnMetaType[]) => void;
// Clicking outside the row side panel (and outside `keepOpenSelector`) closes
// it. Enabled by default; pass `false` to opt out.
closeOnClickOutside?: boolean;
keepOpenSelector?: string;
}

// Clicking the results table (selecting/switching rows, scrolling) keeps the
// row side panel open by default; callers can widen this via `keepOpenSelector`.
const DEFAULT_KEEP_OPEN_SELECTOR = '[data-testid="search-results-table"]';

export default function DBSqlRowTableWithSideBar({
sourceId,
config,
Expand All @@ -67,6 +75,8 @@ export default function DBSqlRowTableWithSideBar({
tableId,
errorVariant,
onResolvedColumnsChange,
closeOnClickOutside = true,
keepOpenSelector = DEFAULT_KEEP_OPEN_SELECTOR,
}: Props) {
const { data: sourceData } = useSource({ id: sourceId });
const [rowId, setRowId] = useQueryState('rowWhere', parseAsStringEncoded);
Expand Down Expand Up @@ -111,6 +121,8 @@ export default function DBSqlRowTableWithSideBar({
rowId={rowId ?? undefined}
aliasWith={aliasWith}
onClose={onCloseSidebar}
closeOnClickOutside={closeOnClickOutside}
keepOpenSelector={keepOpenSelector}
/>
)}
<DBSqlRowTable
Expand Down
11 changes: 11 additions & 0 deletions packages/app/src/components/Search/DirectTraceSidePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { IconConnection } from '@tabler/icons-react';
import DBTracePanel from '@/components/DBTracePanel';
import EmptyState from '@/components/EmptyState';
import { SourceSelectControlled } from '@/components/SourceSelect';
import { useCloseOnClickOutside } from '@/hooks/useCloseOnClickOutside';
import { useSource } from '@/source';

interface DirectTraceSidePanelProps {
Expand All @@ -17,6 +18,8 @@ interface DirectTraceSidePanelProps {
focusDate: Date;
onClose: () => void;
onSourceChange: (sourceId: string | null) => void;
closeOnClickOutside?: boolean;
keepOpenSelector?: string;
}

export default function DirectTraceSidePanel({
Expand All @@ -27,7 +30,15 @@ export default function DirectTraceSidePanel({
focusDate,
onClose,
onSourceChange,
closeOnClickOutside = true,
keepOpenSelector,
}: DirectTraceSidePanelProps) {
useCloseOnClickOutside({
enabled: closeOnClickOutside && opened,
keepOpenSelector,
onClose,
});

const { control, setValue } = useForm<{ source: string | null }>({
defaultValues: {
source: traceSourceId ?? null,
Expand Down
91 changes: 91 additions & 0 deletions packages/app/src/hooks/__tests__/useCloseOnClickOutside.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { act, renderHook } from '@testing-library/react';

import { useCloseOnClickOutside } from '@/hooks/useCloseOnClickOutside';

function mouseDownOn(el: Element) {
act(() => {
el.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true, cancelable: true }),
);
});
}

describe('useCloseOnClickOutside', () => {
afterEach(() => {
document.body.innerHTML = '';
});

it('closes when clicking a plain element outside the safe zones', () => {
const outside = document.createElement('div');
document.body.appendChild(outside);

const onClose = jest.fn();
renderHook(() => useCloseOnClickOutside({ enabled: true, onClose }));

mouseDownOn(outside);
expect(onClose).toHaveBeenCalledTimes(1);
});

it('does not close when clicking inside the keepOpen selector', () => {
const table = document.createElement('div');
table.setAttribute('data-testid', 'results');
const cell = document.createElement('span');
table.appendChild(cell);
document.body.appendChild(table);

const onClose = jest.fn();
renderHook(() =>
useCloseOnClickOutside({
enabled: true,
keepOpenSelector: '[data-testid="results"]',
onClose,
}),
);

mouseDownOn(cell);
expect(onClose).not.toHaveBeenCalled();
});

it('does not close when clicking inside a floating layer (dialog/dropdown)', () => {
const dialog = document.createElement('div');
dialog.setAttribute('role', 'dialog');
const dialogChild = document.createElement('button');
dialog.appendChild(dialogChild);

const dropdown = document.createElement('div');
dropdown.className = 'mantine-Select-dropdown';
const option = document.createElement('div');
dropdown.appendChild(option);

document.body.append(dialog, dropdown);

const onClose = jest.fn();
renderHook(() => useCloseOnClickOutside({ enabled: true, onClose }));

mouseDownOn(dialogChild);
mouseDownOn(option);
expect(onClose).not.toHaveBeenCalled();
});

it('does nothing while disabled and detaches its listener on cleanup', () => {
const outside = document.createElement('div');
document.body.appendChild(outside);

const onClose = jest.fn();
const { rerender, unmount } = renderHook(
({ enabled }) => useCloseOnClickOutside({ enabled, onClose }),
{ initialProps: { enabled: false } },
);

mouseDownOn(outside);
expect(onClose).not.toHaveBeenCalled();

rerender({ enabled: true });
mouseDownOn(outside);
expect(onClose).toHaveBeenCalledTimes(1);

unmount();
mouseDownOn(outside);
expect(onClose).toHaveBeenCalledTimes(1);
});
});
Loading
Loading