| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283 |
- import { zodResolver } from '@hookform/resolvers/zod'
- import { useEffect, useMemo, useState } from 'react'
- import { SubmitHandler, useForm } from 'react-hook-form'
- import { toast } from 'sonner'
- import {
- Button,
- DialogSectionSeparator,
- Form,
- Sheet,
- SheetClose,
- SheetContent,
- SheetDescription,
- SheetFooter,
- SheetHeader,
- SheetTitle,
- SheetTrigger,
- } from 'ui'
- import * as z from 'zod'
- import { getExtensionDefaultSchema } from '../IntegrationOverviewTabV2.utils'
- import { AdvancedSettings } from './AdvancedSettings'
- import { InstallationOverview } from './InstallationOverview'
- import { InstallationSettings } from './InstallationSettings'
- import { type IntegrationDefinition } from '@/components/interfaces/Integrations/Landing/Integrations.constants'
- import { useDatabaseExtensionEnableMutation } from '@/data/database-extensions/database-extension-enable-mutation'
- import {
- DatabaseExtension,
- useDatabaseExtensionsQuery,
- } from '@/data/database-extensions/database-extensions-query'
- import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
- import { useTrack } from '@/lib/telemetry/track'
- import { ResponseError } from '@/types'
- export interface InstallIntegrationSheetProps {
- integration: IntegrationDefinition
- }
- export type ExtensionsSchema = { [key: string]: { schema: string; value: string | undefined } }
- const formId = 'installation-settings'
- /**
- * [Joshen] Trying to figure out what the ideal data structure is between local + remote integrations
- * So it might be a bit messy for now as we get more context and build out this UI
- *
- * If the integration provides its own SQL installation command, we'll use that
- * Otherwise if the integration provides its own SQL installation query, we'll use that through the query endpoint
- * Else if the integration only requires extensions, dashboard will generate the queries and fire through the query endpoint
- *
- */
- export const InstallIntegrationSheet = ({ integration }: InstallIntegrationSheetProps) => {
- const track = useTrack()
- const { data: project } = useSelectedProjectQuery()
- const [open, setOpen] = useState(false)
- const [isInstalling, setIsInstalling] = useState(false)
- const {
- icon,
- name,
- inputs = {},
- installationSql,
- installationCommand,
- checkInstallationStatus,
- requiredExtensions: requiredExtensionNames,
- } = integration
- const allowExtensionCustomSchema = !installationSql
- const involvesExtensions = requiredExtensionNames.length > 0
- const schema = useMemo(() => {
- let baseSchema = z.object({})
- Object.entries(inputs).forEach((entry) => {
- const [key, input] = entry
- baseSchema = baseSchema.extend({
- [key]: z.string().min(1, `Please provide a value for ${input.label}`),
- })
- })
- return baseSchema
- }, [inputs])
- const defaultValues = useMemo(() => {
- let values = {} as Record<string, string>
- Object.entries(inputs).forEach((entry) => {
- const [key] = entry
- values[key] = ''
- })
- return values
- }, [inputs])
- const form = useForm<Record<string, string>>({
- mode: 'onBlur',
- reValidateMode: 'onBlur',
- resolver: zodResolver(schema as any),
- defaultValues,
- })
- const { data: extensions = [], isSuccess: isSuccessExtensions } = useDatabaseExtensionsQuery(
- { projectRef: project?.ref, connectionString: project?.connectionString },
- { enabled: involvesExtensions }
- )
- const defaultExtensionsSchema = useMemo(
- () =>
- Object.fromEntries(
- requiredExtensionNames.map((extName) => {
- const ext = extensions.find((x) => x.name === extName)
- const defaultSchema = getExtensionDefaultSchema(ext)
- return [extName, { schema: defaultSchema ?? 'extensions', value: undefined }]
- })
- ),
- [requiredExtensionNames, extensions]
- )
- const [extensionsSchema, setExtensionsSchema] =
- useState<ExtensionsSchema>(defaultExtensionsSchema)
- const requiredExtensions = extensions.filter((ext) => requiredExtensionNames.includes(ext.name))
- const requiredExtensionsToBeInstalled = requiredExtensions.filter((ext) => !ext.installed_version)
- // [Joshen] Integration requires extensions that are not available to install on the current database image
- const hasMissingExtensions = requiredExtensionNames.length !== requiredExtensions.length
- const { mutateAsync: enableExtension } = useDatabaseExtensionEnableMutation({ onError: () => {} })
- /**
- * [Joshen] This is a bit messy again while we're figuring out requirements
- * If the integration has required extensions that are yet to be installed, we'll install those
- * AND if the integration has a provided installation command, we'll run that too
- */
- const onSubmit: SubmitHandler<Record<string, string>> = async (values) => {
- if (!project) return console.error('Project is required')
- setIsInstalling(true)
- const toastId = toast.loading(`Installing ${name}`)
- try {
- if (requiredExtensionsToBeInstalled.length > 0) {
- toast.loading(`Installing required database extensions`, { id: toastId })
- await installRequiredIntegrationExtensions(requiredExtensionsToBeInstalled)
- }
- if (installationCommand) {
- toast.loading(`Installing ${name}`, { id: toastId })
- await installationCommand({ ref: project.ref, track, ...values })
- }
- if (!!checkInstallationStatus) {
- const pollInstallationStatus = async () => {
- try {
- const { ref: projectRef, connectionString } = project || {}
- const status = await checkInstallationStatus({ projectRef, connectionString })
- if (status === 'installed') {
- toast.success(`Successfully installed ${name}`, { id: toastId })
- setOpen(false)
- setIsInstalling(false)
- } else {
- setTimeout(() => pollInstallationStatus(), 5000)
- }
- } catch (error) {
- toast.error(`Failed to install ${name}: ${(error as ResponseError).message}`, {
- id: toastId,
- })
- setIsInstalling(false)
- }
- }
- pollInstallationStatus()
- } else {
- toast.success(`Successfully installed ${name}`, { id: toastId })
- setOpen(false)
- setIsInstalling(false)
- }
- } catch (error) {
- toast.error(`Failed to install ${name}: ${(error as ResponseError).message}`, {
- id: toastId,
- })
- setIsInstalling(false)
- }
- }
- const installRequiredIntegrationExtensions = async (extensions: DatabaseExtension[]) => {
- if (!project) return console.error('Project is required')
- const { ref: projectRef, connectionString } = project
- const results = await Promise.allSettled(
- extensions.map((ext) => {
- const { name, default_version: version } = ext
- const createSchema = extensionsSchema[name].schema === 'custom'
- const defaultSchema = getExtensionDefaultSchema(ext)
- const schema =
- defaultSchema ||
- (createSchema ? (extensionsSchema[name].value as string) : extensionsSchema[name].schema)
- return enableExtension({
- projectRef,
- connectionString,
- schema,
- name,
- version,
- cascade: true,
- createSchema: createSchema || !schema.startsWith('pg_'),
- })
- })
- )
- const failure = results.find((r) => r.status === 'rejected')
- if (failure) throw new Error(failure.reason.message)
- }
- useEffect(() => {
- if (!isSuccessExtensions) return
- setExtensionsSchema(defaultExtensionsSchema)
- }, [isSuccessExtensions, defaultExtensionsSchema])
- return (
- <Sheet open={open} onOpenChange={setOpen}>
- <SheetTrigger asChild>
- <Button type="primary">Install integration</Button>
- </SheetTrigger>
- <Form {...form}>
- <form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
- <SheetContent
- size="default"
- aria-describedby={undefined}
- className="flex flex-col gap-0 w-[650px]!"
- >
- <SheetHeader className="flex items-center gap-x-4">
- <div className="shrink-0 w-11 h-11 relative bg-white border rounded-md flex items-center justify-center">
- {icon()}
- </div>
- <div className="flex flex-col">
- <SheetTitle>Install {name}</SheetTitle>
- <SheetDescription>Review and configure this integration</SheetDescription>
- </div>
- </SheetHeader>
- <div className="grow overflow-y-auto">
- <div className="py-5 flex flex-col gap-y-7">
- {Object.keys(inputs).length > 0 && (
- <InstallationSettings form={form} integration={integration} />
- )}
- <InstallationOverview
- integration={integration}
- extensionsSchema={extensionsSchema}
- />
- </div>
- {allowExtensionCustomSchema && (
- <>
- <DialogSectionSeparator />
- <AdvancedSettings
- integration={integration}
- extensionsSchema={extensionsSchema}
- setExtensionsSchema={setExtensionsSchema}
- />
- </>
- )}
- <DialogSectionSeparator />
- </div>
- <SheetFooter>
- <SheetClose asChild>
- <Button type="default" disabled={isInstalling}>
- Cancel
- </Button>
- </SheetClose>
- <Button
- form={formId}
- htmlType="submit"
- type="primary"
- loading={isInstalling}
- disabled={hasMissingExtensions}
- >
- Install integration
- </Button>
- </SheetFooter>
- </SheetContent>
- </form>
- </Form>
- </Sheet>
- )
- }
|