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