Files
supabase/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx
Vaibhav 88fd7f1185 fix: create index form dropdown behavior (#43906)
## TL;DR
Fixes/improves the create Index form ux by fixing schema/table dropdown
state coupling and dropdown scroll behavior

## Before

https://github.com/user-attachments/assets/093a6b42-b13e-4138-ab86-45149f4894cb

## After

https://github.com/user-attachments/assets/db9cc11b-0218-4276-9ccf-9c81831e95e5

## Related
- closes https://github.com/supabase/supabase/issues/43878
2026-03-23 09:18:58 -06:00

436 lines
16 KiB
TypeScript

import CodeEditor from 'components/ui/CodeEditor/CodeEditor'
import { DocsButton } from 'components/ui/DocsButton'
import { useDatabaseIndexCreateMutation } from 'data/database-indexes/index-create-mutation'
import { useSchemasQuery } from 'data/database/schemas-query'
import { useTableColumnsQuery } from 'data/database/table-columns-query'
import { useEntityTypesQuery } from 'data/entity-types/entity-types-infinite-query'
import { useIsOrioleDb, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { DOCS_URL } from 'lib/constants'
import { Check, ChevronsUpDown, Loader2 } from 'lucide-react'
import Link from 'next/link'
import { Fragment, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import {
Button,
cn,
Command_Shadcn_,
CommandEmpty_Shadcn_,
CommandGroup_Shadcn_,
CommandInput_Shadcn_,
CommandItem_Shadcn_,
CommandList_Shadcn_,
Popover_Shadcn_,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
Select_Shadcn_,
SelectContent_Shadcn_,
SelectItem_Shadcn_,
SelectSeparator_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
SidePanel,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { MultiSelectOption } from 'ui-patterns/MultiSelectDeprecated'
import { MultiSelectV2 } from 'ui-patterns/MultiSelectDeprecated/MultiSelectV2'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { INDEX_TYPES } from './Indexes.constants'
interface CreateIndexSidePanelProps {
visible: boolean
onClose: () => void
}
export const CreateIndexSidePanel = ({ visible, onClose }: CreateIndexSidePanelProps) => {
const { data: project } = useSelectedProjectQuery()
const isOrioleDb = useIsOrioleDb()
const [selectedSchema, setSelectedSchema] = useState('public')
const [selectedEntity, setSelectedEntity] = useState<string | undefined>(undefined)
const [selectedColumns, setSelectedColumns] = useState<string[]>([])
const [selectedIndexType, setSelectedIndexType] = useState<string>(INDEX_TYPES[0].value)
const [schemaDropdownOpen, setSchemaDropdownOpen] = useState(false)
const [tableDropdownOpen, setTableDropdownOpen] = useState(false)
const [schemaSearchTerm, setSchemaSearchTerm] = useState('')
const [searchTerm, setSearchTerm] = useState('')
const { data: schemas } = useSchemasQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const { data: entities, isPending: isLoadingEntities } = useEntityTypesQuery({
schemas: [selectedSchema],
sort: 'alphabetical',
search: searchTerm,
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const {
data: tableColumns,
isPending: isLoadingTableColumns,
isSuccess: isSuccessTableColumns,
} = useTableColumnsQuery({
schema: selectedSchema,
table: selectedEntity,
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const { mutate: createIndex, isPending: isExecuting } = useDatabaseIndexCreateMutation({
onSuccess: () => {
onClose()
toast.success(`Successfully created index`)
},
})
const entityTypes = useMemo(
() => entities?.pages.flatMap((page) => page.data.entities) || [],
[entities?.pages]
)
function handleSearchChange(value: string) {
setSearchTerm(value)
}
function handleSchemaSelect(schema: string) {
setSelectedSchema(schema)
setSearchTerm('')
setSchemaSearchTerm('')
setSchemaDropdownOpen(false)
}
const columns = tableColumns?.[0]?.columns ?? []
const columnOptions: MultiSelectOption[] = columns
.filter((column): column is NonNullable<typeof column> => column !== null)
.map((column) => ({
id: column.attname,
value: column.attname,
name: column.attname,
disabled: false,
}))
const generatedSQL = `
CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexType} (${selectedColumns
.map((column) => `"${column}"`)
.join(', ')});
`.trim()
const onSaveIndex = () => {
if (!project) return console.error('Project is required')
if (!selectedEntity) return console.error('Entity is required')
createIndex({
projectRef: project.ref,
connectionString: project.connectionString,
payload: {
schema: selectedSchema,
entity: selectedEntity,
type: selectedIndexType,
columns: selectedColumns,
},
})
}
useEffect(() => {
if (visible) {
setSelectedSchema('public')
setSelectedEntity('')
setSelectedColumns([])
setSelectedIndexType(INDEX_TYPES[0].value)
setSchemaSearchTerm('')
setSearchTerm('')
}
}, [visible])
useEffect(() => {
setSelectedEntity('')
setSelectedColumns([])
setSelectedIndexType(INDEX_TYPES[0].value)
setSearchTerm('')
}, [selectedSchema])
useEffect(() => {
setSelectedColumns([])
setSelectedIndexType(INDEX_TYPES[0].value)
}, [selectedEntity])
useEffect(() => {
if (!schemaDropdownOpen) setSchemaSearchTerm('')
}, [schemaDropdownOpen])
const isSelectEntityDisabled = entityTypes.length === 0 && searchTerm.trim().length === 0
return (
<SidePanel
size="large"
header="Create new index"
visible={visible}
onCancel={onClose}
onConfirm={() => onSaveIndex()}
loading={isExecuting}
confirmText="Create index"
>
<div className="py-6 space-y-6">
<SidePanel.Content className="space-y-6">
<FormItemLayout label="Select a schema" name="select-schema" isReactForm={false}>
<Popover_Shadcn_
modal={false}
open={schemaDropdownOpen}
onOpenChange={setSchemaDropdownOpen}
>
<PopoverTrigger_Shadcn_ asChild>
<Button
type="default"
size={'medium'}
className={`w-full [&>span]:w-full text-left`}
iconRight={
<ChevronsUpDown className="text-foreground-muted" strokeWidth={2} size={14} />
}
>
{selectedSchema !== undefined && selectedSchema !== ''
? selectedSchema
: 'Choose a schema'}
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_
className="p-0"
side="bottom"
align="start"
sameWidthAsTrigger
>
<Command_Shadcn_>
<CommandInput_Shadcn_
placeholder="Find schema..."
value={schemaSearchTerm}
onValueChange={setSchemaSearchTerm}
/>
<CommandList_Shadcn_
className={cn((schemas ?? []).length > 7 && '!max-h-[210px] overflow-y-auto')}
onWheel={(event) => event.stopPropagation()}
>
<CommandEmpty_Shadcn_>No schemas found</CommandEmpty_Shadcn_>
<CommandGroup_Shadcn_>
{(schemas ?? []).map((schema) => (
<CommandItem_Shadcn_
key={schema.name}
className="cursor-pointer flex items-center justify-between space-x-2 w-full"
onSelect={() => {
handleSchemaSelect(schema.name)
}}
onClick={() => {
handleSchemaSelect(schema.name)
}}
>
<span>{schema.name}</span>
{selectedSchema === schema.name && (
<Check className="text-brand" strokeWidth={2} size={16} />
)}
</CommandItem_Shadcn_>
))}
</CommandGroup_Shadcn_>
</CommandList_Shadcn_>
</Command_Shadcn_>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
</FormItemLayout>
<FormItemLayout
label="Select a table"
name="select-table"
description={
isSelectEntityDisabled &&
!isLoadingEntities &&
'Create a table in this schema via the Table or SQL editor first'
}
isReactForm={false}
>
<Popover_Shadcn_
modal={false}
open={tableDropdownOpen}
onOpenChange={setTableDropdownOpen}
>
<PopoverTrigger_Shadcn_
asChild
disabled={isSelectEntityDisabled || isLoadingEntities}
>
<Button
type="default"
size="medium"
className={cn(
'w-full [&>span]:w-full text-left',
selectedEntity === '' && 'text-foreground-lighter'
)}
iconRight={
<ChevronsUpDown className="text-foreground-muted" strokeWidth={2} size={14} />
}
>
{selectedEntity !== undefined && selectedEntity !== ''
? selectedEntity
: isSelectEntityDisabled
? 'No tables available in schema'
: 'Choose a table'}
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_
className="p-0"
side="bottom"
align="start"
sameWidthAsTrigger
>
{/* [Terry] shouldFilter context:
https://github.com/pacocoursey/cmdk/issues/267#issuecomment-2252717107 */}
<Command_Shadcn_ shouldFilter={false}>
<CommandInput_Shadcn_
placeholder="Find table..."
value={searchTerm}
onValueChange={handleSearchChange}
/>
<CommandList_Shadcn_
className={cn(entityTypes.length > 7 && '!max-h-[210px] overflow-y-auto')}
onWheel={(event) => event.stopPropagation()}
>
<CommandEmpty_Shadcn_>
{isLoadingEntities ? (
<div className="flex items-center gap-2 text-center justify-center">
<Loader2 size={12} className="animate-spin" />
Loading...
</div>
) : (
'No tables found'
)}
</CommandEmpty_Shadcn_>
<CommandGroup_Shadcn_>
{entityTypes.map((entity) => (
<CommandItem_Shadcn_
key={entity.name}
className="cursor-pointer flex items-center justify-between space-x-2 w-full"
onSelect={() => {
setSelectedEntity(entity.name)
setTableDropdownOpen(false)
}}
onClick={() => {
setSelectedEntity(entity.name)
setTableDropdownOpen(false)
}}
>
<span>{entity.name}</span>
{selectedEntity === entity.name && (
<Check className="text-brand" strokeWidth={2} size={16} />
)}
</CommandItem_Shadcn_>
))}
</CommandGroup_Shadcn_>
</CommandList_Shadcn_>
</Command_Shadcn_>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
</FormItemLayout>
{selectedEntity && (
<FormItemLayout label="Select up to 32 columns" isReactForm={false}>
{isLoadingTableColumns && <ShimmeringLoader className="py-4" />}
{isSuccessTableColumns && (
<MultiSelectV2
options={columnOptions}
placeholder="Choose which columns to create an index on"
searchPlaceholder="Search for a column"
value={selectedColumns}
onChange={setSelectedColumns}
/>
)}
</FormItemLayout>
)}
</SidePanel.Content>
{selectedColumns.length > 0 && (
<>
<SidePanel.Separator />
<SidePanel.Content className="space-y-6">
<FormItemLayout
label="Select an index type"
name="selected-index-type"
isReactForm={false}
>
<Select_Shadcn_
disabled={isOrioleDb}
value={selectedIndexType}
onValueChange={setSelectedIndexType}
name="selected-index-type"
>
<SelectTrigger_Shadcn_ size={'small'}>
<SelectValue_Shadcn_ className="font-mono">
{selectedIndexType}
</SelectValue_Shadcn_>
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{INDEX_TYPES.map((index, i) => (
<Fragment key={index.name}>
<SelectItem_Shadcn_ value={index.value}>
<div className="flex flex-col gap-0.5">
<span>{index.name}</span>
{index.description.split('\n').map((x, idx) => (
<span
className="text-foreground-lighter group-focus:text-foreground-light group-data-[state=checked]:text-foreground-light"
key={`${index.value}-description-${idx}`}
>
{x}
</span>
))}
</div>
</SelectItem_Shadcn_>
{i < INDEX_TYPES.length - 1 && <SelectSeparator_Shadcn_ />}
</Fragment>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormItemLayout>
{isOrioleDb && (
<Admonition
type="default"
className="!mt-2"
title="OrioleDB currently only supports the B-tree index type"
description="More index types may be supported when OrioleDB is no longer in preview"
>
{/* [Joshen Oriole] Hook up proper docs URL */}
<DocsButton className="mt-2" abbrev={false} href={`${DOCS_URL}`} />
</Admonition>
)}
</SidePanel.Content>
<SidePanel.Separator />
<SidePanel.Content>
<div className="flex items-center justify-between">
<p className="text-sm">Preview of SQL statement</p>
<Button asChild type="default">
<Link
href={
project !== undefined
? `/project/${project.ref}/sql/new?content=${generatedSQL}`
: '/'
}
>
Open in SQL Editor
</Link>
</Button>
</div>
</SidePanel.Content>
<div className="h-[200px] !mt-2">
<div className="relative h-full">
<CodeEditor
isReadOnly
autofocus={false}
id={`${selectedSchema}-${selectedEntity}-${selectedColumns.join(
','
)}-${selectedIndexType}`}
language="pgsql"
defaultValue={generatedSQL}
/>
</div>
</div>
</>
)}
</div>
</SidePanel>
)
}