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