EnableExtensionModal.tsx 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. import { zodResolver } from '@hookform/resolvers/zod'
  2. import { useForm } from 'react-hook-form'
  3. import { toast } from 'sonner'
  4. import {
  5. Badge,
  6. Button,
  7. Dialog,
  8. DialogContent,
  9. DialogFooter,
  10. DialogHeader,
  11. DialogSection,
  12. DialogSectionSeparator,
  13. DialogTitle,
  14. Form,
  15. FormControl,
  16. FormField,
  17. Input,
  18. Select,
  19. SelectContent,
  20. SelectItem,
  21. SelectSeparator,
  22. SelectTrigger,
  23. SelectValue,
  24. } from 'ui'
  25. import { Admonition } from 'ui-patterns'
  26. import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
  27. import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
  28. import * as z from 'zod'
  29. import { extensionsWithRecommendedSchemas } from './Extensions.constants'
  30. import { DocsButton } from '@/components/ui/DocsButton'
  31. import { useDatabaseExtensionEnableMutation } from '@/data/database-extensions/database-extension-enable-mutation'
  32. import { type DatabaseExtension } from '@/data/database-extensions/database-extensions-query'
  33. import { useSchemasQuery } from '@/data/database/schemas-query'
  34. import { useIsOrioleDb, useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  35. import { useProtectedSchemas } from '@/hooks/useProtectedSchemas'
  36. import { DOCS_URL } from '@/lib/constants'
  37. const orioleExtCallOuts = ['vector', 'postgis']
  38. const FormSchema = z.object({ name: z.string(), schema: z.string() }).superRefine((val, ctx) => {
  39. if (val.schema === 'custom' && val.name.length === 0) {
  40. ctx.addIssue({
  41. code: z.ZodIssueCode.custom,
  42. path: ['name'],
  43. message: 'Please provide a name for the schema',
  44. })
  45. }
  46. })
  47. interface EnableExtensionModalProps {
  48. visible: boolean
  49. extension: DatabaseExtension
  50. onCancel: () => void
  51. }
  52. export const EnableExtensionModal = ({
  53. visible,
  54. extension,
  55. onCancel,
  56. }: EnableExtensionModalProps) => {
  57. const isOrioleDb = useIsOrioleDb()
  58. const { data: project } = useSelectedProjectQuery()
  59. const { data: protectedSchemas } = useProtectedSchemas({ excludeSchemas: ['extensions'] })
  60. const recommendedSchema = extensionsWithRecommendedSchemas[extension.name]
  61. const { data: schemas = [], isPending: isLoading } = useSchemasQuery(
  62. {
  63. projectRef: project?.ref,
  64. connectionString: project?.connectionString,
  65. },
  66. { enabled: visible }
  67. )
  68. const availableSchemas = schemas.filter(
  69. (schema) =>
  70. schema.name === recommendedSchema ||
  71. !protectedSchemas.some((protectedSchema) => protectedSchema.name === schema.name)
  72. )
  73. // [Joshen] Hard-coding pg_cron here as this is enforced on our end (Not via pg_available_extension_versions)
  74. const defaultSchema =
  75. extension.name === 'pg_cron' ? 'pg_catalog' : extension.default_version_schema
  76. const { mutate: enableExtension, isPending: isEnabling } = useDatabaseExtensionEnableMutation({
  77. onSuccess: () => {
  78. toast.success(`Extension "${extension.name}" is now enabled`)
  79. onCancel()
  80. },
  81. onError: (error) => {
  82. toast.error(`Failed to enable ${extension.name}: ${error.message}`)
  83. },
  84. })
  85. const defaultValues = { name: extension.name, schema: recommendedSchema ?? 'extensions' }
  86. const form = useForm<z.infer<typeof FormSchema>>({
  87. mode: 'onBlur',
  88. reValidateMode: 'onBlur',
  89. resolver: zodResolver(FormSchema as any),
  90. defaultValues,
  91. })
  92. const { schema } = form.watch()
  93. const onSubmit = async (values: z.infer<typeof FormSchema>) => {
  94. if (project === undefined) return console.error('Project is required')
  95. const schema =
  96. defaultSchema !== undefined && defaultSchema !== null
  97. ? defaultSchema
  98. : values.schema === 'custom'
  99. ? values.name
  100. : values.schema
  101. enableExtension({
  102. projectRef: project.ref,
  103. connectionString: project?.connectionString,
  104. schema,
  105. name: extension.name,
  106. version: extension.default_version,
  107. cascade: true,
  108. createSchema: !schema.startsWith('pg_'),
  109. })
  110. }
  111. return (
  112. <Dialog
  113. open={visible}
  114. onOpenChange={(open: boolean) => {
  115. if (!open) onCancel()
  116. }}
  117. >
  118. <DialogContent size="small" aria-describedby={undefined}>
  119. <DialogHeader>
  120. <DialogTitle>Enable {extension.name}</DialogTitle>
  121. </DialogHeader>
  122. <DialogSectionSeparator />
  123. {isOrioleDb && orioleExtCallOuts.includes(extension.name) && (
  124. <Admonition
  125. type="default"
  126. title="Extension is limited by OrioleDB"
  127. className="border-x-0 border-t-0 rounded-none"
  128. >
  129. <span className="block">
  130. {extension.name} cannot be accelerated by indexes on tables that are using the
  131. OrioleDB access method
  132. </span>
  133. <DocsButton abbrev={false} className="mt-2" href={`${DOCS_URL}`} />
  134. </Admonition>
  135. )}
  136. {extension.name === 'pg_cron' && project?.cloud_provider === 'FLY' && (
  137. <Admonition
  138. type="warning"
  139. title="The pg_cron extension is not fully supported for Fly projects"
  140. className="border-x-0 border-t-0 rounded-none"
  141. >
  142. <p>
  143. You can still enable the extension, but pg_cron jobs may not run due to the behavior
  144. of Fly projects.
  145. </p>
  146. <DocsButton
  147. className="mt-2"
  148. href={`${DOCS_URL}/guides/platform/fly-postgres#limitations`}
  149. />
  150. </Admonition>
  151. )}
  152. <DialogSection>
  153. <Form {...form}>
  154. <form id="enable-extensions-form" onSubmit={form.handleSubmit(onSubmit)}>
  155. {isLoading ? (
  156. <div className="space-y-2">
  157. <ShimmeringLoader />
  158. <div className="w-3/4">
  159. <ShimmeringLoader />
  160. </div>
  161. </div>
  162. ) : !!defaultSchema ? (
  163. <div className="flex flex-col gap-y-2">
  164. <FormItemLayout
  165. isReactForm={false}
  166. label="Select a schema to enable the extension for"
  167. >
  168. <Input disabled value={defaultSchema} />
  169. </FormItemLayout>
  170. <p className="text-sm text-foreground-light">
  171. Extension must be installed in the "{defaultSchema}" schema.
  172. </p>
  173. </div>
  174. ) : (
  175. <div className="flex flex-col gap-y-2">
  176. <FormField
  177. key="schema"
  178. name="schema"
  179. control={form.control}
  180. render={({ field }) => (
  181. <FormItemLayout
  182. name="schema"
  183. label="Select a schema to enable the extension for"
  184. >
  185. <FormControl>
  186. <Select
  187. value={field.value}
  188. onValueChange={field.onChange}
  189. disabled={!!defaultSchema}
  190. >
  191. <SelectTrigger>
  192. <SelectValue placeholder="Select a schema" />
  193. </SelectTrigger>
  194. <SelectContent>
  195. <SelectItem value="custom">
  196. Create a new schema{' '}
  197. <code className="text-code-inline">{extension.name}</code>
  198. </SelectItem>
  199. <SelectSeparator />
  200. {availableSchemas.map((schema) => {
  201. return (
  202. <SelectItem key={schema.id} value={schema.name}>
  203. {schema.name}
  204. {schema.name === recommendedSchema ? (
  205. <Badge className="ml-2" variant="success">
  206. Recommended
  207. </Badge>
  208. ) : !defaultSchema && schema.name === 'extensions' ? (
  209. <Badge className="ml-2">Default</Badge>
  210. ) : null}
  211. </SelectItem>
  212. )
  213. })}
  214. </SelectContent>
  215. </Select>
  216. </FormControl>
  217. </FormItemLayout>
  218. )}
  219. />
  220. {!!recommendedSchema && (
  221. <p className="text-sm text-foreground-light">
  222. Use the "{recommendedSchema}" schema for full compatibility with related
  223. features.
  224. </p>
  225. )}
  226. {schema === 'custom' && (
  227. <FormField
  228. key="name"
  229. name="name"
  230. control={form.control}
  231. render={({ field }) => (
  232. <FormItemLayout name="name" label="Schema name">
  233. <FormControl>
  234. <Input {...field} />
  235. </FormControl>
  236. </FormItemLayout>
  237. )}
  238. />
  239. )}
  240. </div>
  241. )}
  242. </form>
  243. </Form>
  244. </DialogSection>
  245. <DialogFooter>
  246. <Button type="default" disabled={isEnabling} onClick={() => onCancel()}>
  247. Cancel
  248. </Button>
  249. <Button
  250. htmlType="submit"
  251. form="enable-extensions-form"
  252. loading={isEnabling}
  253. disabled={isLoading || isEnabling}
  254. >
  255. Enable extension
  256. </Button>
  257. </DialogFooter>
  258. </DialogContent>
  259. </Dialog>
  260. )
  261. }