EnableExtensionModal.tsx282 lines · main
| 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 | |
| 30 | import { extensionsWithRecommendedSchemas } from './Extensions.constants' |
| 31 | import { DocsButton } from '@/components/ui/DocsButton' |
| 32 | import { useDatabaseExtensionEnableMutation } from '@/data/database-extensions/database-extension-enable-mutation' |
| 33 | import { type DatabaseExtension } from '@/data/database-extensions/database-extensions-query' |
| 34 | import { useSchemasQuery } from '@/data/database/schemas-query' |
| 35 | import { useIsOrioleDb, useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 36 | import { useProtectedSchemas } from '@/hooks/useProtectedSchemas' |
| 37 | import { DOCS_URL } from '@/lib/constants' |
| 38 | |
| 39 | const orioleExtCallOuts = ['vector', 'postgis'] |
| 40 | |
| 41 | const 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 | |
| 51 | interface EnableExtensionModalProps { |
| 52 | visible: boolean |
| 53 | extension: DatabaseExtension |
| 54 | onCancel: () => void |
| 55 | } |
| 56 | |
| 57 | export 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 | } |