mirror of
https://github.com/supabase/supabase.git
synced 2026-10-11 12:25:05 +03:00
## 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 --> [](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>
288 lines
11 KiB
TypeScript
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()
|
|
})
|
|
})
|