| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437 |
- 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,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
- Popover,
- PopoverContent,
- PopoverTrigger,
- Select,
- SelectContent,
- SelectItem,
- SelectSeparator,
- SelectTrigger,
- SelectValue,
- SidePanel,
- } 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 { 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
- }
- 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 = 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 modal={false} open={schemaDropdownOpen} onOpenChange={setSchemaDropdownOpen}>
- <PopoverTrigger 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>
- <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}
- 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>
- ))}
- </CommandGroup>
- </CommandList>
- </Command>
- </PopoverContent>
- </Popover>
- </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 modal={false} open={tableDropdownOpen} onOpenChange={setTableDropdownOpen}>
- <PopoverTrigger 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>
- <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 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>
- ))}
- </CommandGroup>
- </CommandList>
- </Command>
- </PopoverContent>
- </Popover>
- </FormItemLayout>
- {selectedEntity && (
- <FormItemLayout id="columns" label="Select up to 32 columns" isReactForm={false}>
- {isLoadingTableColumns && <ShimmeringLoader className="py-4" />}
- {isSuccessTableColumns && (
- <MultiSelector values={selectedColumns} onValuesChange={setSelectedColumns}>
- <MultiSelectorTrigger
- id="columns"
- mode="inline-combobox"
- label={
- selectedColumns.length === 0
- ? 'Choose which columns to create an index on'
- : 'Search for a column'
- }
- deletableBadge
- badgeLimit="wrap"
- showIcon={false}
- />
- <MultiSelectorContent>
- <MultiSelectorList>
- {columnOptions.map((option) => (
- <MultiSelectorItem
- key={option.id}
- value={option.value}
- disabled={option.disabled}
- >
- {option.name}
- </MultiSelectorItem>
- ))}
- </MultiSelectorList>
- </MultiSelectorContent>
- </MultiSelector>
- )}
- </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
- disabled={isOrioleDb}
- value={selectedIndexType}
- onValueChange={setSelectedIndexType}
- name="selected-index-type"
- >
- <SelectTrigger size={'small'}>
- <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>
- </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>
- )
- }
|