mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat(studio): add keyboard shortcuts to Database listing pages (#45467)
## Summary Adds the `O→S` / `Shift+F` / `Shift+N` / `F→C` shortcut set across every Database listing page, on top of the schema-visualizer pattern from #45386. Fixes [FE-3131](https://linear.app/supabase/issue/FE-3131). A shared `list-page.*` registry replaces what would have been a per-page registry file for each route, and `useShortcut`/`Shortcut` gain a `label` override so a single ID renders contextually in Cmd+K and hover tooltips. ## Pages and shortcuts | Page | `O→S` | `Shift+F` | `Shift+N` | `F→C` | | --- | --- | --- | --- | --- | | Tables | Open schema selector | Search tables | Create new table | Clears search + entity-type filter | | Functions | Open schema selector | Search functions | Create new function | Clears search + Return Type + Security | | Triggers — data | Open schema selector | Search triggers | Create new trigger | Clears search + Table filter | | Triggers — event | — | Search event triggers | Create new event trigger | Clears search + Owner filter | | Enumerated Types | Open schema selector | Search enumerated types | Create new enumerated type | Clears search | | Indexes | Open schema selector | Search indexes | Create new index | Clears search | | Roles | — | Search roles | Add new role | Clears search + filter type → 'all' | | Publications | — | Search publications | — | Clears search | | Extensions | — | Search extensions | — | Clears search | `Shift+N` only fires when the page-specific gate allows it (permission + unlocked schema + any other prerequisite like `hasTables` for triggers). ## Test plan - [ ] On each of the 9 pages, all four shortcuts behave as listed in the table above - [ ] Hover the wrapped controls — tooltip shows the page-specific label and the right keybind badge - [ ] Open the schema selector via `O→S` — the hover tooltip is suppressed while the popover is open - [ ] `Shift+N` is a no-op on locked schemas / when permission is missing / when a prereq fails (e.g. no tables on Triggers data) - [ ] `Shift+F` focuses the search input; while focused, Escape clears (with text) → Escape blurs (when empty) - [ ] `Cmd+K` shows the page-specific shortcut name while on each page; the entry goes away on navigation - [ ] `Mod+/` reference sheet shows one "List pages" group with 4 generic entries - [ ] No regression to existing schema-visualizer / table-editor / SQL-editor shortcut behavior <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a dedicated list-page shortcut group in the shortcuts reference. * **Improvements** * Keyboard shortcuts across database list pages: focus & select search, reset filters, and create-new-item shortcuts. * Escape now clears/searches or blurs inputs to avoid accidental popover closes. * Create/new buttons respond to shortcuts when allowed; disabled actions keep permission tooltips for clarity. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
b308983bb8
commit
618c902e1a
18 files changed
+578
-151
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { Edit, MoreVertical, Search, Trash } from 'lucide-react'
|
||||
import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Button,
|
||||
@@ -26,15 +26,21 @@ import EditEnumeratedTypeSidePanel from './EditEnumeratedTypeSidePanel'
|
||||
import AlertError from '@/components/ui/AlertError'
|
||||
import { DocsButton } from '@/components/ui/DocsButton'
|
||||
import SchemaSelector from '@/components/ui/SchemaSelector'
|
||||
import { Shortcut } from '@/components/ui/Shortcut'
|
||||
import { useEnumeratedTypeDeleteMutation } from '@/data/enumerated-types/enumerated-type-delete-mutation'
|
||||
import { useEnumeratedTypesQuery } from '@/data/enumerated-types/enumerated-types-query'
|
||||
import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
|
||||
import { onSearchInputEscape } from '@/lib/keyboard'
|
||||
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
||||
import { useShortcut } from '@/state/shortcuts/useShortcut'
|
||||
|
||||
export const EnumeratedTypes = () => {
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const [search, setSearch] = useState('')
|
||||
const [schemaSelectorOpen, setSchemaSelectorOpen] = useState(false)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
|
||||
|
||||
const {
|
||||
@@ -80,6 +86,19 @@ export const EnumeratedTypes = () => {
|
||||
|
||||
const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedSchema })
|
||||
|
||||
useShortcut(
|
||||
SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
|
||||
() => {
|
||||
searchInputRef.current?.focus()
|
||||
searchInputRef.current?.select()
|
||||
},
|
||||
{ label: 'Search enumerated types' }
|
||||
)
|
||||
|
||||
useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, () => {
|
||||
setSearch('')
|
||||
})
|
||||
|
||||
const onConfirmDeleteType = () => {
|
||||
if (typeToDelete === undefined) return console.error('No enumerated type selected')
|
||||
if (project?.ref === undefined) return console.error('Project ref required')
|
||||
@@ -112,18 +131,29 @@ export const EnumeratedTypes = () => {
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-2 flex-wrap">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center gap-2 flex-wrap">
|
||||
<SchemaSelector
|
||||
className="w-full lg:w-[180px]"
|
||||
size="tiny"
|
||||
showError={false}
|
||||
selectedSchemaName={selectedSchema}
|
||||
onSelectSchema={setSelectedSchema}
|
||||
/>
|
||||
<Shortcut
|
||||
id={SHORTCUT_IDS.LIST_PAGE_FOCUS_SCHEMA}
|
||||
onTrigger={() => setSchemaSelectorOpen(true)}
|
||||
side="bottom"
|
||||
tooltipOpen={schemaSelectorOpen ? false : undefined}
|
||||
>
|
||||
<SchemaSelector
|
||||
className="w-full lg:w-[180px]"
|
||||
size="tiny"
|
||||
showError={false}
|
||||
selectedSchemaName={selectedSchema}
|
||||
onSelectSchema={setSelectedSchema}
|
||||
open={schemaSelectorOpen}
|
||||
onOpenChange={setSchemaSelectorOpen}
|
||||
/>
|
||||
</Shortcut>
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
size="tiny"
|
||||
value={search}
|
||||
className="w-full lg:w-52"
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={onSearchInputEscape(search, setSearch)}
|
||||
placeholder="Search for a type"
|
||||
icon={<Search />}
|
||||
/>
|
||||
@@ -132,13 +162,20 @@ export const EnumeratedTypes = () => {
|
||||
<div className="flex items-center gap-2">
|
||||
<DocsButton href="https://www.postgresql.org/docs/current/datatype-enum.html" />
|
||||
{!isSchemaLocked && (
|
||||
<Button
|
||||
className="ml-auto flex-1"
|
||||
type="primary"
|
||||
onClick={() => setShowCreateTypePanel(true)}
|
||||
<Shortcut
|
||||
id={SHORTCUT_IDS.LIST_PAGE_NEW_ITEM}
|
||||
label="Create new enumerated type"
|
||||
onTrigger={() => setShowCreateTypePanel(true)}
|
||||
side="bottom"
|
||||
>
|
||||
Create type
|
||||
</Button>
|
||||
<Button
|
||||
className="ml-auto flex-1"
|
||||
type="primary"
|
||||
onClick={() => setShowCreateTypePanel(true)}
|
||||
>
|
||||
Create type
|
||||
</Button>
|
||||
</Shortcut>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useParams } from 'common'
|
||||
import { isNull, partition } from 'lodash'
|
||||
import { AlertCircle, Search } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Card,
|
||||
Input,
|
||||
@@ -23,11 +23,28 @@ import { NoSearchResults } from '@/components/ui/NoSearchResults'
|
||||
import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { onSearchInputEscape } from '@/lib/keyboard'
|
||||
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
||||
import { useShortcut } from '@/state/shortcuts/useShortcut'
|
||||
|
||||
export const Extensions = () => {
|
||||
const { filter } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const [filterString, setFilterString] = useState<string>('')
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useShortcut(
|
||||
SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
|
||||
() => {
|
||||
searchInputRef.current?.focus()
|
||||
searchInputRef.current?.select()
|
||||
},
|
||||
{ label: 'Search extensions' }
|
||||
)
|
||||
|
||||
useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, () => {
|
||||
setFilterString('')
|
||||
})
|
||||
|
||||
const { data = [], isPending: isLoading } = useDatabaseExtensionsQuery({
|
||||
projectRef: project?.ref,
|
||||
@@ -63,10 +80,12 @@ export const Extensions = () => {
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<Input
|
||||
inputRef={searchInputRef}
|
||||
size="tiny"
|
||||
placeholder="Search for an extension"
|
||||
value={filterString}
|
||||
onChange={(e) => setFilterString(e.target.value)}
|
||||
onKeyDown={onSearchInputEscape(filterString, setFilterString)}
|
||||
className="w-52"
|
||||
icon={<Search />}
|
||||
/>
|
||||
|
||||
+81
-27
@@ -1,9 +1,18 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { Search } from 'lucide-react'
|
||||
import { parseAsBoolean, parseAsJson, parseAsString, useQueryState } from 'nuqs'
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { AiIconAnimation, Card, Table, TableBody, TableHead, TableHeader, TableRow } from 'ui'
|
||||
import {
|
||||
AiIconAnimation,
|
||||
Button,
|
||||
Card,
|
||||
Table,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from 'ui'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
@@ -20,6 +29,7 @@ import ProductEmptyState from '@/components/to-be-cleaned/ProductEmptyState'
|
||||
import AlertError from '@/components/ui/AlertError'
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
import SchemaSelector from '@/components/ui/SchemaSelector'
|
||||
import { Shortcut } from '@/components/ui/Shortcut'
|
||||
import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper'
|
||||
import { useDatabaseFunctionDeleteMutation } from '@/data/database-functions/database-functions-delete-mutation'
|
||||
import type { DatabaseFunction } from '@/data/database-functions/database-functions-query'
|
||||
@@ -29,8 +39,11 @@ import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
|
||||
import { onSearchInputEscape } from '@/lib/keyboard'
|
||||
import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
|
||||
import { useEditorPanelStateSnapshot } from '@/state/editor-panel-state'
|
||||
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
||||
import { useShortcut } from '@/state/shortcuts/useShortcut'
|
||||
import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
|
||||
|
||||
const createFunctionSnippet = `create function function_name()
|
||||
@@ -107,6 +120,9 @@ export const FunctionsList = () => {
|
||||
parseAsJson(selectFilterSchema.parse)
|
||||
)
|
||||
|
||||
const [schemaSelectorOpen, setSchemaSelectorOpen] = useState(false)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const { can: canCreateFunctions } = useAsyncCheckPermissions(
|
||||
PermissionAction.TENANT_SQL_ADMIN_WRITE,
|
||||
'functions'
|
||||
@@ -114,6 +130,23 @@ export const FunctionsList = () => {
|
||||
|
||||
const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedSchema })
|
||||
|
||||
const canAddFunctions = canCreateFunctions && !isSchemaLocked
|
||||
|
||||
useShortcut(
|
||||
SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
|
||||
() => {
|
||||
searchInputRef.current?.focus()
|
||||
searchInputRef.current?.select()
|
||||
},
|
||||
{ label: 'Search functions' }
|
||||
)
|
||||
|
||||
useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, () => {
|
||||
setFilterString('')
|
||||
setReturnTypeFilter(null)
|
||||
setSecurityFilter(null)
|
||||
})
|
||||
|
||||
// [Joshen] This is to preload the data for the Schema Selector
|
||||
useSchemasQuery({
|
||||
projectRef: project?.ref,
|
||||
@@ -236,23 +269,34 @@ export const FunctionsList = () => {
|
||||
<div className="w-full space-y-4">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-2 flex-wrap">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center gap-2">
|
||||
<SchemaSelector
|
||||
className="w-full lg:w-[180px]"
|
||||
size="tiny"
|
||||
showError={false}
|
||||
selectedSchemaName={selectedSchema}
|
||||
onSelectSchema={(schema) => {
|
||||
setFilterString('')
|
||||
setSelectedSchema(schema)
|
||||
}}
|
||||
/>
|
||||
<Shortcut
|
||||
id={SHORTCUT_IDS.LIST_PAGE_FOCUS_SCHEMA}
|
||||
onTrigger={() => setSchemaSelectorOpen(true)}
|
||||
side="bottom"
|
||||
tooltipOpen={schemaSelectorOpen ? false : undefined}
|
||||
>
|
||||
<SchemaSelector
|
||||
className="w-full lg:w-[180px]"
|
||||
size="tiny"
|
||||
showError={false}
|
||||
selectedSchemaName={selectedSchema}
|
||||
onSelectSchema={(schema) => {
|
||||
setFilterString('')
|
||||
setSelectedSchema(schema)
|
||||
}}
|
||||
open={schemaSelectorOpen}
|
||||
onOpenChange={setSchemaSelectorOpen}
|
||||
/>
|
||||
</Shortcut>
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
placeholder="Search for a function"
|
||||
size="tiny"
|
||||
icon={<Search />}
|
||||
value={filterString}
|
||||
className="w-full lg:w-52"
|
||||
onChange={(e) => setFilterString(e.target.value)}
|
||||
onKeyDown={onSearchInputEscape(filterString, setFilterString)}
|
||||
/>
|
||||
<ReportsSelectFilter
|
||||
label="Return Type"
|
||||
@@ -275,21 +319,31 @@ export const FunctionsList = () => {
|
||||
<div className="flex items-center gap-x-2">
|
||||
{!isSchemaLocked && (
|
||||
<>
|
||||
<ButtonTooltip
|
||||
disabled={!canCreateFunctions}
|
||||
onClick={() => createFunction()}
|
||||
className="grow"
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canCreateFunctions
|
||||
? 'You need additional permissions to create functions'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Create a new function
|
||||
</ButtonTooltip>
|
||||
{canAddFunctions ? (
|
||||
<Shortcut
|
||||
id={SHORTCUT_IDS.LIST_PAGE_NEW_ITEM}
|
||||
label="Create new function"
|
||||
onTrigger={() => createFunction()}
|
||||
side="bottom"
|
||||
>
|
||||
<Button className="grow" onClick={() => createFunction()}>
|
||||
Create a new function
|
||||
</Button>
|
||||
</Shortcut>
|
||||
) : (
|
||||
<ButtonTooltip
|
||||
disabled
|
||||
className="grow"
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: 'You need additional permissions to create functions',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Create a new function
|
||||
</ButtonTooltip>
|
||||
)}
|
||||
<ButtonTooltip
|
||||
type="default"
|
||||
disabled={!canCreateFunctions}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useParams } from 'common'
|
||||
import { sortBy } from 'lodash'
|
||||
import { AlertCircle, Search, Trash } from 'lucide-react'
|
||||
import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs'
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Button,
|
||||
@@ -24,18 +24,24 @@ import { CreateIndexSidePanel } from './CreateIndexSidePanel'
|
||||
import AlertError from '@/components/ui/AlertError'
|
||||
import CodeEditor from '@/components/ui/CodeEditor/CodeEditor'
|
||||
import SchemaSelector from '@/components/ui/SchemaSelector'
|
||||
import { Shortcut } from '@/components/ui/Shortcut'
|
||||
import { useDatabaseIndexDeleteMutation } from '@/data/database-indexes/index-delete-mutation'
|
||||
import { useIndexesQuery, type DatabaseIndex } from '@/data/database-indexes/indexes-query'
|
||||
import { useSchemasQuery } from '@/data/database/schemas-query'
|
||||
import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
|
||||
import { onSearchInputEscape } from '@/lib/keyboard'
|
||||
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
||||
import { useShortcut } from '@/state/shortcuts/useShortcut'
|
||||
|
||||
export const Indexes = () => {
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { schema: urlSchema, table } = useParams()
|
||||
|
||||
const [search, setSearch] = useQueryState('search', parseAsString.withDefault(''))
|
||||
const [schemaSelectorOpen, setSchemaSelectorOpen] = useState(false)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
|
||||
|
||||
const {
|
||||
@@ -84,6 +90,19 @@ export const Indexes = () => {
|
||||
|
||||
const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedSchema })
|
||||
|
||||
useShortcut(
|
||||
SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
|
||||
() => {
|
||||
searchInputRef.current?.focus()
|
||||
searchInputRef.current?.select()
|
||||
},
|
||||
{ label: 'Search indexes' }
|
||||
)
|
||||
|
||||
useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, () => {
|
||||
setSearch('')
|
||||
})
|
||||
|
||||
const sortedIndexes = sortBy(allIndexes ?? [], (index) => index.name.toLocaleLowerCase())
|
||||
const indexes =
|
||||
search.length > 0
|
||||
@@ -138,32 +157,51 @@ export const Indexes = () => {
|
||||
</div>
|
||||
)}
|
||||
{isSuccessSchemas && (
|
||||
<SchemaSelector
|
||||
className="w-full lg:w-[180px]"
|
||||
size="tiny"
|
||||
showError={false}
|
||||
selectedSchemaName={selectedSchema}
|
||||
onSelectSchema={setSelectedSchema}
|
||||
/>
|
||||
<Shortcut
|
||||
id={SHORTCUT_IDS.LIST_PAGE_FOCUS_SCHEMA}
|
||||
onTrigger={() => setSchemaSelectorOpen(true)}
|
||||
side="bottom"
|
||||
tooltipOpen={schemaSelectorOpen ? false : undefined}
|
||||
>
|
||||
<SchemaSelector
|
||||
className="w-full lg:w-[180px]"
|
||||
size="tiny"
|
||||
showError={false}
|
||||
selectedSchemaName={selectedSchema}
|
||||
onSelectSchema={setSelectedSchema}
|
||||
open={schemaSelectorOpen}
|
||||
onOpenChange={setSchemaSelectorOpen}
|
||||
/>
|
||||
</Shortcut>
|
||||
)}
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
size="tiny"
|
||||
value={search}
|
||||
className="w-full lg:w-52"
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={onSearchInputEscape(search, setSearch)}
|
||||
placeholder="Search for an index"
|
||||
icon={<Search />}
|
||||
/>
|
||||
|
||||
{!isSchemaLocked && (
|
||||
<Button
|
||||
className="ml-auto grow lg:grow-0"
|
||||
type="primary"
|
||||
onClick={() => setShowCreateIndex(true)}
|
||||
disabled={!isSuccessSchemas}
|
||||
<Shortcut
|
||||
id={SHORTCUT_IDS.LIST_PAGE_NEW_ITEM}
|
||||
label="Create new index"
|
||||
onTrigger={() => setShowCreateIndex(true)}
|
||||
options={{ enabled: isSuccessSchemas }}
|
||||
side="bottom"
|
||||
>
|
||||
Create index
|
||||
</Button>
|
||||
<Button
|
||||
className="ml-auto grow lg:grow-0"
|
||||
type="primary"
|
||||
onClick={() => setShowCreateIndex(true)}
|
||||
disabled={!isSuccessSchemas}
|
||||
>
|
||||
Create index
|
||||
</Button>
|
||||
</Shortcut>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useParams } from 'common'
|
||||
import { AlertCircle, Info, Search } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Button,
|
||||
@@ -29,6 +29,9 @@ import { useDatabasePublicationsQuery } from '@/data/database-publications/datab
|
||||
import { useDatabasePublicationUpdateMutation } from '@/data/database-publications/database-publications-update-mutation'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { onSearchInputEscape } from '@/lib/keyboard'
|
||||
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
||||
import { useShortcut } from '@/state/shortcuts/useShortcut'
|
||||
|
||||
interface PublicationEvent {
|
||||
event: string
|
||||
@@ -39,6 +42,20 @@ export const PublicationsList = () => {
|
||||
const { ref } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const [filterString, setFilterString] = useState<string>('')
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useShortcut(
|
||||
SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
|
||||
() => {
|
||||
searchInputRef.current?.focus()
|
||||
searchInputRef.current?.select()
|
||||
},
|
||||
{ label: 'Search publications' }
|
||||
)
|
||||
|
||||
useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, () => {
|
||||
setFilterString('')
|
||||
})
|
||||
|
||||
const {
|
||||
data = [],
|
||||
@@ -99,12 +116,14 @@ export const PublicationsList = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
size="tiny"
|
||||
icon={<Search />}
|
||||
className="w-48"
|
||||
placeholder="Search for a publication"
|
||||
value={filterString}
|
||||
onChange={(e) => setFilterString(e.target.value)}
|
||||
onKeyDown={onSearchInputEscape(filterString, setFilterString)}
|
||||
/>
|
||||
</div>
|
||||
{isPermissionsLoaded && !canUpdatePublications && (
|
||||
|
||||
@@ -2,7 +2,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { partition, sortBy } from 'lodash'
|
||||
import { Plus, Search, X } from 'lucide-react'
|
||||
import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Badge, Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
@@ -14,12 +14,16 @@ import { RoleRowSkeleton } from './RoleRowSkeleton'
|
||||
import { SUPABASE_ROLES } from './Roles.constants'
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
import { NoSearchResults } from '@/components/ui/NoSearchResults'
|
||||
import { Shortcut } from '@/components/ui/Shortcut'
|
||||
import { SparkBar } from '@/components/ui/SparkBar'
|
||||
import { useDatabaseRoleDeleteMutation } from '@/data/database-roles/database-role-delete-mutation'
|
||||
import { useDatabaseRolesQuery } from '@/data/database-roles/database-roles-query'
|
||||
import { useMaxConnectionsQuery } from '@/data/database/max-connections-query'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { onSearchInputEscape } from '@/lib/keyboard'
|
||||
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
||||
import { useShortcut } from '@/state/shortcuts/useShortcut'
|
||||
|
||||
type SUPABASE_ROLE = (typeof SUPABASE_ROLES)[number]
|
||||
|
||||
@@ -28,12 +32,27 @@ export const RolesList = () => {
|
||||
|
||||
const [filterString, setFilterString] = useState('')
|
||||
const [filterType, setFilterType] = useState<'all' | 'active'>('all')
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const { can: canUpdateRoles } = useAsyncCheckPermissions(
|
||||
PermissionAction.TENANT_SQL_ADMIN_WRITE,
|
||||
'roles'
|
||||
)
|
||||
|
||||
useShortcut(
|
||||
SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
|
||||
() => {
|
||||
searchInputRef.current?.focus()
|
||||
searchInputRef.current?.select()
|
||||
},
|
||||
{ label: 'Search roles' }
|
||||
)
|
||||
|
||||
useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, () => {
|
||||
setFilterString('')
|
||||
setFilterType('all')
|
||||
})
|
||||
|
||||
const { data: maxConnData } = useMaxConnectionsQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
@@ -107,12 +126,14 @@ export const RolesList = () => {
|
||||
<div className="mb-4 flex items-center justify-between gap-2 flex-wrap">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
size="tiny"
|
||||
className="w-52"
|
||||
placeholder="Search for a role"
|
||||
icon={<Search />}
|
||||
value={filterString}
|
||||
onChange={(event) => setFilterString(event.target.value)}
|
||||
onKeyDown={onSearchInputEscape(filterString, setFilterString)}
|
||||
actions={
|
||||
filterString && (
|
||||
<Button
|
||||
@@ -191,22 +212,36 @@ export const RolesList = () => {
|
||||
))}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<ButtonTooltip
|
||||
type="primary"
|
||||
disabled={!canUpdateRoles}
|
||||
icon={<Plus size={12} />}
|
||||
onClick={() => setIsCreatingRole(true)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canUpdateRoles
|
||||
? 'You need additional permissions to add a new role'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Add role
|
||||
</ButtonTooltip>
|
||||
{canUpdateRoles ? (
|
||||
<Shortcut
|
||||
id={SHORTCUT_IDS.LIST_PAGE_NEW_ITEM}
|
||||
label="Add new role"
|
||||
onTrigger={() => setIsCreatingRole(true)}
|
||||
side="bottom"
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Plus size={12} />}
|
||||
onClick={() => setIsCreatingRole(true)}
|
||||
>
|
||||
Add role
|
||||
</Button>
|
||||
</Shortcut>
|
||||
) : (
|
||||
<ButtonTooltip
|
||||
type="primary"
|
||||
disabled
|
||||
icon={<Plus size={12} />}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: 'You need additional permissions to add a new role',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Add role
|
||||
</ButtonTooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Check, Copy, Edit, Eye, Filter, MoreVertical, Plus, Search, Trash, X }
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { parseAsString, useQueryState } from 'nuqs'
|
||||
import { useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -42,6 +42,7 @@ import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip'
|
||||
import { EntityTypeIcon } from '@/components/ui/EntityTypeIcon'
|
||||
import SchemaSelector from '@/components/ui/SchemaSelector'
|
||||
import { Shortcut } from '@/components/ui/Shortcut'
|
||||
import { useDatabasePublicationsQuery } from '@/data/database-publications/database-publications-query'
|
||||
import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
|
||||
import { useForeignTablesQuery } from '@/data/foreign-tables/foreign-tables-query'
|
||||
@@ -53,6 +54,9 @@ import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
|
||||
import { onSearchInputEscape } from '@/lib/keyboard'
|
||||
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
||||
import { useShortcut } from '@/state/shortcuts/useShortcut'
|
||||
|
||||
interface TableListProps {
|
||||
onAddTable: () => void
|
||||
@@ -77,6 +81,8 @@ export const TableList = ({
|
||||
|
||||
const [filterString, setFilterString] = useQueryState('search', parseAsString.withDefault(''))
|
||||
const [visibleTypes, setVisibleTypes] = useState<string[]>(Object.values(ENTITY_TYPE))
|
||||
const [schemaSelectorOpen, setSchemaSelectorOpen] = useState(false)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const { can: canUpdateTables } = useAsyncCheckPermissions(
|
||||
PermissionAction.TENANT_SQL_ADMIN_WRITE,
|
||||
@@ -187,6 +193,22 @@ export const TableList = ({
|
||||
|
||||
const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedSchema })
|
||||
|
||||
const canAddTables = canUpdateTables && !isSchemaLocked
|
||||
|
||||
useShortcut(
|
||||
SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
|
||||
() => {
|
||||
searchInputRef.current?.focus()
|
||||
searchInputRef.current?.select()
|
||||
},
|
||||
{ label: 'Search tables' }
|
||||
)
|
||||
|
||||
useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, () => {
|
||||
setVisibleTypes(Object.values(ENTITY_TYPE))
|
||||
setFilterString('')
|
||||
})
|
||||
|
||||
const error = tablesError || viewsError || materializedViewsError || foreignTablesError
|
||||
const isError = isErrorTables || isErrorViews || isErrorMaterializedViews || isErrorForeignTables
|
||||
const isLoading =
|
||||
@@ -209,13 +231,22 @@ export const TableList = ({
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center gap-2 flex-wrap">
|
||||
<div className="flex gap-2 items-center">
|
||||
<SchemaSelector
|
||||
className="grow lg:grow-0 w-[180px]"
|
||||
size="tiny"
|
||||
showError={false}
|
||||
selectedSchemaName={selectedSchema}
|
||||
onSelectSchema={setSelectedSchema}
|
||||
/>
|
||||
<Shortcut
|
||||
id={SHORTCUT_IDS.LIST_PAGE_FOCUS_SCHEMA}
|
||||
onTrigger={() => setSchemaSelectorOpen(true)}
|
||||
side="bottom"
|
||||
tooltipOpen={schemaSelectorOpen ? false : undefined}
|
||||
>
|
||||
<SchemaSelector
|
||||
className="grow lg:grow-0 w-[180px]"
|
||||
size="tiny"
|
||||
showError={false}
|
||||
selectedSchemaName={selectedSchema}
|
||||
onSelectSchema={setSelectedSchema}
|
||||
open={schemaSelectorOpen}
|
||||
onOpenChange={setSchemaSelectorOpen}
|
||||
/>
|
||||
</Shortcut>
|
||||
<Popover_Shadcn_>
|
||||
<PopoverTrigger_Shadcn_ asChild>
|
||||
<Button
|
||||
@@ -265,32 +296,43 @@ export const TableList = ({
|
||||
</div>
|
||||
<div className="flex grow justify-between gap-2 items-center">
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
size="tiny"
|
||||
containerClassName="grow lg:grow-0 w-52"
|
||||
placeholder="Search for a table"
|
||||
value={filterString}
|
||||
onChange={(e) => setFilterString(e.target.value)}
|
||||
onKeyDown={onSearchInputEscape(filterString, setFilterString)}
|
||||
icon={<Search />}
|
||||
/>
|
||||
|
||||
{!isSchemaLocked && (
|
||||
<ButtonTooltip
|
||||
className="w-auto ml-auto"
|
||||
icon={<Plus />}
|
||||
disabled={!canUpdateTables}
|
||||
onClick={() => onAddTable()}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canUpdateTables
|
||||
? 'You need additional permissions to create tables'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
New table
|
||||
</ButtonTooltip>
|
||||
)}
|
||||
{!isSchemaLocked &&
|
||||
(canAddTables ? (
|
||||
<Shortcut
|
||||
id={SHORTCUT_IDS.LIST_PAGE_NEW_ITEM}
|
||||
label="Create new table"
|
||||
onTrigger={() => onAddTable()}
|
||||
side="bottom"
|
||||
>
|
||||
<Button className="w-auto ml-auto" icon={<Plus />} onClick={() => onAddTable()}>
|
||||
New table
|
||||
</Button>
|
||||
</Shortcut>
|
||||
) : (
|
||||
<ButtonTooltip
|
||||
className="w-auto ml-auto"
|
||||
icon={<Plus />}
|
||||
disabled
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: 'You need additional permissions to create tables',
|
||||
},
|
||||
}}
|
||||
>
|
||||
New table
|
||||
</ButtonTooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+49
-18
@@ -1,9 +1,9 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { DatabaseZap, Plus, Search } from 'lucide-react'
|
||||
import { parseAsJson, parseAsString, useQueryState } from 'nuqs'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Card, Input, Table, TableBody, TableHead, TableHeader, TableRow } from 'ui'
|
||||
import { Button, Card, Input, Table, TableBody, TableHead, TableHeader, TableRow } from 'ui'
|
||||
import { EmptyStatePresentational } from 'ui-patterns'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
@@ -19,6 +19,7 @@ import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/L
|
||||
import AlertError from '@/components/ui/AlertError'
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
import { DocsButton } from '@/components/ui/DocsButton'
|
||||
import { Shortcut } from '@/components/ui/Shortcut'
|
||||
import { useDatabaseEventTriggerDeleteMutation } from '@/data/database-event-triggers/database-event-trigger-delete-mutation'
|
||||
import {
|
||||
useDatabaseEventTriggersQuery,
|
||||
@@ -27,9 +28,12 @@ import {
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
import { onSearchInputEscape } from '@/lib/keyboard'
|
||||
import { EMPTY_ARR } from '@/lib/void'
|
||||
import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
|
||||
import { useEditorPanelStateSnapshot } from '@/state/editor-panel-state'
|
||||
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
||||
import { useShortcut } from '@/state/shortcuts/useShortcut'
|
||||
import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
|
||||
|
||||
const DEFAULT_OWNER_FILTER = ['postgres']
|
||||
@@ -46,6 +50,7 @@ export const EventTriggersList = () => {
|
||||
)
|
||||
const ownerFilterValue = ownerFilter ?? DEFAULT_OWNER_FILTER
|
||||
const [triggerToDelete, setTriggerToDelete] = useState<DatabaseEventTrigger | null>(null)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const { openSidebar } = useSidebarManagerSnapshot()
|
||||
const aiSnap = useAiAssistantStateSnapshot()
|
||||
const {
|
||||
@@ -144,6 +149,20 @@ export const EventTriggersList = () => {
|
||||
return uniqueOwners.includes('postgres') ? uniqueOwners : ['postgres', ...uniqueOwners]
|
||||
}, [eventTriggers])
|
||||
|
||||
useShortcut(
|
||||
SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
|
||||
() => {
|
||||
searchInputRef.current?.focus()
|
||||
searchInputRef.current?.select()
|
||||
},
|
||||
{ label: 'Search event triggers' }
|
||||
)
|
||||
|
||||
useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, () => {
|
||||
setFilterString('')
|
||||
setOwnerFilter(null)
|
||||
})
|
||||
|
||||
const showEmptyState = useMemo(() => {
|
||||
const hasPostgresOwnerTriggers = eventTriggers.some((trigger) => trigger.owner === 'postgres')
|
||||
return (
|
||||
@@ -166,12 +185,14 @@ export const EventTriggersList = () => {
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-2 flex-wrap">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center gap-2 flex-wrap">
|
||||
<Input
|
||||
inputRef={searchInputRef}
|
||||
placeholder="Search for an event trigger"
|
||||
size="tiny"
|
||||
icon={<Search />}
|
||||
value={filterString}
|
||||
className="w-full lg:w-64"
|
||||
onChange={(e) => setFilterString(e.target.value)}
|
||||
onKeyDown={onSearchInputEscape(filterString, setFilterString)}
|
||||
/>
|
||||
<ReportsSelectFilter
|
||||
label="Owner"
|
||||
@@ -186,22 +207,32 @@ export const EventTriggersList = () => {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DocsButton href={`${DOCS_URL}/guides/database/postgres/event-triggers`} />
|
||||
<ButtonTooltip
|
||||
type="primary"
|
||||
icon={<Plus size={12} />}
|
||||
disabled={!canUpdateEventTriggers}
|
||||
onClick={createEventTrigger}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canUpdateEventTriggers
|
||||
? 'You need additional permissions to add a new event trigger'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
New trigger
|
||||
</ButtonTooltip>
|
||||
{canUpdateEventTriggers ? (
|
||||
<Shortcut
|
||||
id={SHORTCUT_IDS.LIST_PAGE_NEW_ITEM}
|
||||
label="Create new event trigger"
|
||||
onTrigger={createEventTrigger}
|
||||
side="bottom"
|
||||
>
|
||||
<Button type="primary" icon={<Plus size={12} />} onClick={createEventTrigger}>
|
||||
New trigger
|
||||
</Button>
|
||||
</Shortcut>
|
||||
) : (
|
||||
<ButtonTooltip
|
||||
type="primary"
|
||||
icon={<Plus size={12} />}
|
||||
disabled
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: 'You need additional permissions to add a new event trigger',
|
||||
},
|
||||
}}
|
||||
>
|
||||
New trigger
|
||||
</ButtonTooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { PostgresTrigger } from '@supabase/postgres-meta'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { DatabaseZap, Search } from 'lucide-react'
|
||||
import { parseAsBoolean, parseAsJson, parseAsString, useQueryState } from 'nuqs'
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Card, Input, Table, TableBody, TableHead, TableHeader, TableRow } from 'ui'
|
||||
import { EmptyStatePresentational } from 'ui-patterns'
|
||||
@@ -22,6 +22,7 @@ import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/L
|
||||
import AlertError from '@/components/ui/AlertError'
|
||||
import { DocsButton } from '@/components/ui/DocsButton'
|
||||
import SchemaSelector from '@/components/ui/SchemaSelector'
|
||||
import { Shortcut } from '@/components/ui/Shortcut'
|
||||
import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper'
|
||||
import { useDatabaseTriggerDeleteMutation } from '@/data/database-triggers/database-trigger-delete-mutation'
|
||||
import { useDatabaseTriggersQuery } from '@/data/database-triggers/database-triggers-query'
|
||||
@@ -31,7 +32,10 @@ import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { useIsProtectedSchema, useProtectedSchemas } from '@/hooks/useProtectedSchemas'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
import { onSearchInputEscape } from '@/lib/keyboard'
|
||||
import { useEditorPanelStateSnapshot } from '@/state/editor-panel-state'
|
||||
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
||||
import { useShortcut } from '@/state/shortcuts/useShortcut'
|
||||
import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
|
||||
|
||||
export const TriggersList = () => {
|
||||
@@ -45,6 +49,9 @@ export const TriggersList = () => {
|
||||
parseAsJson(selectFilterSchema.parse).withDefault([])
|
||||
)
|
||||
|
||||
const [schemaSelectorOpen, setSchemaSelectorOpen] = useState(false)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const isInlineEditorEnabled = useIsInlineEditorEnabled()
|
||||
const {
|
||||
templates: editorPanelTemplates,
|
||||
@@ -160,6 +167,30 @@ execute function function_name();`)
|
||||
const schemaTriggers = triggers.filter((x) => x.schema === selectedSchema)
|
||||
const tables = Array.from(new Set(schemaTriggers.map((x) => x.table))).sort()
|
||||
|
||||
const canAddTriggers = canCreateTriggers && !isSchemaLocked && hasTables
|
||||
|
||||
useShortcut(
|
||||
SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
|
||||
() => {
|
||||
searchInputRef.current?.focus()
|
||||
searchInputRef.current?.select()
|
||||
},
|
||||
{ label: 'Search triggers' }
|
||||
)
|
||||
|
||||
useShortcut(
|
||||
SHORTCUT_IDS.LIST_PAGE_NEW_ITEM,
|
||||
() => {
|
||||
createTrigger()
|
||||
},
|
||||
{ enabled: canAddTriggers, label: 'Create new trigger' }
|
||||
)
|
||||
|
||||
useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, () => {
|
||||
setFilterString('')
|
||||
setTablesFilter([])
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (isSuccess && !!triggerIdToEdit && !triggerToEdit) {
|
||||
toast('Trigger not found')
|
||||
@@ -194,20 +225,31 @@ execute function function_name();`)
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-2 flex-wrap">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center gap-2 flex-wrap">
|
||||
<SchemaSelector
|
||||
className="w-full lg:w-[180px]"
|
||||
size="tiny"
|
||||
showError={false}
|
||||
selectedSchemaName={selectedSchema}
|
||||
onSelectSchema={setSelectedSchema}
|
||||
/>
|
||||
<Shortcut
|
||||
id={SHORTCUT_IDS.LIST_PAGE_FOCUS_SCHEMA}
|
||||
onTrigger={() => setSchemaSelectorOpen(true)}
|
||||
side="bottom"
|
||||
tooltipOpen={schemaSelectorOpen ? false : undefined}
|
||||
>
|
||||
<SchemaSelector
|
||||
className="w-full lg:w-[180px]"
|
||||
size="tiny"
|
||||
showError={false}
|
||||
selectedSchemaName={selectedSchema}
|
||||
onSelectSchema={setSelectedSchema}
|
||||
open={schemaSelectorOpen}
|
||||
onOpenChange={setSchemaSelectorOpen}
|
||||
/>
|
||||
</Shortcut>
|
||||
<Input
|
||||
inputRef={searchInputRef}
|
||||
placeholder="Search for a trigger"
|
||||
size="tiny"
|
||||
icon={<Search />}
|
||||
value={filterString}
|
||||
className="w-full lg:w-52"
|
||||
onChange={(e) => setFilterString(e.target.value)}
|
||||
onKeyDown={onSearchInputEscape(filterString, setFilterString)}
|
||||
/>
|
||||
<ReportsSelectFilter
|
||||
label="Table"
|
||||
|
||||
@@ -33,6 +33,7 @@ const GROUP_LABELS: Record<string, string> = {
|
||||
'command-menu': 'Command Menu',
|
||||
'data-table': 'Data Tables',
|
||||
'inline-editor': 'Inline Editor',
|
||||
'list-page': 'List pages',
|
||||
nav: 'Navigation',
|
||||
'operation-queue': 'Operation Queue',
|
||||
results: 'Results',
|
||||
@@ -52,6 +53,7 @@ const GROUP_ORDER = [
|
||||
'data-table',
|
||||
'table-editor',
|
||||
'schema-visualizer',
|
||||
'list-page',
|
||||
'action-bar',
|
||||
'operation-queue',
|
||||
'unified-logs',
|
||||
|
||||
@@ -67,7 +67,7 @@ export const Shortcut = ({
|
||||
label,
|
||||
tooltipOpen,
|
||||
}: ShortcutProps) => {
|
||||
useShortcut(id, onTrigger, options)
|
||||
useShortcut(id, onTrigger, label !== undefined ? { ...options, label } : options)
|
||||
|
||||
return (
|
||||
<ShortcutTooltip
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { KeyboardEvent } from 'react'
|
||||
|
||||
type ClearableInputElement = HTMLInputElement | HTMLTextAreaElement
|
||||
|
||||
/**
|
||||
* Staged-Escape handler for search/filter inputs:
|
||||
* - Escape while the input has a value → clear the value (keeps focus)
|
||||
* - Escape while the input is empty → blur the input
|
||||
*
|
||||
* Stops propagation on Escape so the keystroke doesn't bubble to dialog/popover
|
||||
* close handlers when the consumer is nested inside one.
|
||||
*
|
||||
* @example
|
||||
* <Input
|
||||
* value={query}
|
||||
* onChange={(e) => setQuery(e.target.value)}
|
||||
* onKeyDown={onSearchInputEscape(query, setQuery)}
|
||||
* />
|
||||
*/
|
||||
export const onSearchInputEscape =
|
||||
<T extends ClearableInputElement>(value: string, onClear: (next: string) => void) =>
|
||||
(event: KeyboardEvent<T>) => {
|
||||
if (event.key !== 'Escape') return
|
||||
event.stopPropagation()
|
||||
if (value.length > 0) {
|
||||
onClear('')
|
||||
} else {
|
||||
event.currentTarget.blur()
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LIST_PAGE_SHORTCUT_IDS, listPageRegistry } from './registry/list-page'
|
||||
import {
|
||||
SCHEMA_VISUALIZER_SHORTCUT_IDS,
|
||||
schemaVisualizerRegistry,
|
||||
@@ -60,6 +61,9 @@ export const SHORTCUT_IDS = {
|
||||
|
||||
// Schema visualizer shortcuts
|
||||
...SCHEMA_VISUALIZER_SHORTCUT_IDS,
|
||||
|
||||
// Shared list-page shortcuts (database/* listing pages, etc.)
|
||||
...LIST_PAGE_SHORTCUT_IDS,
|
||||
} as const
|
||||
|
||||
/**
|
||||
@@ -314,4 +318,7 @@ export const SHORTCUT_DEFINITIONS: Record<ShortcutId, ShortcutDefinition> = {
|
||||
|
||||
// Schema visualizer shortcut registration
|
||||
...schemaVisualizerRegistry,
|
||||
|
||||
// Shared list-page shortcut registration
|
||||
...listPageRegistry,
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { RegistryDefinations } from '../types'
|
||||
|
||||
/**
|
||||
* Shared shortcuts for "list pages" — the conventional Database/* page shape
|
||||
* with a schema selector, a search input, a primary "create new" action, and
|
||||
* one or more facet filters.
|
||||
*
|
||||
* Pages register their per-page handlers via `useShortcut`. Pass a `label`
|
||||
* override to make the Cmd+K entry and hover tooltip read e.g. "Search tables"
|
||||
* instead of the generic "Focus search" stored here.
|
||||
*/
|
||||
export const LIST_PAGE_SHORTCUT_IDS = {
|
||||
LIST_PAGE_FOCUS_SCHEMA: 'list-page.focus-schema',
|
||||
LIST_PAGE_FOCUS_SEARCH: 'list-page.focus-search',
|
||||
LIST_PAGE_NEW_ITEM: 'list-page.new-item',
|
||||
LIST_PAGE_RESET_FILTERS: 'list-page.reset-filters',
|
||||
}
|
||||
|
||||
export type ListPageShortcutId =
|
||||
(typeof LIST_PAGE_SHORTCUT_IDS)[keyof typeof LIST_PAGE_SHORTCUT_IDS]
|
||||
|
||||
export const listPageRegistry: RegistryDefinations<ListPageShortcutId> = {
|
||||
[LIST_PAGE_SHORTCUT_IDS.LIST_PAGE_FOCUS_SCHEMA]: {
|
||||
id: LIST_PAGE_SHORTCUT_IDS.LIST_PAGE_FOCUS_SCHEMA,
|
||||
label: 'Open schema selector',
|
||||
sequence: ['O', 'S'],
|
||||
showInSettings: false,
|
||||
options: { ignoreInputs: true, registerInCommandMenu: true },
|
||||
},
|
||||
[LIST_PAGE_SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH]: {
|
||||
id: LIST_PAGE_SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
|
||||
label: 'Focus search',
|
||||
sequence: ['Shift+F'],
|
||||
showInSettings: false,
|
||||
options: { registerInCommandMenu: true },
|
||||
},
|
||||
[LIST_PAGE_SHORTCUT_IDS.LIST_PAGE_NEW_ITEM]: {
|
||||
id: LIST_PAGE_SHORTCUT_IDS.LIST_PAGE_NEW_ITEM,
|
||||
label: 'Create new item',
|
||||
sequence: ['Shift+N'],
|
||||
showInSettings: false,
|
||||
options: { registerInCommandMenu: true },
|
||||
},
|
||||
[LIST_PAGE_SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS]: {
|
||||
id: LIST_PAGE_SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS,
|
||||
label: 'Reset filters',
|
||||
sequence: ['F', 'C'],
|
||||
showInSettings: false,
|
||||
options: { ignoreInputs: true, registerInCommandMenu: true },
|
||||
},
|
||||
}
|
||||
@@ -55,6 +55,16 @@ export interface ShortcutOptions {
|
||||
* context-specific shortcuts that only make sense in certain views.
|
||||
*/
|
||||
registerInCommandMenu?: boolean
|
||||
|
||||
/**
|
||||
* Override the registry's default `label` for the duration of this mount.
|
||||
* Flows to the Cmd+K command-menu entry and the hover tooltip rendered via
|
||||
* `<Shortcut>`. Use for shared shortcuts whose contextual label changes per
|
||||
* page (e.g. `list-page.focus-search` rendering as "Search tables" on one
|
||||
* page and "Search functions" on another). Leave `undefined` to use the
|
||||
* registry default.
|
||||
*/
|
||||
label?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -72,6 +72,7 @@ export function useShortcut(id: ShortcutId, callback: () => void, options?: Shor
|
||||
const ignoreInputs = options?.ignoreInputs ?? def.options?.ignoreInputs
|
||||
const registerInCommandMenu =
|
||||
options?.registerInCommandMenu ?? def.options?.registerInCommandMenu ?? false
|
||||
const label = options?.label ?? def.label
|
||||
|
||||
// Only include `ignoreInputs` when set. The library resolves it to a concrete
|
||||
// boolean at register time (false for Meta/Ctrl/Escape, true otherwise), but
|
||||
@@ -86,7 +87,7 @@ export function useShortcut(id: ShortcutId, callback: () => void, options?: Shor
|
||||
|
||||
// Handle overrides for command menu
|
||||
const enabledInCommandMenu = enabled && registerInCommandMenu
|
||||
const depsInCommandMenu = [enabled, def.label]
|
||||
const depsInCommandMenu = [enabled, label]
|
||||
const callbackRef = useLatest(callback)
|
||||
const setCommandMenuOpen = useSetCommandMenuOpen()
|
||||
const stableAction = useCallback(() => {
|
||||
@@ -99,7 +100,7 @@ export function useShortcut(id: ShortcutId, callback: () => void, options?: Shor
|
||||
[
|
||||
{
|
||||
id,
|
||||
name: def.label,
|
||||
name: label,
|
||||
action: stableAction,
|
||||
badge: () => (
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { Label as LabelPrimitive, Slot as SlotPrimitive } from 'radix-ui'
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Label as LabelPrimitive, Slot as SlotPrimitive } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
import {
|
||||
Controller,
|
||||
@@ -182,31 +181,37 @@ const FormMessage = React.forwardRef<
|
||||
|
||||
FormMessage.displayName = 'FormMessage'
|
||||
|
||||
function FormInputGroupInput(props: InputProps) {
|
||||
const FormInputGroupInput = React.forwardRef<HTMLInputElement, InputProps>((props, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<InputGroupInput
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={!error ? formDescriptionId : `${formDescriptionId} ${formMessageId}`}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})
|
||||
FormInputGroupInput.displayName = 'FormInputGroupInput'
|
||||
|
||||
function FormInputGroupTextArea(props: TextareaProps) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
const FormInputGroupTextArea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
(props, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<InputGroupTextarea
|
||||
id={formItemId}
|
||||
aria-describedby={!error ? formDescriptionId : `${formDescriptionId} ${formMessageId}`}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<InputGroupTextarea
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={!error ? formDescriptionId : `${formDescriptionId} ${formMessageId}`}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
FormInputGroupTextArea.displayName = 'FormInputGroupTextArea'
|
||||
|
||||
export {
|
||||
Form,
|
||||
|
||||
@@ -159,9 +159,10 @@ function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
/*
|
||||
* If you need to use this component inside a <Form>, use FormInputGroupInput instead.
|
||||
*/
|
||||
function InputGroupInput({ className, ...props }: InputProps) {
|
||||
return (
|
||||
const InputGroupInput = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<Input
|
||||
ref={ref}
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
'flex-1 rounded-none border border-transparent -m-px bg-transparent shadow-none',
|
||||
@@ -174,14 +175,16 @@ function InputGroupInput({ className, ...props }: InputProps) {
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
InputGroupInput.displayName = 'InputGroupInput'
|
||||
|
||||
/*
|
||||
* If you need to use this component inside a <Form>, use FormInputGroupTextArea instead.
|
||||
*/
|
||||
function InputGroupTextarea({ className, ...props }: TextareaProps) {
|
||||
return (
|
||||
const InputGroupTextarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<Textarea
|
||||
ref={ref}
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
'flex-1 resize-none rounded-none border border-transparent bg-transparent py-0 shadow-none',
|
||||
@@ -191,7 +194,8 @@ function InputGroupTextarea({ className, ...props }: TextareaProps) {
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
InputGroupTextarea.displayName = 'InputGroupTextarea'
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
|
||||
Reference in new issue
Block a user