mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 01:15:03 +03:00
284 lines
9.8 KiB
TypeScript
284 lines
9.8 KiB
TypeScript
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_Shadcn_,
|
|
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),
|
|
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_Shadcn_ {...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="flex-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_Shadcn_>
|
|
</Sheet>
|
|
)
|
|
}
|