Indexes.tsx353 lines · main
1import { useParams } from 'common'
2import { sortBy } from 'lodash'
3import { AlertCircle, Search, Trash } from 'lucide-react'
4import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs'
5import { useEffect, useRef, useState } from 'react'
6import { toast } from 'sonner'
7import {
8 Button,
9 Card,
10 SidePanel,
11 Table,
12 TableBody,
13 TableCell,
14 TableHead,
15 TableHeader,
16 TableRow,
17} from 'ui'
18import { Input } from 'ui-patterns/DataInputs/Input'
19import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal'
20import { GenericSkeletonLoader, ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
21
22import { ProtectedSchemaWarning } from '../ProtectedSchemaWarning'
23import { CreateIndexSidePanel } from './CreateIndexSidePanel'
24import AlertError from '@/components/ui/AlertError'
25import CodeEditor from '@/components/ui/CodeEditor/CodeEditor'
26import SchemaSelector from '@/components/ui/SchemaSelector'
27import { Shortcut } from '@/components/ui/Shortcut'
28import { useDatabaseIndexDeleteMutation } from '@/data/database-indexes/index-delete-mutation'
29import { useIndexesQuery, type DatabaseIndex } from '@/data/database-indexes/indexes-query'
30import { useSchemasQuery } from '@/data/database/schemas-query'
31import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
32import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
33import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
34import { onSearchInputEscape } from '@/lib/keyboard'
35import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
36import { useShortcut } from '@/state/shortcuts/useShortcut'
37
38export const Indexes = () => {
39 const { data: project } = useSelectedProjectQuery()
40 const { schema: urlSchema, table } = useParams()
41
42 const [search, setSearch] = useQueryState('search', parseAsString.withDefault(''))
43 const [schemaSelectorOpen, setSchemaSelectorOpen] = useState(false)
44 const searchInputRef = useRef<HTMLInputElement>(null)
45 const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
46
47 const {
48 data: allIndexes,
49 error: indexesError,
50 isPending: isLoadingIndexes,
51 isSuccess: isSuccessIndexes,
52 isError: isErrorIndexes,
53 } = useIndexesQuery({
54 schema: selectedSchema,
55 projectRef: project?.ref,
56 connectionString: project?.connectionString,
57 })
58
59 const [showCreateIndex, setShowCreateIndex] = useQueryState(
60 'new',
61 parseAsBoolean.withDefault(false)
62 )
63
64 const [editIndexId, setEditIndexId] = useQueryState('edit', parseAsString)
65 const selectedIndex = allIndexes?.find((idx) => idx.name === editIndexId)
66
67 const [deleteIndexId, setDeleteIndexId] = useQueryState('delete', parseAsString)
68 const selectedIndexToDelete = allIndexes?.find((idx) => idx.name === deleteIndexId)
69
70 const {
71 data: schemas,
72 isPending: isLoadingSchemas,
73 isSuccess: isSuccessSchemas,
74 isError: isErrorSchemas,
75 } = useSchemasQuery({
76 projectRef: project?.ref,
77 connectionString: project?.connectionString,
78 })
79
80 const {
81 mutate: deleteIndex,
82 isPending: isExecuting,
83 isSuccess: isSuccessDelete,
84 } = useDatabaseIndexDeleteMutation({
85 onSuccess: async () => {
86 setDeleteIndexId(null)
87 toast.success('Successfully deleted index')
88 },
89 })
90
91 const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedSchema })
92
93 useShortcut(
94 SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
95 () => {
96 searchInputRef.current?.focus()
97 searchInputRef.current?.select()
98 },
99 { label: 'Search indexes' }
100 )
101
102 useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, () => {
103 setSearch('')
104 })
105
106 const sortedIndexes = sortBy(allIndexes ?? [], (index) => index.name.toLocaleLowerCase())
107 const indexes =
108 search.length > 0
109 ? sortedIndexes.filter((index) => index.name.includes(search) || index.table.includes(search))
110 : sortedIndexes
111
112 const onConfirmDeleteIndex = (index: DatabaseIndex) => {
113 if (!project) return console.error('Project is required')
114 deleteIndex({
115 projectRef: project.ref,
116 connectionString: project.connectionString,
117 name: index.name,
118 schema: selectedSchema,
119 })
120 }
121
122 useEffect(() => {
123 if (urlSchema !== undefined) {
124 const schema = schemas?.find((s) => s.name === urlSchema)
125 if (schema !== undefined) setSelectedSchema(schema.name)
126 }
127 }, [urlSchema, isSuccessSchemas])
128
129 useEffect(() => {
130 if (table !== undefined) setSearch(table)
131 }, [table])
132
133 useEffect(() => {
134 if (isSuccessIndexes && !!editIndexId && !selectedIndex) {
135 toast('Index not found')
136 setEditIndexId(null)
137 }
138 }, [isSuccessIndexes, editIndexId, selectedIndex, setEditIndexId])
139
140 useEffect(() => {
141 if (isSuccessIndexes && !!deleteIndexId && !selectedIndexToDelete && !isSuccessDelete) {
142 toast('Index not found')
143 setDeleteIndexId(null)
144 }
145 }, [isSuccessIndexes, deleteIndexId, selectedIndexToDelete, isSuccessDelete, setDeleteIndexId])
146
147 return (
148 <>
149 <div className="pb-8">
150 <div className="flex flex-col gap-y-4">
151 <div className="flex items-center gap-2 flex-wrap">
152 {isLoadingSchemas && <ShimmeringLoader className="w-[260px]" />}
153 {isErrorSchemas && (
154 <div className="w-[260px] text-foreground-light text-sm border px-3 py-1.5 rounded-sm flex items-center space-x-2">
155 <AlertCircle strokeWidth={2} size={16} />
156 <p>Failed to load schemas</p>
157 </div>
158 )}
159 {isSuccessSchemas && (
160 <Shortcut
161 id={SHORTCUT_IDS.LIST_PAGE_FOCUS_SCHEMA}
162 onTrigger={() => setSchemaSelectorOpen(true)}
163 side="bottom"
164 tooltipOpen={schemaSelectorOpen ? false : undefined}
165 >
166 <SchemaSelector
167 className="w-full lg:w-[180px]"
168 size="tiny"
169 showError={false}
170 selectedSchemaName={selectedSchema}
171 onSelectSchema={setSelectedSchema}
172 open={schemaSelectorOpen}
173 onOpenChange={setSchemaSelectorOpen}
174 />
175 </Shortcut>
176 )}
177 <Input
178 ref={searchInputRef}
179 size="tiny"
180 value={search}
181 className="w-full lg:w-52"
182 onChange={(e) => setSearch(e.target.value)}
183 onKeyDown={onSearchInputEscape(search, setSearch)}
184 placeholder="Search for an index"
185 icon={<Search />}
186 />
187
188 {!isSchemaLocked && (
189 <Shortcut
190 id={SHORTCUT_IDS.LIST_PAGE_NEW_ITEM}
191 label="Create new index"
192 onTrigger={() => setShowCreateIndex(true)}
193 options={{ enabled: isSuccessSchemas }}
194 side="bottom"
195 >
196 <Button
197 className="ml-auto grow lg:grow-0"
198 type="primary"
199 onClick={() => setShowCreateIndex(true)}
200 disabled={!isSuccessSchemas}
201 >
202 Create index
203 </Button>
204 </Shortcut>
205 )}
206 </div>
207
208 {isSchemaLocked && <ProtectedSchemaWarning schema={selectedSchema} entity="indexes" />}
209
210 {isLoadingIndexes && <GenericSkeletonLoader />}
211
212 {isErrorIndexes && (
213 <AlertError error={indexesError as any} subject="Failed to retrieve database indexes" />
214 )}
215
216 {isSuccessIndexes && (
217 <div className="w-full overflow-hidden">
218 <Card>
219 <Table>
220 <TableHeader>
221 <TableRow>
222 <TableHead key="table">Table</TableHead>
223 <TableHead key="columns">Columns</TableHead>
224 <TableHead key="name">Name</TableHead>
225 <TableHead key="buttons" />
226 </TableRow>
227 </TableHeader>
228 <TableBody>
229 {indexes.length === 0 && search.length === 0 && (
230 <TableRow>
231 <TableCell colSpan={4}>
232 <p className="text-sm text-foreground">No indexes created yet</p>
233 <p className="text-sm text-foreground-light">
234 There are no indexes found in the schema "{selectedSchema}"
235 </p>
236 </TableCell>
237 </TableRow>
238 )}
239 {indexes.length === 0 && search.length > 0 && (
240 <TableRow>
241 <TableCell colSpan={4}>
242 <p className="text-sm text-foreground">No results found</p>
243 <p className="text-sm text-foreground-light">
244 Your search for "{search}" did not return any results
245 </p>
246 </TableCell>
247 </TableRow>
248 )}
249 {indexes.length > 0 &&
250 indexes.map((index) => (
251 <TableRow key={index.name}>
252 <TableCell>
253 <p title={index.table}>{index.table}</p>
254 </TableCell>
255 <TableCell>
256 <p title={index.columns}>{index.columns}</p>
257 </TableCell>
258 <TableCell>
259 <p title={index.name}>{index.name}</p>
260 </TableCell>
261 <TableCell>
262 <div className="flex justify-end items-center space-x-2">
263 <Button type="default" onClick={() => setEditIndexId(index.name)}>
264 View definition
265 </Button>
266 {!isSchemaLocked && (
267 <Button
268 aria-label="Delete index"
269 type="text"
270 className="px-1"
271 icon={<Trash />}
272 onClick={() => setDeleteIndexId(index.name)}
273 />
274 )}
275 </div>
276 </TableCell>
277 </TableRow>
278 ))}
279 </TableBody>
280 </Table>
281 </Card>
282 </div>
283 )}
284 </div>
285 </div>
286
287 <SidePanel
288 size="xlarge"
289 visible={!!selectedIndex}
290 header={
291 <>
292 <span>Index:</span>
293 <code className="text-sm ml-2">{selectedIndex?.name}</code>
294 </>
295 }
296 onCancel={() => setEditIndexId(null)}
297 >
298 <div className="h-full">
299 <div className="relative h-full">
300 <CodeEditor
301 isReadOnly
302 id={selectedIndex?.name ?? ''}
303 language="pgsql"
304 defaultValue={selectedIndex?.definition ?? ''}
305 />
306 </div>
307 </div>
308 </SidePanel>
309
310 <CreateIndexSidePanel visible={showCreateIndex} onClose={() => setShowCreateIndex(false)} />
311
312 <ConfirmationModal
313 variant="warning"
314 size="medium"
315 loading={isExecuting}
316 visible={!!selectedIndexToDelete}
317 title={
318 <>
319 Confirm to delete index{' '}
320 <code className="text-code-inline">{selectedIndexToDelete?.name}</code>
321 </>
322 }
323 confirmLabel="Confirm delete"
324 confirmLabelLoading="Deleting..."
325 onConfirm={() =>
326 selectedIndexToDelete !== undefined ? onConfirmDeleteIndex(selectedIndexToDelete) : {}
327 }
328 onCancel={() => setDeleteIndexId(null)}
329 alert={{
330 title: 'This action cannot be undone',
331 description:
332 'Deleting an index that is still in use will cause queries to slow down, and in some cases causing significant performance issues.',
333 }}
334 className="pt-0"
335 >
336 <ul className="mt-4 space-y-5">
337 <li className="flex gap-3">
338 <div>
339 <strong className="text-sm">Before deleting this index, consider:</strong>
340 <ul className="space-y-2 mt-2 text-sm text-foreground-light">
341 <li className="list-disc ml-6">This index is no longer in use</li>
342 <li className="list-disc ml-6">
343 The table which the index is on is not currently in use, as dropping an index
344 requires a short exclusive access lock on the table.
345 </li>
346 </ul>
347 </div>
348 </li>
349 </ul>
350 </ConfirmationModal>
351 </>
352 )
353}