Files
supabase/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx
Joshen Lim f2e20eac34 Joshen/fe 3651 deprecate monacoeditor from grid folder to use codeeditor (#47179)
## Context

Part of efforts to consolidate all the code editors that we have in the
repository

`CodeEditor` will serve as the base monaco editor file that all UIs
should consume from
It's aimed to be generic and just stores the common logic that will be
generally used where-ever we need a code editor (editor options, base
editor set up on mount, etc)

The idea is that `CodeEditor` holds just 3 default actions (run queyr,
format document and placeholder fill)
If any editor needs specific behaviours (e.g SQL Editor), they can
declare them in the `onMount` prop of `CodeEditor` which gives some
flexibility

## Changes involved
- Use `CodeEditor` component for SQL Editor's `MonacoEditor`
- Shifted Cmd K behaviour into `CodeEditor` since that's probably needed
everywhere that we render that UI
- Deprecate `MonacoEditor` from the table editor's `grid` folder
- All files that were using that component to use `CodeEditor` component
instead

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

## Release Notes

* **Refactor**
* Migrated the studio’s code editing UI (including JSON, text, and
payload viewers) to a unified CodeEditor experience.
* **New Features**
  * Added plaintext language support for read-only/truncated views.
* **Behavior Changes**
* Improved editor startup by setting cursor position consistently and
deferring autofocus.
* Streamlined editor context-menu actions to the core set (run query,
format, placeholder fill).
* Updated SQL editor wiring for more consistent command/menu and
selection handling.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-23 11:39:24 +08:00

571 lines
22 KiB
TypeScript

import { zodResolver } from '@hookform/resolvers/zod'
import { Check, ChevronsUpDown, Loader2 } from 'lucide-react'
import Link from 'next/link'
import { Fragment, useEffect, useMemo, useState } from 'react'
import { useForm, useWatch } from 'react-hook-form'
import { toast } from 'sonner'
import {
Button,
cn,
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
Form,
FormControl,
FormField,
Popover,
PopoverContent,
PopoverTrigger,
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
Separator,
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetSection,
SheetTitle,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import {
MultiSelector,
MultiSelectorContent,
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
} from 'ui-patterns/multi-select'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import * as z from 'zod'
import { INDEX_TYPES } from './Indexes.constants'
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'
interface CreateIndexSidePanelProps {
visible: boolean
onClose: () => void
}
const formSchema = z.object({
schema: z.string().min(1, 'Please provide a name for your schema'),
table: z.string().min(1, 'Please provide a name for your table'),
columns: z
.array(z.string())
.min(1, 'Please select at least one column')
.max(32, 'You can select up to 32 columns'),
type: z.string().min(1, 'Please select an index type'),
})
type FormSchema = z.infer<typeof formSchema>
export const CreateIndexSidePanel = ({ visible, onClose }: CreateIndexSidePanelProps) => {
const { data: project } = useSelectedProjectQuery()
const isOrioleDb = useIsOrioleDb()
const form = useForm<FormSchema>({
resolver: zodResolver(formSchema),
defaultValues: {
schema: 'public',
table: '',
columns: [],
type: INDEX_TYPES[0].value,
},
})
const formId = 'schema-form'
const selectedSchema = useWatch({ name: 'schema', control: form.control })
const selectedEntity = useWatch({ name: 'table', control: form.control })
const selectedColumns = useWatch({ name: 'columns', control: form.control }) ?? []
const selectedIndexType = useWatch({ name: 'type', control: form.control })
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)
}
const columns = tableColumns?.[0]?.columns ?? []
const columnOptions = 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 { reset } = form
useEffect(() => {
if (visible) {
reset()
setSchemaSearchTerm('')
setSearchTerm('')
}
}, [visible, reset])
useEffect(() => {
if (!schemaDropdownOpen) setSchemaSearchTerm('')
}, [schemaDropdownOpen])
const isSelectEntityDisabled = entityTypes.length === 0 && searchTerm.trim().length === 0
function onSubmit(values: z.infer<typeof formSchema>) {
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: values.schema,
entity: values.table,
type: values.type,
columns: values.columns,
},
})
}
return (
<Sheet open={visible} onOpenChange={() => onClose()}>
<SheetContent size="lg" className="flex flex-col gap-0">
<SheetHeader>
<SheetTitle>Create new index</SheetTitle>
</SheetHeader>
<Form {...form}>
<form
id={formId}
onSubmit={form.handleSubmit(onSubmit)}
className="overflow-auto grow px-0"
>
<SheetSection>
<FormField
control={form.control}
name="schema"
render={({ field }) => (
<FormItemLayout layout="horizontal" label="Select a schema" id="schema">
<FormControl className="col-span-6">
<Popover
modal={false}
open={schemaDropdownOpen}
onOpenChange={setSchemaDropdownOpen}
>
<PopoverTrigger asChild>
<Button
id="schema"
variant="default"
size={'medium'}
className={`w-full [&>span]:w-full text-left`}
iconRight={
<ChevronsUpDown
className="text-foreground-muted"
strokeWidth={2}
size={14}
/>
}
>
{field.value || 'Choose a schema'}
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
side="bottom"
align="start"
sameWidthAsTrigger
>
<Command>
<CommandInput
placeholder="Find schema..."
value={schemaSearchTerm}
onValueChange={setSchemaSearchTerm}
/>
<CommandList
className={cn(
(schemas ?? []).length > 7 && 'max-h-[210px]! overflow-y-auto'
)}
onWheel={(event) => event.stopPropagation()}
>
<CommandEmpty>No schemas found</CommandEmpty>
<CommandGroup>
{(schemas ?? []).map((schema) => (
<CommandItem
key={schema.name}
value={schema.name}
className="cursor-pointer flex items-center space-x-2 w-full"
onSelect={() => {
field.onChange(schema.name)
form.setValue('table', '')
form.setValue('columns', [])
form.setValue('type', INDEX_TYPES[0].value)
setSearchTerm('')
}}
>
<Check
className={cn(
'text-brand',
schema.name === field.value ? 'opacity-100' : 'opacity-0'
)}
strokeWidth={2}
size={16}
/>
<span>{schema.name}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</FormControl>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="w-full" />
<SheetSection>
<FormField
control={form.control}
name="table"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Select a table"
id="table"
description={
isSelectEntityDisabled &&
!isLoadingEntities &&
'Create a table in this schema via the Table or SQL editor first'
}
>
<FormControl className="col-span-6">
<Popover
modal={false}
open={tableDropdownOpen}
onOpenChange={setTableDropdownOpen}
>
<PopoverTrigger
asChild
disabled={isSelectEntityDisabled || isLoadingEntities}
>
<Button
id="table"
variant="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}
/>
}
>
{field.value
? field.value
: isSelectEntityDisabled
? 'No tables available in schema'
: 'Choose a table'}
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
side="bottom"
align="start"
sameWidthAsTrigger
>
{/* [Terry] shouldFilter context:
https://github.com/pacocoursey/cmdk/issues/267#issuecomment-2252717107 */}
<Command shouldFilter={false}>
<CommandInput
placeholder="Find table..."
value={searchTerm}
onValueChange={handleSearchChange}
/>
<CommandList
className={cn(
entityTypes.length > 7 && 'max-h-[210px]! overflow-y-auto'
)}
onWheel={(event) => event.stopPropagation()}
>
<CommandEmpty>
{isLoadingEntities ? (
<div className="flex items-center gap-2 text-center justify-center">
<Loader2 size={12} className="animate-spin" />
Loading...
</div>
) : (
'No tables found'
)}
</CommandEmpty>
<CommandGroup>
{entityTypes.map((entity) => (
<CommandItem
key={entity.name}
className="cursor-pointer flex items-center space-x-2 w-full"
onSelect={() => {
field.onChange(entity.name)
setTableDropdownOpen(false)
form.setValue('columns', [])
form.setValue('type', INDEX_TYPES[0].value)
}}
>
<Check
className={cn(
'text-brand',
entity.name === field.value ? 'opacity-100' : 'opacity-0'
)}
strokeWidth={2}
size={16}
/>
<span>{entity.name}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</FormControl>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="w-full" />
{selectedEntity && (
<SheetSection>
<FormField
control={form.control}
name="columns"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Select up to 32 columns"
id="columns"
>
{isLoadingTableColumns && <ShimmeringLoader className="py-4" />}
{isSuccessTableColumns && (
<div className="col-span-6">
<MultiSelector
onValuesChange={field.onChange}
values={field.value}
size="small"
className="w-full"
>
<MultiSelectorTrigger
id="columns"
mode="inline-combobox"
label={
field.value.length === 0
? 'Choose which columns to create an index on'
: 'Search for a column'
}
deletableBadge
badgeLimit="wrap"
showIcon={false}
className="w-full"
/>
<MultiSelectorContent>
<MultiSelectorList>
{columnOptions.map((option) => (
<MultiSelectorItem
key={option.id}
value={option.value}
disabled={option.disabled}
>
{option.name}
</MultiSelectorItem>
))}
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
</div>
)}
</FormItemLayout>
)}
/>
</SheetSection>
)}
<Separator className="w-full" />
{selectedColumns.length > 0 && (
<>
<SheetSection>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<>
<FormItemLayout layout="horizontal" label="Select an index type">
<FormControl className="col-span-6">
<Select
disabled={isOrioleDb}
value={field.value}
onValueChange={field.onChange}
>
<SelectTrigger>
<SelectValue className="font-mono">{selectedIndexType}</SelectValue>
</SelectTrigger>
<SelectContent>
{INDEX_TYPES.map((index, i) => (
<Fragment key={index.name}>
<SelectItem 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-checked:text-foreground-light"
key={`${index.value}-description-${idx}`}
>
{x}
</span>
))}
</div>
</SelectItem>
{i < INDEX_TYPES.length - 1 && <SelectSeparator />}
</Fragment>
))}
</SelectContent>
</Select>
</FormControl>
</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>
)}
</>
)}
/>
</SheetSection>
<Separator className="w-full" />
<SheetSection>
<div className="flex items-center justify-between">
<p className="text-sm">Preview of SQL statement</p>
<Button asChild variant="default">
<Link
href={
project !== undefined
? `/project/${project.ref}/sql/new?content=${generatedSQL}`
: '/'
}
>
Open in SQL Editor
</Link>
</Button>
</div>
</SheetSection>
<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>
</>
)}
</form>
</Form>
<SheetFooter>
<Button
variant="default"
disabled={isExecuting}
onClick={() => {
form.reset()
onClose()
}}
>
Cancel
</Button>
<Button
variant="primary"
form={formId}
type="submit"
disabled={isExecuting}
loading={isExecuting}
>
Create index
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}