Files
supabase/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx
Ali Waseem 42b431a270 feat(studio): add keyboard shortcuts to the table editor (#45178)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Feature — a set of new keyboard shortcuts for the table editor, along
with infrastructure to register, gate, and surface them.

## What is the current behavior?

Clicking into the grid "traps" the keyboard: Escape doesn't pop out,
there are no shortcuts for row selection / deletion / navigation, and
the search-tables input grabs focus on page load.

## What is the new behavior?

### New shortcuts (all scoped to the table editor)

| Keybind | Action | Surface |
|---|---|---|
| `Esc` | Exit grid selection — clears the highlighted cell and drops
focus back to the page | hotkey |
| `↑` / `↓` | Start grid navigation from the first cell when no cell is
selected | hotkey |
| `Shift+Space` | Toggle selection on the current row | hotkey +
checkbox tooltip |
| `Mod+A` | Toggle selection on all displayed rows (matches Excel) |
hotkey + header-checkbox tooltip + Cmd+K |
| `Mod+Shift+A` | Toggle selection on all rows in the table | hotkey +
"Select all rows in table" button tooltip + Cmd+K |
| `Mod+Backspace` | Delete selected rows | hotkey + delete-button
tooltip + Cmd+K |

### Infrastructure

- **Split registry** — table-editor shortcuts moved to
`state/shortcuts/registry/table-editor.ts`, spread into `SHORTCUT_IDS`.
Makes it easy to scope a runtime check to a specific surface.
- **`eventMatchesAnyShortcut`** (`state/shortcuts/matchEvent.ts`) —
queries the hotkey library's live `SequenceManager` so gated shortcuts
(`enabled: false`) are correctly excluded. Covered by
`matchEvent.test.ts`.
- **`handleCellKeyDown`** now calls `event.preventGridDefault()`
whenever the keystroke matches an active table-editor shortcut, so rdg's
"start editing on key press" default doesn't compete with shortcut
actions (e.g. typing `Shift+X` no longer opens edit mode with `X` as
input).
- **`<Shortcut>` / `<ShortcutTooltip>`** used on the header checkbox,
the per-row checkbox, the "Select all rows in table" button, and the
delete button — keybinds show up on hover (Linear-style) so users can
discover them without reading docs.
- **CSS** — `.rdg:not(:focus-within) .rdg-cell[aria-selected='true']`
drops the selected-cell outline whenever focus leaves the grid,
reinforcing the "you're out" feedback after `Esc`.
- **`useShortcut`** wraps the Cmd+K-registered action to close the
command menu after firing (previously menu stayed open after selecting
an action).
- **Search-tables input** no longer auto-focuses on load, so arrow
shortcuts work immediately without clicking out first.

## Additional context

Linear: FE-3057

### Test plan

- [x] Open any table → `↓` selects the first cell; subsequent arrows
navigate rows
- [x] `Esc` drops focus out of the grid and re-enables `↓` to re-enter
- [x] Click a cell → `Shift+Space` toggles that row's selection
(checkbox)
- [x] `Mod+A` toggles all displayed rows
- [x] With pagination + some rows selected → `Mod+Shift+A` toggles
"Select all rows in table"
- [x] With rows selected → `Mod+Backspace` deletes them (existing
confirmation flow)
- [x] Hover the header checkbox / per-row checkbox / delete button →
keybind tooltip after ~500ms
- [x] Cmd+K with selection → the relevant action shows up; selecting it
closes the palette and runs

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

* **New Features**
* Added table editor keyboard shortcuts for navigation, row selection,
and cell actions, with command-menu integration and visible shortcut
tooltips.

* **Improvements**
* Better keyboard handling in grid cells allowing external shortcuts to
override default behavior.
* Select-all/deselect-all toggle and improved select-row UX;
selected-cell styling no longer shows when grid loses focus.
  * Command menu now reliably closes before executing shortcut actions.
* Removed autofocus on the table editor search input for consistent
focus behavior.

* **Tests**
* Added unit tests covering shortcut matching and command-menu shortcut
behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-04-28 08:20:04 -06:00

331 lines
12 KiB
TypeScript

import { PermissionAction } from '@supabase/shared-types/out/constants'
import { keepPreviousData } from '@tanstack/react-query'
import { useParams } from 'common'
import { Filter, Plus } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
Button,
Checkbox,
Label_Shadcn_,
Popover_Shadcn_,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
} from 'ui'
import {
InnerSideBarEmptyPanel,
InnerSideBarFilters,
InnerSideBarFilterSearchInput,
InnerSideBarFilterSortDropdown,
InnerSideBarFilterSortDropdownItem,
} from 'ui-patterns/InnerSideMenu'
import { useTableEditorTabsCleanUp } from '../Tabs/Tabs.utils'
import { EntityListItem } from './EntityListItem'
import { TableMenuEmptyState } from './TableMenuEmptyState'
import { ExportDialog } from '@/components/grid/components/header/ExportDialog'
import { parseSupaTable } from '@/components/grid/SupabaseGrid.utils'
import { SupaTable } from '@/components/grid/types'
import { ProtectedSchemaWarning } from '@/components/interfaces/Database/ProtectedSchemaWarning'
import { ErrorMatcher } from '@/components/interfaces/ErrorHandling/ErrorMatcher'
import EditorMenuListSkeleton from '@/components/layouts/TableEditorLayout/EditorMenuListSkeleton'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { InfiniteListDefault, LoaderForIconMenuItems } from '@/components/ui/InfiniteList'
import SchemaSelector from '@/components/ui/SchemaSelector'
import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
import { useEntityTypesQuery } from '@/data/entity-types/entity-types-infinite-query'
import { useTableApiAccessQuery } from '@/data/privileges/table-api-access-query'
import { getTableEditor, useTableEditorQuery } from '@/data/table-editor/table-editor-query'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useLocalStorage } from '@/hooks/misc/useLocalStorage'
import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
import { useTableEditorStateSnapshot } from '@/state/table-editor'
export const TableEditorMenu = () => {
const { id: _id, ref: projectRef } = useParams()
const id = _id ? Number(_id) : undefined
const snap = useTableEditorStateSnapshot()
const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
const [searchText, setSearchText] = useState<string>('')
const [tableToExport, setTableToExport] = useState<SupaTable>()
const [visibleTypes, setVisibleTypes] = useState<string[]>(Object.values(ENTITY_TYPE))
const [sort, setSort] = useLocalStorage<'alphabetical' | 'grouped-alphabetical'>(
'table-editor-sort',
'alphabetical'
)
const { data: project } = useSelectedProjectQuery()
const {
data,
isLoading,
isSuccess,
isError,
error,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useEntityTypesQuery(
{
projectRef: project?.ref,
connectionString: project?.connectionString,
schemas: [selectedSchema],
search: searchText.trim() || undefined,
sort,
filterTypes: visibleTypes,
},
{
placeholderData: Boolean(searchText) ? keepPreviousData : undefined,
}
)
const entityTypes = useMemo(
() => data?.pages.flatMap((page) => page.data.entities),
[data?.pages]
)
const entityNames = useMemo(() => entityTypes?.map((entity) => entity.name) ?? [], [entityTypes])
const { data: apiAccessByTableName } = useTableApiAccessQuery(
{
projectRef: project?.ref,
connectionString: project?.connectionString ?? undefined,
schemaName: selectedSchema,
tableNames: entityNames,
},
{ enabled: Boolean(selectedSchema && entityNames.length > 0) }
)
const { can: canCreateTables } = useAsyncCheckPermissions(
PermissionAction.TENANT_SQL_ADMIN_WRITE,
'tables'
)
const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedSchema })
const { data: selectedTable } = useTableEditorQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
id,
})
if (selectedTable?.schema && !selectedSchema) {
setSelectedSchema(selectedTable.schema)
}
const tableEditorTabsCleanUp = useTableEditorTabsCleanUp()
const onSelectExportCLI = useCallback(
async (id: number) => {
const table = await getTableEditor({
id: id,
projectRef,
connectionString: project?.connectionString,
})
const supaTable = table && parseSupaTable(table)
setTableToExport(supaTable)
},
[project?.connectionString, projectRef]
)
const getItemKey = useCallback(
(index: number) => {
const item = entityTypes?.[index]
return item?.id ? String(item.id) : `table-editor-entity-${index}`
},
[entityTypes]
)
const entityProps = useMemo(
() => ({
projectRef: project?.ref!,
id: Number(id),
isLocked: isSchemaLocked,
onExportCLI: () => onSelectExportCLI(Number(id)),
apiAccessMap: apiAccessByTableName,
}),
[project?.ref, id, isSchemaLocked, onSelectExportCLI, apiAccessByTableName]
)
useEffect(() => {
// Clean up tabs + recent items for any tables that might have been removed outside of the dashboard session
if (entityTypes && !searchText) {
tableEditorTabsCleanUp({ schemas: [selectedSchema], entities: entityTypes })
}
}, [entityTypes, searchText, selectedSchema, tableEditorTabsCleanUp])
return (
<>
<div className="flex flex-col grow gap-5 pt-5 h-full">
<div className="flex flex-col gap-y-1.5">
<SchemaSelector
className="mx-4"
selectedSchemaName={selectedSchema}
onSelectSchema={(name: string) => {
setSearchText('')
setSelectedSchema(name)
}}
onSelectCreateSchema={() => snap.onAddSchema()}
/>
<div className="grid gap-3 mx-4">
{!isSchemaLocked ? (
<ButtonTooltip
block
title="Create a new table"
name="New table"
disabled={!canCreateTables}
size="tiny"
icon={<Plus size={14} strokeWidth={1.5} className="text-foreground-muted" />}
type="default"
className="justify-start"
onClick={() => snap.onAddTable()}
tooltip={{
content: {
side: 'bottom',
text: !canCreateTables
? 'You need additional permissions to create tables'
: undefined,
},
}}
>
New table
</ButtonTooltip>
) : (
<ProtectedSchemaWarning size="sm" schema={selectedSchema} entity="table" />
)}
</div>
</div>
<div className="grow min-h-0 flex flex-col gap-2 pb-4">
<InnerSideBarFilters className="mx-2">
<InnerSideBarFilterSearchInput
name="search-tables"
value={searchText}
placeholder="Search tables..."
aria-labelledby="Search tables"
onChange={(e) => setSearchText(e.target.value)}
>
<InnerSideBarFilterSortDropdown
value={sort}
onValueChange={(value: any) => setSort(value)}
>
<InnerSideBarFilterSortDropdownItem
key="alphabetical"
value="alphabetical"
className="flex gap-2"
>
Alphabetical
</InnerSideBarFilterSortDropdownItem>
<InnerSideBarFilterSortDropdownItem
key="grouped-alphabetical"
value="grouped-alphabetical"
>
Entity Type
</InnerSideBarFilterSortDropdownItem>
</InnerSideBarFilterSortDropdown>
</InnerSideBarFilterSearchInput>
<Popover_Shadcn_>
<PopoverTrigger_Shadcn_ asChild>
<Button
type={visibleTypes.length !== 5 ? 'default' : 'dashed'}
className="h-[32px] md:h-[28px] px-1.5"
icon={<Filter />}
/>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="p-0 w-56" side="bottom" align="center">
<div className="px-3 pt-3 pb-2 flex flex-col gap-y-2">
<p className="text-xs">Show entity types</p>
<div className="flex flex-col">
{Object.entries(ENTITY_TYPE).map(([key, value]) => (
<div key={key} className="group flex items-center justify-between py-0.5">
<div className="flex items-center gap-x-2">
<Checkbox
id={key}
name={key}
checked={visibleTypes.includes(value)}
onCheckedChange={() => {
if (visibleTypes.includes(value)) {
setVisibleTypes(visibleTypes.filter((y) => y !== value))
} else {
setVisibleTypes(visibleTypes.concat([value]))
}
}}
/>
<Label_Shadcn_ htmlFor={key} className="capitalize text-xs">
{key.toLowerCase().replace('_', ' ')}
</Label_Shadcn_>
</div>
<Button
size="tiny"
type="default"
onClick={() => setVisibleTypes([value])}
className="transition opacity-0 group-hover:opacity-100 h-auto px-1 py-0.5"
>
Select only
</Button>
</div>
))}
</div>
</div>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
</InnerSideBarFilters>
{isLoading && <EditorMenuListSkeleton />}
{isError && (
<ErrorMatcher
title="Failed to load tables"
error={error ?? 'Failed to load tables'}
supportFormParams={{ projectRef: project?.ref }}
className="mx-4 mt-3"
/>
)}
{isSuccess && (
<>
{searchText.length === 0 && (entityTypes?.length ?? 0) <= 0 && (
<TableMenuEmptyState />
)}
{searchText.length > 0 && (entityTypes?.length ?? 0) <= 0 && (
<InnerSideBarEmptyPanel
className="mx-2"
title="No results found"
description={`Your search for "${searchText}" did not return any results`}
/>
)}
{(entityTypes?.length ?? 0) > 0 && (
<div className="flex flex-1 min-h-0 w-full" data-testid="tables-list">
<InfiniteListDefault
className="h-full w-full"
items={entityTypes!}
ItemComponent={EntityListItem}
LoaderComponent={LoaderForIconMenuItems}
itemProps={entityProps}
getItemKey={getItemKey}
getItemSize={(index) =>
index !== 0 && index === entityTypes!.length ? 85 : 28
}
hasNextPage={hasNextPage}
isLoadingNextPage={isFetchingNextPage}
onLoadNextPage={fetchNextPage}
/>
</div>
)}
</>
)}
</div>
</div>
<ExportDialog
ignoreRoleImpersonation
table={tableToExport}
open={!!tableToExport}
onOpenChange={(open) => {
if (!open) setTableToExport(undefined)
}}
/>
</>
)
}