Extensions.tsx 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. import { PermissionAction } from '@supabase/shared-types/out/constants'
  2. import { useParams } from 'common'
  3. import { isNull, partition } from 'lodash'
  4. import { AlertCircle, Search } from 'lucide-react'
  5. import { useEffect, useRef, useState } from 'react'
  6. import {
  7. Card,
  8. InputGroup,
  9. InputGroupAddon,
  10. InputGroupInput,
  11. ShadowScrollArea,
  12. Table,
  13. TableBody,
  14. TableCell,
  15. TableHead,
  16. TableHeader,
  17. TableRow,
  18. } from 'ui'
  19. import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
  20. import { ExtensionRow } from './ExtensionRow'
  21. import { HIDDEN_EXTENSIONS, SEARCH_TERMS } from './Extensions.constants'
  22. import InformationBox from '@/components/ui/InformationBox'
  23. import { NoSearchResults } from '@/components/ui/NoSearchResults'
  24. import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query'
  25. import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
  26. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  27. import { onSearchInputEscape } from '@/lib/keyboard'
  28. import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
  29. import { useShortcut } from '@/state/shortcuts/useShortcut'
  30. export const Extensions = () => {
  31. const { filter } = useParams()
  32. const { data: project } = useSelectedProjectQuery()
  33. const [filterString, setFilterString] = useState<string>('')
  34. const searchInputRef = useRef<HTMLInputElement>(null)
  35. useShortcut(
  36. SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
  37. () => {
  38. searchInputRef.current?.focus()
  39. searchInputRef.current?.select()
  40. },
  41. { label: 'Search extensions' }
  42. )
  43. useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, () => {
  44. setFilterString('')
  45. })
  46. const { data = [], isPending: isLoading } = useDatabaseExtensionsQuery({
  47. projectRef: project?.ref,
  48. connectionString: project?.connectionString,
  49. })
  50. const visibleExtensions = data.filter((ext) => !HIDDEN_EXTENSIONS.includes(ext.name))
  51. const extensions =
  52. filterString.length === 0
  53. ? visibleExtensions
  54. : visibleExtensions.filter((ext) => {
  55. const nameMatchesSearch = ext.name.toLowerCase().includes(filterString.toLowerCase())
  56. const searchTermsMatchesSearch = (SEARCH_TERMS[ext.name] || []).some((x) =>
  57. x.includes(filterString.toLowerCase())
  58. )
  59. return nameMatchesSearch || searchTermsMatchesSearch
  60. })
  61. const [enabledExtensions, disabledExtensions] = partition(
  62. extensions,
  63. (ext) => !isNull(ext.installed_version)
  64. )
  65. const { can: canUpdateExtensions, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions(
  66. PermissionAction.TENANT_SQL_ADMIN_WRITE,
  67. 'extensions'
  68. )
  69. useEffect(() => {
  70. if (filter !== undefined) setFilterString(filter as string)
  71. }, [filter])
  72. return (
  73. <>
  74. <div className="mb-4">
  75. <InputGroup className="w-52">
  76. <InputGroupInput
  77. ref={searchInputRef}
  78. size="tiny"
  79. placeholder="Search for an extension"
  80. value={filterString}
  81. onChange={(e) => setFilterString(e.target.value)}
  82. onKeyDown={onSearchInputEscape(filterString, setFilterString)}
  83. />
  84. <InputGroupAddon>
  85. <Search />
  86. </InputGroupAddon>
  87. </InputGroup>
  88. </div>
  89. {isPermissionsLoaded && !canUpdateExtensions && (
  90. <InformationBox
  91. icon={<AlertCircle className="text-foreground-light" size={18} strokeWidth={2} />}
  92. title="You need additional permissions to update database extensions"
  93. />
  94. )}
  95. {isLoading ? (
  96. <GenericSkeletonLoader />
  97. ) : (
  98. <Card>
  99. <ShadowScrollArea stickyLastColumn>
  100. <Table>
  101. <TableHeader>
  102. <TableRow>
  103. <TableHead key="name">Name</TableHead>
  104. <TableHead key="version" className="w-28">
  105. Version
  106. </TableHead>
  107. <TableHead key="schema">Schema</TableHead>
  108. <TableHead key="description" className="min-w-80">
  109. Description
  110. </TableHead>
  111. <TableHead key="used-by">Used by</TableHead>
  112. <TableHead key="links">Links</TableHead>
  113. {/*
  114. [Joshen] All these classes are just to make the last column sticky
  115. I reckon we can pull these out into the Table component where we can declare
  116. sticky columns via props, but we can do that if we start to have more tables
  117. in the dashboard with sticky columns
  118. */}
  119. <TableHead key="enabled" className="px-0">
  120. <div className="bg-200! px-4 w-full h-full flex items-center border-l">
  121. Enabled
  122. </div>
  123. </TableHead>
  124. </TableRow>
  125. </TableHeader>
  126. <TableBody>
  127. {[...enabledExtensions, ...disabledExtensions].map((extension) => (
  128. <ExtensionRow key={extension.name} extension={extension} />
  129. ))}
  130. {extensions.length === 0 && (
  131. <TableRow>
  132. <TableCell colSpan={7}>
  133. <NoSearchResults
  134. className="border-none p-0! bg-transparent"
  135. searchString={filterString}
  136. onResetFilter={() => setFilterString('')}
  137. />
  138. </TableCell>
  139. </TableRow>
  140. )}
  141. </TableBody>
  142. </Table>
  143. </ShadowScrollArea>
  144. </Card>
  145. )}
  146. </>
  147. )
  148. }