mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat(app): keyboard shortcuts modal from Help menu (#2064)
## Why Users had no in-app list of keyboard shortcuts. Documenting them under **Help** makes discoverability match other support links and reduces guesswork (e.g. command palette vs search bar). <img width="467" height="201" alt="Screenshot 2026-04-07 at 17 23 40" src="https://github.com/user-attachments/assets/f03e3379-0feb-4912-8f3a-89fca338d02a" /> <img width="901" height="1003" alt="Screenshot 2026-04-07 at 17 23 46" src="https://github.com/user-attachments/assets/d3f7bb0f-3a10-44c2-8e5f-7259382652b8" /> ## What - **Help → Keyboard shortcuts** opens a modal with shortcuts gathered from current hotkey usage (Spotlight ⌘/Ctrl+K, `/` and `s` for search/WHERE, time picker, find-in-table, log navigation, trace timeline, dashboards, etc.). - Help dropdown order: documentation and setup first, then shortcuts, then Discord. - Modal: comfortable width, dividers between rows, **or** vs **+** for alternatives vs chords. - E2E: help menu includes the new item and opens the modal. - Changeset: `@hyperdx/app` patch. ## Test plan - [ ] Open Help → Keyboard shortcuts; confirm list and close behavior. - [ ] CI: rely on PR checks (`make ci-lint` / `make ci-unit` if running locally).
This commit is contained in:
parent
ffc961c621
commit
ad71dc2e91
4 changed files with 202 additions and 56 deletions
9
.changeset/help-keyboard-shortcuts-modal.md
Normal file
9
.changeset/help-keyboard-shortcuts-modal.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: Add keyboard shortcuts modal from the Help menu
|
||||
|
||||
- New **Keyboard shortcuts** item opens a modal documenting app shortcuts (command palette ⌘/Ctrl+K, search focus, time picker, tables, traces, dashboards, and more).
|
||||
- Help menu items ordered by importance (documentation and setup before shortcuts and community).
|
||||
- Shortcuts modal uses a readable width, row dividers, and **or** vs **+** labels so alternative keys are not confused with key chords.
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import {
|
||||
IconBook,
|
||||
IconBrandDiscord,
|
||||
|
|
@ -20,6 +21,7 @@ import {
|
|||
IconChevronRight,
|
||||
IconChevronUp,
|
||||
IconHelp,
|
||||
IconKeyboard,
|
||||
IconLogout,
|
||||
IconSettings,
|
||||
IconUserCog,
|
||||
|
|
@ -27,6 +29,8 @@ import {
|
|||
|
||||
import { IS_LOCAL_MODE } from '@/config';
|
||||
|
||||
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
|
||||
|
||||
import styles from './AppNav.module.scss';
|
||||
|
||||
export const AppNavContext = React.createContext<{
|
||||
|
|
@ -176,64 +180,84 @@ export const AppNavUserMenu = ({
|
|||
|
||||
export const AppNavHelpMenu = ({ version }: { version?: string }) => {
|
||||
const { isCollapsed } = React.useContext(AppNavContext);
|
||||
const [
|
||||
shortcutsOpened,
|
||||
{ open: openShortcutsModal, close: closeShortcutsModal },
|
||||
] = useDisclosure(false);
|
||||
|
||||
return (
|
||||
<Menu position="right-start" transitionProps={{ transition: 'fade-right' }}>
|
||||
<Menu.Target>
|
||||
<UnstyledButton
|
||||
data-testid="help-menu-trigger"
|
||||
className={styles.navItem}
|
||||
>
|
||||
<span className={styles.navItemContent}>
|
||||
<span className={styles.navItemIcon}>
|
||||
<IconHelp size={16} />
|
||||
<>
|
||||
<Menu
|
||||
position="right-start"
|
||||
transitionProps={{ transition: 'fade-right' }}
|
||||
>
|
||||
<Menu.Target>
|
||||
<UnstyledButton
|
||||
data-testid="help-menu-trigger"
|
||||
className={styles.navItem}
|
||||
>
|
||||
<span className={styles.navItemContent}>
|
||||
<span className={styles.navItemIcon}>
|
||||
<IconHelp size={16} />
|
||||
</span>
|
||||
{!isCollapsed && <span>Help</span>}
|
||||
</span>
|
||||
{!isCollapsed && <span>Help</span>}
|
||||
</span>
|
||||
</UnstyledButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>
|
||||
Help{' '}
|
||||
{version && (
|
||||
<Text size="xs" component="span">
|
||||
v{version}
|
||||
</Text>
|
||||
)}
|
||||
</Menu.Label>
|
||||
</UnstyledButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>
|
||||
Help{' '}
|
||||
{version && (
|
||||
<Text size="xs" component="span">
|
||||
v{version}
|
||||
</Text>
|
||||
)}
|
||||
</Menu.Label>
|
||||
|
||||
<Menu.Item
|
||||
data-testid="documentation-menu-item"
|
||||
href="https://clickhouse.com/docs/use-cases/observability/clickstack"
|
||||
component="a"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
leftSection={<IconBook size={16} />}
|
||||
>
|
||||
Documentation
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
data-testid="discord-menu-item"
|
||||
leftSection={<IconBrandDiscord size={16} />}
|
||||
component="a"
|
||||
href="https://hyperdx.io/discord"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Discord Community
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
data-testid="setup-instructions-menu-item"
|
||||
leftSection={<IconBulb size={16} />}
|
||||
href="https://clickhouse.com/docs/use-cases/observability/clickstack/getting-started"
|
||||
component="a"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Setup Instructions
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<Menu.Item
|
||||
data-testid="documentation-menu-item"
|
||||
href="https://clickhouse.com/docs/use-cases/observability/clickstack"
|
||||
component="a"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
leftSection={<IconBook size={16} />}
|
||||
>
|
||||
Documentation
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
data-testid="setup-instructions-menu-item"
|
||||
leftSection={<IconBulb size={16} />}
|
||||
href="https://clickhouse.com/docs/use-cases/observability/clickstack/getting-started"
|
||||
component="a"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Setup Instructions
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
data-testid="keyboard-shortcuts-menu-item"
|
||||
leftSection={<IconKeyboard size={16} />}
|
||||
onClick={openShortcutsModal}
|
||||
>
|
||||
Keyboard shortcuts
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
data-testid="discord-menu-item"
|
||||
leftSection={<IconBrandDiscord size={16} />}
|
||||
component="a"
|
||||
href="https://hyperdx.io/discord"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Discord Community
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<KeyboardShortcutsModal
|
||||
opened={shortcutsOpened}
|
||||
onClose={closeShortcutsModal}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
import React from 'react';
|
||||
import { Divider, Group, Kbd, Modal, Stack, Text } from '@mantine/core';
|
||||
|
||||
type ShortcutRow = {
|
||||
keys: readonly string[];
|
||||
label: string;
|
||||
/** Use either key. Default is `chord` (+) for combinations pressed together. */
|
||||
keyJoin?: 'or' | 'chord';
|
||||
};
|
||||
|
||||
const SHORTCUTS: ShortcutRow[] = [
|
||||
{ keys: ['⌘/Ctrl', 'k'], label: 'Open command palette' },
|
||||
{
|
||||
keys: ['/', 's'],
|
||||
label: 'Focus search or WHERE editor',
|
||||
keyJoin: 'or',
|
||||
},
|
||||
{ keys: ['d'], label: 'Open time range picker' },
|
||||
{
|
||||
keys: ['Enter'],
|
||||
label: 'Apply custom time range (when time picker is open)',
|
||||
},
|
||||
{ keys: ['⌘/Ctrl', 'f'], label: 'Find in log table' },
|
||||
{ keys: ['Enter'], label: 'Next find match (find bar open)' },
|
||||
{ keys: ['Shift', 'Enter'], label: 'Previous find match (find bar open)' },
|
||||
{
|
||||
keys: ['Esc'],
|
||||
label: 'Close panel, drawer, or find bar; clear histogram bucket selection',
|
||||
},
|
||||
{
|
||||
keys: ['←', '→'],
|
||||
label: 'Move through events in log table',
|
||||
keyJoin: 'or',
|
||||
},
|
||||
{
|
||||
keys: ['↑', '↓'],
|
||||
label: 'Move through events in log table',
|
||||
keyJoin: 'or',
|
||||
},
|
||||
{
|
||||
keys: ['k', 'j'],
|
||||
label: 'Move through events in log table (vim-style)',
|
||||
keyJoin: 'or',
|
||||
},
|
||||
{ keys: ['⌘/Ctrl', 'scroll'], label: 'Zoom trace timeline' },
|
||||
{
|
||||
keys: ['⌥/Alt', 'click'],
|
||||
label: 'Collapse span children (trace waterfall)',
|
||||
},
|
||||
{ keys: ['Space'], label: 'Play / pause session replay' },
|
||||
{
|
||||
keys: ['f'],
|
||||
label: 'Toggle chart fullscreen (dashboard) or exit fullscreen view',
|
||||
},
|
||||
{ keys: ['a'], label: 'Toggle chart AI assistant (Charts page)' },
|
||||
];
|
||||
|
||||
export const KeyboardShortcutsModal = ({
|
||||
opened,
|
||||
onClose,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title="Keyboard Shortcuts"
|
||||
size="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap={0} data-testid="keyboard-shortcuts-modal">
|
||||
{SHORTCUTS.map(({ keys, label, keyJoin = 'chord' }, rowIndex) => (
|
||||
<React.Fragment key={rowIndex}>
|
||||
<Group justify="space-between" wrap="nowrap" gap="md" py="sm">
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{keys.map((key, i) => (
|
||||
<React.Fragment key={`${rowIndex}-${i}-${key}`}>
|
||||
{i > 0 && (
|
||||
<Text span size="xs" c="dimmed" tt="lowercase" px={2}>
|
||||
{keyJoin === 'or' ? 'or' : '+'}
|
||||
</Text>
|
||||
)}
|
||||
<Kbd size="xs">{key}</Kbd>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Group>
|
||||
<Text size="sm" maw="58%" ta="right">
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
{rowIndex < SHORTCUTS.length - 1 ? <Divider /> : null}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
@ -134,14 +134,28 @@ test.describe('Navigation', { tag: ['@core'] }, () => {
|
|||
const documentationItem = page.locator(
|
||||
'[data-testid="documentation-menu-item"]',
|
||||
);
|
||||
const discordItem = page.locator('[data-testid="discord-menu-item"]');
|
||||
const setupItem = page.locator(
|
||||
'[data-testid="setup-instructions-menu-item"]',
|
||||
);
|
||||
const shortcutsItem = page.locator(
|
||||
'[data-testid="keyboard-shortcuts-menu-item"]',
|
||||
);
|
||||
const discordItem = page.locator('[data-testid="discord-menu-item"]');
|
||||
|
||||
await expect(documentationItem).toBeVisible();
|
||||
await expect(discordItem).toBeVisible();
|
||||
await expect(setupItem).toBeVisible();
|
||||
await expect(shortcutsItem).toBeVisible();
|
||||
await expect(discordItem).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Open keyboard shortcuts from help menu', async () => {
|
||||
const shortcutsItem = page.getByTestId('keyboard-shortcuts-menu-item');
|
||||
await shortcutsItem.scrollIntoViewIfNeeded();
|
||||
await shortcutsItem.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: 'Keyboard Shortcuts' }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue