InstallIntegrationSheet.tsx 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. import { zodResolver } from '@hookform/resolvers/zod'
  2. import { useEffect, useMemo, useState } from 'react'
  3. import { SubmitHandler, useForm } from 'react-hook-form'
  4. import { toast } from 'sonner'
  5. import {
  6. Button,
  7. DialogSectionSeparator,
  8. Form,
  9. Sheet,
  10. SheetClose,
  11. SheetContent,
  12. SheetDescription,
  13. SheetFooter,
  14. SheetHeader,
  15. SheetTitle,
  16. SheetTrigger,
  17. } from 'ui'
  18. import * as z from 'zod'
  19. import { getExtensionDefaultSchema } from '../IntegrationOverviewTabV2.utils'
  20. import { AdvancedSettings } from './AdvancedSettings'
  21. import { InstallationOverview } from './InstallationOverview'
  22. import { InstallationSettings } from './InstallationSettings'
  23. import { type IntegrationDefinition } from '@/components/interfaces/Integrations/Landing/Integrations.constants'
  24. import { useDatabaseExtensionEnableMutation } from '@/data/database-extensions/database-extension-enable-mutation'
  25. import {
  26. DatabaseExtension,
  27. useDatabaseExtensionsQuery,
  28. } from '@/data/database-extensions/database-extensions-query'
  29. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  30. import { useTrack } from '@/lib/telemetry/track'
  31. import { ResponseError } from '@/types'
  32. export interface InstallIntegrationSheetProps {
  33. integration: IntegrationDefinition
  34. }
  35. export type ExtensionsSchema = { [key: string]: { schema: string; value: string | undefined } }
  36. const formId = 'installation-settings'
  37. /**
  38. * [Joshen] Trying to figure out what the ideal data structure is between local + remote integrations
  39. * So it might be a bit messy for now as we get more context and build out this UI
  40. *
  41. * If the integration provides its own SQL installation command, we'll use that
  42. * Otherwise if the integration provides its own SQL installation query, we'll use that through the query endpoint
  43. * Else if the integration only requires extensions, dashboard will generate the queries and fire through the query endpoint
  44. *
  45. */
  46. export const InstallIntegrationSheet = ({ integration }: InstallIntegrationSheetProps) => {
  47. const track = useTrack()
  48. const { data: project } = useSelectedProjectQuery()
  49. const [open, setOpen] = useState(false)
  50. const [isInstalling, setIsInstalling] = useState(false)
  51. const {
  52. icon,
  53. name,
  54. inputs = {},
  55. installationSql,
  56. installationCommand,
  57. checkInstallationStatus,
  58. requiredExtensions: requiredExtensionNames,
  59. } = integration
  60. const allowExtensionCustomSchema = !installationSql
  61. const involvesExtensions = requiredExtensionNames.length > 0
  62. const schema = useMemo(() => {
  63. let baseSchema = z.object({})
  64. Object.entries(inputs).forEach((entry) => {
  65. const [key, input] = entry
  66. baseSchema = baseSchema.extend({
  67. [key]: z.string().min(1, `Please provide a value for ${input.label}`),
  68. })
  69. })
  70. return baseSchema
  71. }, [inputs])
  72. const defaultValues = useMemo(() => {
  73. let values = {} as Record<string, string>
  74. Object.entries(inputs).forEach((entry) => {
  75. const [key] = entry
  76. values[key] = ''
  77. })
  78. return values
  79. }, [inputs])
  80. const form = useForm<Record<string, string>>({
  81. mode: 'onBlur',
  82. reValidateMode: 'onBlur',
  83. resolver: zodResolver(schema as any),
  84. defaultValues,
  85. })
  86. const { data: extensions = [], isSuccess: isSuccessExtensions } = useDatabaseExtensionsQuery(
  87. { projectRef: project?.ref, connectionString: project?.connectionString },
  88. { enabled: involvesExtensions }
  89. )
  90. const defaultExtensionsSchema = useMemo(
  91. () =>
  92. Object.fromEntries(
  93. requiredExtensionNames.map((extName) => {
  94. const ext = extensions.find((x) => x.name === extName)
  95. const defaultSchema = getExtensionDefaultSchema(ext)
  96. return [extName, { schema: defaultSchema ?? 'extensions', value: undefined }]
  97. })
  98. ),
  99. [requiredExtensionNames, extensions]
  100. )
  101. const [extensionsSchema, setExtensionsSchema] =
  102. useState<ExtensionsSchema>(defaultExtensionsSchema)
  103. const requiredExtensions = extensions.filter((ext) => requiredExtensionNames.includes(ext.name))
  104. const requiredExtensionsToBeInstalled = requiredExtensions.filter((ext) => !ext.installed_version)
  105. // [Joshen] Integration requires extensions that are not available to install on the current database image
  106. const hasMissingExtensions = requiredExtensionNames.length !== requiredExtensions.length
  107. const { mutateAsync: enableExtension } = useDatabaseExtensionEnableMutation({ onError: () => {} })
  108. /**
  109. * [Joshen] This is a bit messy again while we're figuring out requirements
  110. * If the integration has required extensions that are yet to be installed, we'll install those
  111. * AND if the integration has a provided installation command, we'll run that too
  112. */
  113. const onSubmit: SubmitHandler<Record<string, string>> = async (values) => {
  114. if (!project) return console.error('Project is required')
  115. setIsInstalling(true)
  116. const toastId = toast.loading(`Installing ${name}`)
  117. try {
  118. if (requiredExtensionsToBeInstalled.length > 0) {
  119. toast.loading(`Installing required database extensions`, { id: toastId })
  120. await installRequiredIntegrationExtensions(requiredExtensionsToBeInstalled)
  121. }
  122. if (installationCommand) {
  123. toast.loading(`Installing ${name}`, { id: toastId })
  124. await installationCommand({ ref: project.ref, track, ...values })
  125. }
  126. if (!!checkInstallationStatus) {
  127. const pollInstallationStatus = async () => {
  128. try {
  129. const { ref: projectRef, connectionString } = project || {}
  130. const status = await checkInstallationStatus({ projectRef, connectionString })
  131. if (status === 'installed') {
  132. toast.success(`Successfully installed ${name}`, { id: toastId })
  133. setOpen(false)
  134. setIsInstalling(false)
  135. } else {
  136. setTimeout(() => pollInstallationStatus(), 5000)
  137. }
  138. } catch (error) {
  139. toast.error(`Failed to install ${name}: ${(error as ResponseError).message}`, {
  140. id: toastId,
  141. })
  142. setIsInstalling(false)
  143. }
  144. }
  145. pollInstallationStatus()
  146. } else {
  147. toast.success(`Successfully installed ${name}`, { id: toastId })
  148. setOpen(false)
  149. setIsInstalling(false)
  150. }
  151. } catch (error) {
  152. toast.error(`Failed to install ${name}: ${(error as ResponseError).message}`, {
  153. id: toastId,
  154. })
  155. setIsInstalling(false)
  156. }
  157. }
  158. const installRequiredIntegrationExtensions = async (extensions: DatabaseExtension[]) => {
  159. if (!project) return console.error('Project is required')
  160. const { ref: projectRef, connectionString } = project
  161. const results = await Promise.allSettled(
  162. extensions.map((ext) => {
  163. const { name, default_version: version } = ext
  164. const createSchema = extensionsSchema[name].schema === 'custom'
  165. const defaultSchema = getExtensionDefaultSchema(ext)
  166. const schema =
  167. defaultSchema ||
  168. (createSchema ? (extensionsSchema[name].value as string) : extensionsSchema[name].schema)
  169. return enableExtension({
  170. projectRef,
  171. connectionString,
  172. schema,
  173. name,
  174. version,
  175. cascade: true,
  176. createSchema: createSchema || !schema.startsWith('pg_'),
  177. })
  178. })
  179. )
  180. const failure = results.find((r) => r.status === 'rejected')
  181. if (failure) throw new Error(failure.reason.message)
  182. }
  183. useEffect(() => {
  184. if (!isSuccessExtensions) return
  185. setExtensionsSchema(defaultExtensionsSchema)
  186. }, [isSuccessExtensions, defaultExtensionsSchema])
  187. return (
  188. <Sheet open={open} onOpenChange={setOpen}>
  189. <SheetTrigger asChild>
  190. <Button type="primary">Install integration</Button>
  191. </SheetTrigger>
  192. <Form {...form}>
  193. <form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
  194. <SheetContent
  195. size="default"
  196. aria-describedby={undefined}
  197. className="flex flex-col gap-0 w-[650px]!"
  198. >
  199. <SheetHeader className="flex items-center gap-x-4">
  200. <div className="shrink-0 w-11 h-11 relative bg-white border rounded-md flex items-center justify-center">
  201. {icon()}
  202. </div>
  203. <div className="flex flex-col">
  204. <SheetTitle>Install {name}</SheetTitle>
  205. <SheetDescription>Review and configure this integration</SheetDescription>
  206. </div>
  207. </SheetHeader>
  208. <div className="grow overflow-y-auto">
  209. <div className="py-5 flex flex-col gap-y-7">
  210. {Object.keys(inputs).length > 0 && (
  211. <InstallationSettings form={form} integration={integration} />
  212. )}
  213. <InstallationOverview
  214. integration={integration}
  215. extensionsSchema={extensionsSchema}
  216. />
  217. </div>
  218. {allowExtensionCustomSchema && (
  219. <>
  220. <DialogSectionSeparator />
  221. <AdvancedSettings
  222. integration={integration}
  223. extensionsSchema={extensionsSchema}
  224. setExtensionsSchema={setExtensionsSchema}
  225. />
  226. </>
  227. )}
  228. <DialogSectionSeparator />
  229. </div>
  230. <SheetFooter>
  231. <SheetClose asChild>
  232. <Button type="default" disabled={isInstalling}>
  233. Cancel
  234. </Button>
  235. </SheetClose>
  236. <Button
  237. form={formId}
  238. htmlType="submit"
  239. type="primary"
  240. loading={isInstalling}
  241. disabled={hasMissingExtensions}
  242. >
  243. Install integration
  244. </Button>
  245. </SheetFooter>
  246. </SheetContent>
  247. </form>
  248. </Form>
  249. </Sheet>
  250. )
  251. }