CreateIndexSidePanel.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  1. import { Check, ChevronsUpDown, Loader2 } from 'lucide-react'
  2. import Link from 'next/link'
  3. import { Fragment, useEffect, useMemo, useState } from 'react'
  4. import { toast } from 'sonner'
  5. import {
  6. Button,
  7. cn,
  8. Command,
  9. CommandEmpty,
  10. CommandGroup,
  11. CommandInput,
  12. CommandItem,
  13. CommandList,
  14. Popover,
  15. PopoverContent,
  16. PopoverTrigger,
  17. Select,
  18. SelectContent,
  19. SelectItem,
  20. SelectSeparator,
  21. SelectTrigger,
  22. SelectValue,
  23. SidePanel,
  24. } from 'ui'
  25. import { Admonition } from 'ui-patterns'
  26. import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
  27. import {
  28. MultiSelector,
  29. MultiSelectorContent,
  30. MultiSelectorItem,
  31. MultiSelectorList,
  32. MultiSelectorTrigger,
  33. } from 'ui-patterns/multi-select'
  34. import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
  35. import { INDEX_TYPES } from './Indexes.constants'
  36. import CodeEditor from '@/components/ui/CodeEditor/CodeEditor'
  37. import { DocsButton } from '@/components/ui/DocsButton'
  38. import { useDatabaseIndexCreateMutation } from '@/data/database-indexes/index-create-mutation'
  39. import { useSchemasQuery } from '@/data/database/schemas-query'
  40. import { useTableColumnsQuery } from '@/data/database/table-columns-query'
  41. import { useEntityTypesQuery } from '@/data/entity-types/entity-types-infinite-query'
  42. import { useIsOrioleDb, useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  43. import { DOCS_URL } from '@/lib/constants'
  44. interface CreateIndexSidePanelProps {
  45. visible: boolean
  46. onClose: () => void
  47. }
  48. export const CreateIndexSidePanel = ({ visible, onClose }: CreateIndexSidePanelProps) => {
  49. const { data: project } = useSelectedProjectQuery()
  50. const isOrioleDb = useIsOrioleDb()
  51. const [selectedSchema, setSelectedSchema] = useState('public')
  52. const [selectedEntity, setSelectedEntity] = useState<string | undefined>(undefined)
  53. const [selectedColumns, setSelectedColumns] = useState<string[]>([])
  54. const [selectedIndexType, setSelectedIndexType] = useState<string>(INDEX_TYPES[0].value)
  55. const [schemaDropdownOpen, setSchemaDropdownOpen] = useState(false)
  56. const [tableDropdownOpen, setTableDropdownOpen] = useState(false)
  57. const [schemaSearchTerm, setSchemaSearchTerm] = useState('')
  58. const [searchTerm, setSearchTerm] = useState('')
  59. const { data: schemas } = useSchemasQuery({
  60. projectRef: project?.ref,
  61. connectionString: project?.connectionString,
  62. })
  63. const { data: entities, isPending: isLoadingEntities } = useEntityTypesQuery({
  64. schemas: [selectedSchema],
  65. sort: 'alphabetical',
  66. search: searchTerm,
  67. projectRef: project?.ref,
  68. connectionString: project?.connectionString,
  69. })
  70. const {
  71. data: tableColumns,
  72. isPending: isLoadingTableColumns,
  73. isSuccess: isSuccessTableColumns,
  74. } = useTableColumnsQuery({
  75. schema: selectedSchema,
  76. table: selectedEntity,
  77. projectRef: project?.ref,
  78. connectionString: project?.connectionString,
  79. })
  80. const { mutate: createIndex, isPending: isExecuting } = useDatabaseIndexCreateMutation({
  81. onSuccess: () => {
  82. onClose()
  83. toast.success(`Successfully created index`)
  84. },
  85. })
  86. const entityTypes = useMemo(
  87. () => entities?.pages.flatMap((page) => page.data.entities) || [],
  88. [entities?.pages]
  89. )
  90. function handleSearchChange(value: string) {
  91. setSearchTerm(value)
  92. }
  93. function handleSchemaSelect(schema: string) {
  94. setSelectedSchema(schema)
  95. setSearchTerm('')
  96. setSchemaSearchTerm('')
  97. setSchemaDropdownOpen(false)
  98. }
  99. const columns = tableColumns?.[0]?.columns ?? []
  100. const columnOptions = columns
  101. .filter((column): column is NonNullable<typeof column> => column !== null)
  102. .map((column) => ({
  103. id: column.attname,
  104. value: column.attname,
  105. name: column.attname,
  106. disabled: false,
  107. }))
  108. const generatedSQL = `
  109. CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexType} (${selectedColumns
  110. .map((column) => `"${column}"`)
  111. .join(', ')});
  112. `.trim()
  113. const onSaveIndex = () => {
  114. if (!project) return console.error('Project is required')
  115. if (!selectedEntity) return console.error('Entity is required')
  116. createIndex({
  117. projectRef: project.ref,
  118. connectionString: project.connectionString,
  119. payload: {
  120. schema: selectedSchema,
  121. entity: selectedEntity,
  122. type: selectedIndexType,
  123. columns: selectedColumns,
  124. },
  125. })
  126. }
  127. useEffect(() => {
  128. if (visible) {
  129. setSelectedSchema('public')
  130. setSelectedEntity('')
  131. setSelectedColumns([])
  132. setSelectedIndexType(INDEX_TYPES[0].value)
  133. setSchemaSearchTerm('')
  134. setSearchTerm('')
  135. }
  136. }, [visible])
  137. useEffect(() => {
  138. setSelectedEntity('')
  139. setSelectedColumns([])
  140. setSelectedIndexType(INDEX_TYPES[0].value)
  141. setSearchTerm('')
  142. }, [selectedSchema])
  143. useEffect(() => {
  144. setSelectedColumns([])
  145. setSelectedIndexType(INDEX_TYPES[0].value)
  146. }, [selectedEntity])
  147. useEffect(() => {
  148. if (!schemaDropdownOpen) setSchemaSearchTerm('')
  149. }, [schemaDropdownOpen])
  150. const isSelectEntityDisabled = entityTypes.length === 0 && searchTerm.trim().length === 0
  151. return (
  152. <SidePanel
  153. size="large"
  154. header="Create new index"
  155. visible={visible}
  156. onCancel={onClose}
  157. onConfirm={() => onSaveIndex()}
  158. loading={isExecuting}
  159. confirmText="Create index"
  160. >
  161. <div className="py-6 space-y-6">
  162. <SidePanel.Content className="space-y-6">
  163. <FormItemLayout label="Select a schema" name="select-schema" isReactForm={false}>
  164. <Popover modal={false} open={schemaDropdownOpen} onOpenChange={setSchemaDropdownOpen}>
  165. <PopoverTrigger asChild>
  166. <Button
  167. type="default"
  168. size={'medium'}
  169. className={`w-full [&>span]:w-full text-left`}
  170. iconRight={
  171. <ChevronsUpDown className="text-foreground-muted" strokeWidth={2} size={14} />
  172. }
  173. >
  174. {selectedSchema !== undefined && selectedSchema !== ''
  175. ? selectedSchema
  176. : 'Choose a schema'}
  177. </Button>
  178. </PopoverTrigger>
  179. <PopoverContent className="p-0" side="bottom" align="start" sameWidthAsTrigger>
  180. <Command>
  181. <CommandInput
  182. placeholder="Find schema..."
  183. value={schemaSearchTerm}
  184. onValueChange={setSchemaSearchTerm}
  185. />
  186. <CommandList
  187. className={cn((schemas ?? []).length > 7 && 'max-h-[210px]! overflow-y-auto')}
  188. onWheel={(event) => event.stopPropagation()}
  189. >
  190. <CommandEmpty>No schemas found</CommandEmpty>
  191. <CommandGroup>
  192. {(schemas ?? []).map((schema) => (
  193. <CommandItem
  194. key={schema.name}
  195. className="cursor-pointer flex items-center justify-between space-x-2 w-full"
  196. onSelect={() => {
  197. handleSchemaSelect(schema.name)
  198. }}
  199. onClick={() => {
  200. handleSchemaSelect(schema.name)
  201. }}
  202. >
  203. <span>{schema.name}</span>
  204. {selectedSchema === schema.name && (
  205. <Check className="text-brand" strokeWidth={2} size={16} />
  206. )}
  207. </CommandItem>
  208. ))}
  209. </CommandGroup>
  210. </CommandList>
  211. </Command>
  212. </PopoverContent>
  213. </Popover>
  214. </FormItemLayout>
  215. <FormItemLayout
  216. label="Select a table"
  217. name="select-table"
  218. description={
  219. isSelectEntityDisabled &&
  220. !isLoadingEntities &&
  221. 'Create a table in this schema via the Table or SQL editor first'
  222. }
  223. isReactForm={false}
  224. >
  225. <Popover modal={false} open={tableDropdownOpen} onOpenChange={setTableDropdownOpen}>
  226. <PopoverTrigger asChild disabled={isSelectEntityDisabled || isLoadingEntities}>
  227. <Button
  228. type="default"
  229. size="medium"
  230. className={cn(
  231. 'w-full [&>span]:w-full text-left',
  232. selectedEntity === '' && 'text-foreground-lighter'
  233. )}
  234. iconRight={
  235. <ChevronsUpDown className="text-foreground-muted" strokeWidth={2} size={14} />
  236. }
  237. >
  238. {selectedEntity !== undefined && selectedEntity !== ''
  239. ? selectedEntity
  240. : isSelectEntityDisabled
  241. ? 'No tables available in schema'
  242. : 'Choose a table'}
  243. </Button>
  244. </PopoverTrigger>
  245. <PopoverContent className="p-0" side="bottom" align="start" sameWidthAsTrigger>
  246. {/* [Terry] shouldFilter context:
  247. https://github.com/pacocoursey/cmdk/issues/267#issuecomment-2252717107 */}
  248. <Command shouldFilter={false}>
  249. <CommandInput
  250. placeholder="Find table..."
  251. value={searchTerm}
  252. onValueChange={handleSearchChange}
  253. />
  254. <CommandList
  255. className={cn(entityTypes.length > 7 && 'max-h-[210px]! overflow-y-auto')}
  256. onWheel={(event) => event.stopPropagation()}
  257. >
  258. <CommandEmpty>
  259. {isLoadingEntities ? (
  260. <div className="flex items-center gap-2 text-center justify-center">
  261. <Loader2 size={12} className="animate-spin" />
  262. Loading...
  263. </div>
  264. ) : (
  265. 'No tables found'
  266. )}
  267. </CommandEmpty>
  268. <CommandGroup>
  269. {entityTypes.map((entity) => (
  270. <CommandItem
  271. key={entity.name}
  272. className="cursor-pointer flex items-center justify-between space-x-2 w-full"
  273. onSelect={() => {
  274. setSelectedEntity(entity.name)
  275. setTableDropdownOpen(false)
  276. }}
  277. onClick={() => {
  278. setSelectedEntity(entity.name)
  279. setTableDropdownOpen(false)
  280. }}
  281. >
  282. <span>{entity.name}</span>
  283. {selectedEntity === entity.name && (
  284. <Check className="text-brand" strokeWidth={2} size={16} />
  285. )}
  286. </CommandItem>
  287. ))}
  288. </CommandGroup>
  289. </CommandList>
  290. </Command>
  291. </PopoverContent>
  292. </Popover>
  293. </FormItemLayout>
  294. {selectedEntity && (
  295. <FormItemLayout id="columns" label="Select up to 32 columns" isReactForm={false}>
  296. {isLoadingTableColumns && <ShimmeringLoader className="py-4" />}
  297. {isSuccessTableColumns && (
  298. <MultiSelector values={selectedColumns} onValuesChange={setSelectedColumns}>
  299. <MultiSelectorTrigger
  300. id="columns"
  301. mode="inline-combobox"
  302. label={
  303. selectedColumns.length === 0
  304. ? 'Choose which columns to create an index on'
  305. : 'Search for a column'
  306. }
  307. deletableBadge
  308. badgeLimit="wrap"
  309. showIcon={false}
  310. />
  311. <MultiSelectorContent>
  312. <MultiSelectorList>
  313. {columnOptions.map((option) => (
  314. <MultiSelectorItem
  315. key={option.id}
  316. value={option.value}
  317. disabled={option.disabled}
  318. >
  319. {option.name}
  320. </MultiSelectorItem>
  321. ))}
  322. </MultiSelectorList>
  323. </MultiSelectorContent>
  324. </MultiSelector>
  325. )}
  326. </FormItemLayout>
  327. )}
  328. </SidePanel.Content>
  329. {selectedColumns.length > 0 && (
  330. <>
  331. <SidePanel.Separator />
  332. <SidePanel.Content className="space-y-6">
  333. <FormItemLayout
  334. label="Select an index type"
  335. name="selected-index-type"
  336. isReactForm={false}
  337. >
  338. <Select
  339. disabled={isOrioleDb}
  340. value={selectedIndexType}
  341. onValueChange={setSelectedIndexType}
  342. name="selected-index-type"
  343. >
  344. <SelectTrigger size={'small'}>
  345. <SelectValue className="font-mono">{selectedIndexType}</SelectValue>
  346. </SelectTrigger>
  347. <SelectContent>
  348. {INDEX_TYPES.map((index, i) => (
  349. <Fragment key={index.name}>
  350. <SelectItem value={index.value}>
  351. <div className="flex flex-col gap-0.5">
  352. <span>{index.name}</span>
  353. {index.description.split('\n').map((x, idx) => (
  354. <span
  355. className="text-foreground-lighter group-focus:text-foreground-light group-data-checked:text-foreground-light"
  356. key={`${index.value}-description-${idx}`}
  357. >
  358. {x}
  359. </span>
  360. ))}
  361. </div>
  362. </SelectItem>
  363. {i < INDEX_TYPES.length - 1 && <SelectSeparator />}
  364. </Fragment>
  365. ))}
  366. </SelectContent>
  367. </Select>
  368. </FormItemLayout>
  369. {isOrioleDb && (
  370. <Admonition
  371. type="default"
  372. className="mt-2!"
  373. title="OrioleDB currently only supports the B-tree index type"
  374. description="More index types may be supported when OrioleDB is no longer in preview"
  375. >
  376. {/* [Joshen Oriole] Hook up proper docs URL */}
  377. <DocsButton className="mt-2" abbrev={false} href={`${DOCS_URL}`} />
  378. </Admonition>
  379. )}
  380. </SidePanel.Content>
  381. <SidePanel.Separator />
  382. <SidePanel.Content>
  383. <div className="flex items-center justify-between">
  384. <p className="text-sm">Preview of SQL statement</p>
  385. <Button asChild type="default">
  386. <Link
  387. href={
  388. project !== undefined
  389. ? `/project/${project.ref}/sql/new?content=${generatedSQL}`
  390. : '/'
  391. }
  392. >
  393. Open in SQL Editor
  394. </Link>
  395. </Button>
  396. </div>
  397. </SidePanel.Content>
  398. <div className="h-[200px] mt-2!">
  399. <div className="relative h-full">
  400. <CodeEditor
  401. isReadOnly
  402. autofocus={false}
  403. id={`${selectedSchema}-${selectedEntity}-${selectedColumns.join(
  404. ','
  405. )}-${selectedIndexType}`}
  406. language="pgsql"
  407. defaultValue={generatedSQL}
  408. />
  409. </div>
  410. </div>
  411. </>
  412. )}
  413. </div>
  414. </SidePanel>
  415. )
  416. }