Files
supabase/apps/studio/components/ui/GlobalShortcuts/ShortcutsReferenceSheet.test.tsx
Ali WaseemandDanny White 3d9fb2f98d feat(realtime): shortcuts for realtime (#46001)
## What kind of change does this PR introduce?

Feature — adds keyboard shortcuts for the Realtime section in Studio.

## What is the current behavior?

No keyboard shortcuts exist for Realtime navigation or inspector
actions.

## What is the new behavior?

### Navigation chords (layout-scoped, active on any Realtime page)

| Shortcut | Action |
|---|---|
| `R I` | Go to Inspector |
| `R P` | Go to Policies |
| `R S` | Go to Settings |

### Inspector actions (active on the Inspector page)

| Shortcut | Action | Gating |
|---|---|---|
| `Shift+J` | Join a channel | Only when no channel is joined |
| `Shift+L` | Start/Stop listening | Only when a channel is joined |
| `Shift+F` | Open filter popover | Only when a channel is joined |
| `Shift+B` | Broadcast a message | Only when listening |
| `Mod+Shift+C` | Copy selected message | Only when a message is
selected |


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Realtime Inspector: new keyboard shortcuts (join channel, toggle
listening, toggle filters, broadcast) and a copy-message shortcut with
toast feedback.
* Channel & Filter popovers support controlled/uncontrolled open state;
header now wires popover state through props.
* Shortcut tooltips added to copy and broadcast actions; realtime page
navigation shortcuts and menu shortcuts added.
* **Tests**
* Shortcut reference sheet tests updated to include realtime navigation
and inspector groups.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46001?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Danny White <3104761+dnywh@users.noreply.github.com>
2026-05-19 11:12:54 -06:00

288 lines
11 KiB
TypeScript

import type { HotkeyRegistrationView, SequenceRegistrationView } from '@tanstack/react-hotkeys'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ShortcutsReferenceSheet } from './ShortcutsReferenceSheet'
import { SHORTCUT_DEFINITIONS, SHORTCUT_IDS, type ShortcutId } from '@/state/shortcuts/registry'
import type { ShortcutHotkeyMeta } from '@/state/shortcuts/useShortcut'
import { customRender } from '@/tests/lib/custom-render'
const { mockUseHotkeyRegistrations } = vi.hoisted(() => ({
mockUseHotkeyRegistrations:
vi.fn<() => { hotkeys: HotkeyRegistrationView[]; sequences: SequenceRegistrationView[] }>(),
}))
vi.mock('@tanstack/react-hotkeys', async () => {
const actual =
await vi.importActual<typeof import('@tanstack/react-hotkeys')>('@tanstack/react-hotkeys')
return {
...actual,
useHotkeyRegistrations: mockUseHotkeyRegistrations,
}
})
const ACTIVE_SHORTCUT_IDS = [
SHORTCUT_IDS.COMMAND_MENU_OPEN,
SHORTCUT_IDS.NAV_HOME,
] satisfies ShortcutId[]
const ACTIVE_DATABASE_SHORTCUT_IDS = [
...ACTIVE_SHORTCUT_IDS,
SHORTCUT_IDS.NAV_DATABASE_TABLES,
] satisfies ShortcutId[]
const ACTIVE_AUTH_SHORTCUT_IDS = [
...ACTIVE_SHORTCUT_IDS,
SHORTCUT_IDS.NAV_AUTH_USERS,
] satisfies ShortcutId[]
const ACTIVE_FUNCTION_DETAIL_NAV_SHORTCUT_IDS = [
...ACTIVE_SHORTCUT_IDS,
SHORTCUT_IDS.NAV_FUNCTION_DETAIL_OVERVIEW,
] satisfies ShortcutId[]
const ACTIVE_REALTIME_NAV_SHORTCUT_IDS = [
...ACTIVE_SHORTCUT_IDS,
SHORTCUT_IDS.NAV_REALTIME_INSPECTOR,
] satisfies ShortcutId[]
const ACTIVE_REALTIME_INSPECTOR_SHORTCUT_IDS = [
...ACTIVE_SHORTCUT_IDS,
SHORTCUT_IDS.INSPECTOR_JOIN_CHANNEL,
SHORTCUT_IDS.INSPECTOR_TOGGLE_LISTENING,
SHORTCUT_IDS.INSPECTOR_BROADCAST,
SHORTCUT_IDS.INSPECTOR_COPY_MESSAGE,
] satisfies ShortcutId[]
const ACTIVE_SURFACE_SHORTCUT_IDS = [
...ACTIVE_SHORTCUT_IDS,
SHORTCUT_IDS.AUTH_USERS_REFRESH,
SHORTCUT_IDS.FUNCTION_DETAIL_OPEN_TEST,
SHORTCUT_IDS.FUNCTION_OVERVIEW_INTERVAL_15MIN,
SHORTCUT_IDS.FUNCTIONS_LIST_REFRESH,
SHORTCUT_IDS.LOGS_PREVIEW_REFRESH,
SHORTCUT_IDS.SQL_EDITOR_FORMAT,
SHORTCUT_IDS.STORAGE_BUCKETS_REFRESH,
SHORTCUT_IDS.STORAGE_EXPLORER_REFRESH,
] satisfies ShortcutId[]
let sequenceIdCounter = 0
const buildSequenceRegistration = (id: ShortcutId): SequenceRegistrationView => {
const definition = SHORTCUT_DEFINITIONS[id]
const meta: ShortcutHotkeyMeta = {
id: definition.id,
name: definition.label,
referenceGroup: definition.referenceGroup,
}
return {
id: `sequence_${++sequenceIdCounter}`,
sequence: definition.sequence,
options: {
enabled: true,
meta,
},
target: document,
triggerCount: 0,
hasFired: false,
matchedStepCount: 0,
partialMatchLastKeyTime: 0,
}
}
const seedRegistrations = (ids: ShortcutId[]) => {
mockUseHotkeyRegistrations.mockReturnValue({
hotkeys: [],
sequences: ids.map(buildSequenceRegistration),
})
}
const renderShortcutsReferenceSheet = (ids: ShortcutId[] = ACTIVE_SHORTCUT_IDS) => {
seedRegistrations(ids)
const onOpenChange = vi.fn()
customRender(<ShortcutsReferenceSheet open onOpenChange={onOpenChange} />)
return { onOpenChange }
}
describe('ShortcutsReferenceSheet', () => {
beforeEach(() => {
sequenceIdCounter = 0
mockUseHotkeyRegistrations.mockReset()
mockUseHotkeyRegistrations.mockReturnValue({ hotkeys: [], sequences: [] })
})
it('renders the grouped shortcut list by default', async () => {
renderShortcutsReferenceSheet()
expect(await screen.findByText('Keyboard shortcuts')).toBeInTheDocument()
expect(screen.getByLabelText('Search shortcuts')).toBeInTheDocument()
expect(screen.getByText('Command Menu')).toBeInTheDocument()
expect(screen.getByText('Navigation')).toBeInTheDocument()
expect(screen.queryByText('Global Navigation')).not.toBeInTheDocument()
expect(screen.queryByText('Database Navigation')).not.toBeInTheDocument()
expect(screen.getByText('Open command menu')).toBeInTheDocument()
expect(screen.getByText('Go to Project Overview')).toBeInTheDocument()
})
it('shows only active shortcuts in a group when the group label matches the search', async () => {
const user = userEvent.setup()
renderShortcutsReferenceSheet()
await user.type(screen.getByLabelText('Search shortcuts'), 'navigation')
expect(screen.getByText('Navigation')).toBeInTheDocument()
expect(screen.getByText('Go to Project Overview')).toBeInTheDocument()
expect(screen.queryByText('Command Menu')).not.toBeInTheDocument()
expect(screen.queryByText('Go to Database')).not.toBeInTheDocument()
})
it('keeps the parent group header when only an item label matches', async () => {
const user = userEvent.setup()
renderShortcutsReferenceSheet()
await user.type(screen.getByLabelText('Search shortcuts'), 'Go to Project Overview')
expect(screen.getByText('Navigation')).toBeInTheDocument()
expect(screen.getByText('Go to Project Overview')).toBeInTheDocument()
expect(screen.queryByText('Open command menu')).not.toBeInTheDocument()
expect(screen.queryByText('Command Menu')).not.toBeInTheDocument()
})
it('shows the database navigation section when database shortcuts are active', async () => {
renderShortcutsReferenceSheet(ACTIVE_DATABASE_SHORTCUT_IDS)
expect(await screen.findByText('Global Navigation')).toBeInTheDocument()
expect(screen.getByText('Database Navigation')).toBeInTheDocument()
expect(screen.queryByText(/^Navigation$/)).not.toBeInTheDocument()
expect(screen.getByText('Go to Tables')).toBeInTheDocument()
})
it('shows the auth navigation section when auth shortcuts are active', async () => {
renderShortcutsReferenceSheet(ACTIVE_AUTH_SHORTCUT_IDS)
expect(await screen.findByText('Global Navigation')).toBeInTheDocument()
expect(screen.getByText('Auth Navigation')).toBeInTheDocument()
expect(screen.queryByText(/^Navigation$/)).not.toBeInTheDocument()
expect(screen.getByText('Go to Users')).toBeInTheDocument()
})
it('shows the edge function tabs section when function tab shortcuts are active', async () => {
renderShortcutsReferenceSheet(ACTIVE_FUNCTION_DETAIL_NAV_SHORTCUT_IDS)
expect(await screen.findByText('Global Navigation')).toBeInTheDocument()
expect(screen.getByText('Edge Function Tabs')).toBeInTheDocument()
expect(screen.queryByText('Edge Function Page Navigation')).not.toBeInTheDocument()
expect(screen.getByText('Go to Overview')).toBeInTheDocument()
})
it('shows the realtime navigation section when realtime shortcuts are active', async () => {
renderShortcutsReferenceSheet(ACTIVE_REALTIME_NAV_SHORTCUT_IDS)
expect(await screen.findByText('Global Navigation')).toBeInTheDocument()
expect(screen.getByText('Realtime Navigation')).toBeInTheDocument()
expect(screen.getByText('Go to Inspector')).toBeInTheDocument()
})
it('uses human labels for realtime inspector shortcut groups', async () => {
renderShortcutsReferenceSheet(ACTIVE_REALTIME_INSPECTOR_SHORTCUT_IDS)
expect(await screen.findByText('Realtime Inspector')).toBeInTheDocument()
expect(screen.getByText('Join a channel')).toBeInTheDocument()
expect(screen.getByText('Start/Stop listening')).toBeInTheDocument()
expect(screen.getByText('Broadcast a message')).toBeInTheDocument()
expect(screen.getByText('Copy selected message')).toBeInTheDocument()
expect(screen.queryByText('realtime-inspector')).not.toBeInTheDocument()
})
it('uses human labels for active surface shortcut groups', async () => {
renderShortcutsReferenceSheet(ACTIVE_SURFACE_SHORTCUT_IDS)
expect(await screen.findByText('Auth Users')).toBeInTheDocument()
expect(screen.getByText('Edge Function Actions')).toBeInTheDocument()
expect(screen.getByText('Edge Function Overview')).toBeInTheDocument()
expect(screen.getByText('Edge Functions')).toBeInTheDocument()
expect(screen.getByText('Logs Explorer')).toBeInTheDocument()
expect(screen.getByText('SQL Editor')).toBeInTheDocument()
expect(screen.getByText('Storage Buckets')).toBeInTheDocument()
expect(screen.getByText('Storage File Explorer')).toBeInTheDocument()
expect(screen.queryByText('auth-users')).not.toBeInTheDocument()
expect(screen.queryByText('functions-detail')).not.toBeInTheDocument()
expect(screen.queryByText('functions-list')).not.toBeInTheDocument()
expect(screen.queryByText('functions-overview')).not.toBeInTheDocument()
expect(screen.queryByText('logs-preview')).not.toBeInTheDocument()
expect(screen.queryByText('sql-editor')).not.toBeInTheDocument()
expect(screen.queryByText('storage-buckets')).not.toBeInTheDocument()
expect(screen.queryByText('storage-explorer')).not.toBeInTheDocument()
})
it('does not show inactive database shortcuts in search results', async () => {
const user = userEvent.setup()
renderShortcutsReferenceSheet()
await user.type(screen.getByLabelText('Search shortcuts'), 'Go to Tables')
expect(screen.getByText('No matching shortcuts found')).toBeInTheDocument()
expect(screen.queryByText('Database Navigation')).not.toBeInTheDocument()
})
it('hides shortcuts whose registration is soft-disabled', async () => {
sequenceIdCounter = 0
const enabled = buildSequenceRegistration(SHORTCUT_IDS.COMMAND_MENU_OPEN)
const disabled = buildSequenceRegistration(SHORTCUT_IDS.NAV_HOME)
disabled.options = { ...disabled.options, enabled: false }
mockUseHotkeyRegistrations.mockReturnValue({
hotkeys: [],
sequences: [enabled, disabled],
})
customRender(<ShortcutsReferenceSheet open onOpenChange={vi.fn()} />)
expect(await screen.findByText('Open command menu')).toBeInTheDocument()
expect(screen.queryByText('Go to Project Overview')).not.toBeInTheDocument()
})
it('shows a clear button when searching and resets the list when clicked', async () => {
const user = userEvent.setup()
renderShortcutsReferenceSheet()
await user.type(screen.getByLabelText('Search shortcuts'), 'navigation')
expect(screen.getByRole('button', { name: 'Clear search' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Clear search' }))
expect(screen.getByLabelText('Search shortcuts')).toHaveValue('')
expect(screen.getByText('Command Menu')).toBeInTheDocument()
expect(screen.getByText('Navigation')).toBeInTheDocument()
})
it.each(['⌘Esc', 'Mod+/'])('does not search shortcut values like %s', async (query) => {
const user = userEvent.setup()
renderShortcutsReferenceSheet()
await user.type(screen.getByLabelText('Search shortcuts'), query)
expect(screen.getByText('No matching shortcuts found')).toBeInTheDocument()
expect(screen.queryByText('Navigation')).not.toBeInTheDocument()
})
it('shows an empty state when nothing matches', async () => {
const user = userEvent.setup()
renderShortcutsReferenceSheet()
await user.type(screen.getByLabelText('Search shortcuts'), 'totally missing')
expect(screen.getByText('No matching shortcuts found')).toBeInTheDocument()
})
})