index.tsx 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661
  1. // @ts-nocheck
  2. import { zodResolver } from '@hookform/resolvers/zod'
  3. import { PermissionAction } from '@supabase/shared-types/out/constants'
  4. import { useParams } from 'common'
  5. import { AnimatePresence, motion } from 'framer-motion'
  6. import { Loader2 } from 'lucide-react'
  7. import { useEffect, useMemo, useRef, useState } from 'react'
  8. import { useForm } from 'react-hook-form'
  9. import { toast } from 'sonner'
  10. import { Button, DialogSectionSeparator, Form, SheetFooter, SheetSection } from 'ui'
  11. import * as z from 'zod'
  12. import {
  13. useIsETLBigQueryPrivateAlpha,
  14. useIsETLDucklakePrivateAlpha,
  15. useIsETLIcebergPrivateAlpha,
  16. } from '../../useIsETLPrivateAlpha'
  17. import { DestinationType } from '../DestinationPanel.types'
  18. import { AdvancedSettings } from './AdvancedSettings'
  19. import { CREATE_NEW_NAMESPACE } from './DestinationForm.constants'
  20. import { DestinationPanelFormSchema as FormSchema } from './DestinationForm.schema'
  21. import {
  22. buildDestinationConfig,
  23. buildDestinationConfigForValidation,
  24. getDucklakeValidationIssues,
  25. } from './DestinationForm.utils'
  26. import { DestinationNameInput } from './DestinationNameInput'
  27. import { AnalyticsBucketFields, BigQueryFields, DuckLakeFields } from './DestinationPanelFields'
  28. import { NewPublicationPanel } from './NewPublicationPanel'
  29. import { NoDestinationsAvailable } from './NoDestinationsAvailable'
  30. import { PublicationSelection } from './PublicationSelection'
  31. import { ReplicationDisclaimerDialog } from './ReplicationDisclaimerDialog'
  32. import { ValidationFailuresSection } from './ValidationFailuresSection'
  33. import { CreateAnalyticsBucketSheet } from '@/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketSheet'
  34. import { getKeys, useAPIKeysQuery } from '@/data/api-keys/api-keys-query'
  35. import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query'
  36. import {
  37. BatchConfig,
  38. useCreateDestinationPipelineMutation,
  39. } from '@/data/replication/create-destination-pipeline-mutation'
  40. import { useReplicationDestinationByIdQuery } from '@/data/replication/destination-by-id-query'
  41. import { useReplicationPipelineByIdQuery } from '@/data/replication/pipeline-by-id-query'
  42. import { useReplicationPublicationsQuery } from '@/data/replication/publications-query'
  43. import { useRestartPipelineHelper } from '@/data/replication/restart-pipeline-helper'
  44. import { useReplicationSourcesQuery } from '@/data/replication/sources-query'
  45. import { useStartPipelineMutation } from '@/data/replication/start-pipeline-mutation'
  46. import { useUpdateDestinationPipelineMutation } from '@/data/replication/update-destination-pipeline-mutation'
  47. import {
  48. useValidateDestinationMutation,
  49. type ValidationFailure,
  50. } from '@/data/replication/validate-destination-mutation'
  51. import { useValidatePipelineMutation } from '@/data/replication/validate-pipeline-mutation'
  52. import { useIcebergNamespaceCreateMutation } from '@/data/storage/iceberg-namespace-create-mutation'
  53. import { useS3AccessKeyCreateMutation } from '@/data/storage/s3-access-key-create-mutation'
  54. import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
  55. import {
  56. PipelineStatusRequestStatus,
  57. usePipelineRequestStatus,
  58. } from '@/state/replication-pipeline-request-status'
  59. import { type ResponseError } from '@/types'
  60. const formId = 'destination-editor'
  61. interface DestinationFormProps {
  62. selectedType: DestinationType
  63. visible: boolean
  64. existingDestination?: {
  65. sourceId?: number
  66. destinationId: number
  67. pipelineId?: number
  68. enabled: boolean
  69. statusName?: string
  70. }
  71. onClose: () => void
  72. }
  73. type DucklakeApiConfig = {
  74. catalog_url: string
  75. data_path: string
  76. pool_size?: number
  77. s3_access_key_id?: string
  78. s3_secret_access_key?: string
  79. s3_region?: string
  80. s3_endpoint?: string
  81. s3_url_style?: 'path' | 'vhost'
  82. s3_use_ssl?: boolean
  83. metadata_schema?: string
  84. expire_snapshots_older_than?: string
  85. }
  86. export const DestinationForm = ({
  87. selectedType,
  88. visible,
  89. existingDestination,
  90. onClose,
  91. }: DestinationFormProps) => {
  92. const { ref: projectRef } = useParams()
  93. const { setRequestStatus } = usePipelineRequestStatus()
  94. const etlEnableBigQuery = useIsETLBigQueryPrivateAlpha()
  95. const etlEnableIceberg = useIsETLIcebergPrivateAlpha()
  96. const etlEnableDucklake = useIsETLDucklakePrivateAlpha()
  97. const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*')
  98. const [isFormInteracting, setIsFormInteracting] = useState(false)
  99. const [showDisclaimerDialog, setShowDisclaimerDialog] = useState(false)
  100. const [publicationPanelVisible, setPublicationPanelVisible] = useState(false)
  101. const [newBucketSheetVisible, setNewBucketSheetVisible] = useState(false)
  102. const [pendingFormValues, setPendingFormValues] = useState<z.infer<typeof FormSchema> | null>(
  103. null
  104. )
  105. const [hasRunValidation, setHasRunValidation] = useState(false)
  106. const [destinationValidationFailures, setDestinationValidationFailures] = useState<
  107. ValidationFailure[]
  108. >([])
  109. const [pipelineValidationFailures, setPipelineValidationFailures] = useState<ValidationFailure[]>(
  110. []
  111. )
  112. const validationSectionRef = useRef<HTMLDivElement>(null)
  113. const editMode = !!existingDestination
  114. // Compute available destinations based on feature flags
  115. const availableDestinations = useMemo(() => {
  116. const destinations = []
  117. if (etlEnableBigQuery) destinations.push({ value: 'BigQuery', label: 'BigQuery' })
  118. if (etlEnableIceberg)
  119. destinations.push({ value: 'Analytics Bucket', label: 'Analytics Bucket' })
  120. if (etlEnableDucklake) destinations.push({ value: 'DuckLake', label: 'DuckLake' })
  121. return destinations
  122. }, [etlEnableBigQuery, etlEnableDucklake, etlEnableIceberg])
  123. const hasNoAvailableDestinations = availableDestinations.length === 0
  124. const { data: sourcesData } = useReplicationSourcesQuery({ projectRef })
  125. const sourceId = sourcesData?.sources.find((s) => s.name === projectRef)?.id
  126. const {
  127. data: publications = [],
  128. isSuccess: isSuccessPublications,
  129. refetch: refetchPublications,
  130. } = useReplicationPublicationsQuery({ projectRef, sourceId })
  131. const { data: destinationData } = useReplicationDestinationByIdQuery({
  132. projectRef,
  133. destinationId: existingDestination?.destinationId,
  134. })
  135. const { data: pipelineData } = useReplicationPipelineByIdQuery({
  136. projectRef,
  137. pipelineId: existingDestination?.pipelineId,
  138. })
  139. const { data: apiKeys } = useAPIKeysQuery(
  140. { projectRef, reveal: true },
  141. { enabled: canReadAPIKeys }
  142. )
  143. const { serviceKey } = getKeys(apiKeys)
  144. const catalogToken = serviceKey?.api_key ?? ''
  145. const { data: projectSettings } = useProjectSettingsV2Query({ projectRef })
  146. const { mutateAsync: createDestinationPipeline, isPending: creatingDestinationPipeline } =
  147. useCreateDestinationPipelineMutation({
  148. onSuccess: () => form.reset(defaultValues),
  149. })
  150. const { mutateAsync: updateDestinationPipeline, isPending: updatingDestinationPipeline } =
  151. useUpdateDestinationPipelineMutation({
  152. onSuccess: () => form.reset(defaultValues),
  153. })
  154. const { mutateAsync: startPipeline, isPending: startingPipeline } = useStartPipelineMutation()
  155. const { restartPipeline } = useRestartPipelineHelper()
  156. const { mutateAsync: createS3AccessKey, isPending: isCreatingS3AccessKey } =
  157. useS3AccessKeyCreateMutation()
  158. const { mutateAsync: createNamespace, isPending: isCreatingNamespace } =
  159. useIcebergNamespaceCreateMutation()
  160. const { mutateAsync: validateDestination, isPending: isValidatingDestination } =
  161. useValidateDestinationMutation()
  162. const { mutateAsync: validatePipeline, isPending: isValidatingPipeline } =
  163. useValidatePipelineMutation()
  164. const isValidating = isValidatingDestination || isValidatingPipeline
  165. const defaultValues = useMemo(() => {
  166. const config = destinationData?.config
  167. const isBigQueryConfig = config && 'big_query' in config
  168. const isIcebergConfig = config && 'iceberg' in config
  169. const ducklakeConfigValue =
  170. config && 'ducklake' in (config as Record<string, unknown>)
  171. ? (config as Record<string, unknown>).ducklake
  172. : undefined
  173. const ducklakeConfig =
  174. ducklakeConfigValue && typeof ducklakeConfigValue === 'object'
  175. ? (ducklakeConfigValue as DucklakeApiConfig)
  176. : undefined
  177. return {
  178. // Common fields
  179. name: destinationData?.name ?? '',
  180. publicationName: pipelineData?.config.publication_name ?? '',
  181. maxFillMs: pipelineData?.config?.batch?.max_fill_ms ?? undefined,
  182. maxTableSyncWorkers: pipelineData?.config?.max_table_sync_workers ?? undefined,
  183. maxCopyConnectionsPerTable: pipelineData?.config?.max_copy_connections_per_table ?? undefined,
  184. invalidatedSlotBehavior:
  185. (pipelineData?.config as { invalidated_slot_behavior?: 'error' | 'recreate' } | undefined)
  186. ?.invalidated_slot_behavior ?? undefined,
  187. // BigQuery fields
  188. projectId: isBigQueryConfig ? config.big_query.project_id : '',
  189. datasetId: isBigQueryConfig ? config.big_query.dataset_id : '',
  190. serviceAccountKey: isBigQueryConfig ? config.big_query.service_account_key : '',
  191. connectionPoolSize:
  192. (config as { big_query?: { connection_pool_size?: number } } | undefined)?.big_query
  193. ?.connection_pool_size ?? undefined,
  194. maxStalenessMins: isBigQueryConfig ? config.big_query.max_staleness_mins : undefined, // Default: null
  195. // Analytics Bucket fields
  196. warehouseName: isIcebergConfig ? config.iceberg.briven.warehouse_name : '',
  197. namespace: isIcebergConfig ? config.iceberg.briven.namespace : '',
  198. newNamespaceName: '',
  199. catalogToken: isIcebergConfig ? config.iceberg.briven.catalog_token : catalogToken,
  200. s3AccessKeyId: isIcebergConfig ? config.iceberg.briven.s3_access_key_id : '',
  201. s3SecretAccessKey: isIcebergConfig ? config.iceberg.briven.s3_secret_access_key : '',
  202. s3Region:
  203. projectSettings?.region ?? (isIcebergConfig ? config.iceberg.briven.s3_region : ''),
  204. // DuckLake fields
  205. ducklakeCatalogUrl: ducklakeConfig?.catalog_url ?? '',
  206. ducklakeDataPath: ducklakeConfig?.data_path ?? '',
  207. ducklakePoolSize: ducklakeConfig?.pool_size,
  208. ducklakeS3AccessKeyId: ducklakeConfig?.s3_access_key_id ?? '',
  209. ducklakeS3SecretAccessKey: ducklakeConfig?.s3_secret_access_key ?? '',
  210. ducklakeS3Region: ducklakeConfig?.s3_region ?? '',
  211. ducklakeS3Endpoint: ducklakeConfig?.s3_endpoint ?? '',
  212. ducklakeS3UrlStyle: ducklakeConfig?.s3_url_style ?? 'path',
  213. ducklakeS3UseSsl: ducklakeConfig?.s3_use_ssl ?? true,
  214. ducklakeMetadataSchema: ducklakeConfig?.metadata_schema ?? 'ducklake',
  215. ducklakeExpireSnapshotsOlderThan: ducklakeConfig?.expire_snapshots_older_than ?? '',
  216. }
  217. }, [destinationData, pipelineData, catalogToken, projectSettings])
  218. const form = useForm<z.infer<typeof FormSchema>>({
  219. mode: 'onChange',
  220. reValidateMode: 'onChange',
  221. resolver: zodResolver(
  222. FormSchema.superRefine((data, ctx: any) => {
  223. const addRequiredFieldError = (path: string, message: string) => {
  224. ctx.addIssue({
  225. code: z.ZodIssueCode.custom,
  226. message,
  227. path: [path],
  228. })
  229. }
  230. if (selectedType === 'BigQuery') {
  231. if (!data.projectId?.length) addRequiredFieldError('projectId', 'Project ID is required')
  232. if (!data.datasetId?.length) addRequiredFieldError('datasetId', 'Dataset ID is required')
  233. if (!data.serviceAccountKey?.length)
  234. addRequiredFieldError('serviceAccountKey', 'Service Account Key is required')
  235. } else if (selectedType === 'Analytics Bucket') {
  236. if (!data.warehouseName?.length)
  237. addRequiredFieldError('warehouseName', 'Bucket is required')
  238. const hasValidNamespace =
  239. (data.namespace?.length && data.namespace !== 'create-new-namespace') ||
  240. (data.namespace === 'create-new-namespace' && data.newNamespaceName?.length)
  241. if (!hasValidNamespace) {
  242. const isCreatingNew = data.namespace === 'create-new-namespace'
  243. addRequiredFieldError(
  244. isCreatingNew ? 'newNamespaceName' : 'namespace',
  245. isCreatingNew ? 'Namespace name is required' : 'Namespace is required'
  246. )
  247. }
  248. if (!data.s3Region?.length) addRequiredFieldError('s3Region', 'S3 Region is required')
  249. if (!data.s3AccessKeyId?.length)
  250. addRequiredFieldError('s3AccessKeyId', 'S3 Access Key ID is required')
  251. if (data.s3AccessKeyId !== 'create-new' && !data.s3SecretAccessKey?.length) {
  252. addRequiredFieldError('s3SecretAccessKey', 'S3 Secret Access Key is required')
  253. }
  254. } else if (selectedType === 'DuckLake') {
  255. getDucklakeValidationIssues(data).forEach(({ path, message }) => {
  256. addRequiredFieldError(path, message)
  257. })
  258. }
  259. })
  260. ),
  261. defaultValues,
  262. })
  263. const { publicationName, warehouseName } = form.watch()
  264. const publicationNames = useMemo(() => publications?.map((pub) => pub.name) ?? [], [publications])
  265. const isSelectedPublicationMissing =
  266. isSuccessPublications && !!publicationName && !publicationNames.includes(publicationName)
  267. const allValidationFailures = [...destinationValidationFailures, ...pipelineValidationFailures]
  268. const hasValidationFailures = allValidationFailures.some((f) => f.failure_type === 'critical')
  269. const isSaving =
  270. creatingDestinationPipeline ||
  271. updatingDestinationPipeline ||
  272. startingPipeline ||
  273. isCreatingS3AccessKey ||
  274. isCreatingNamespace ||
  275. isValidating
  276. const isSubmitDisabled =
  277. isSaving || isSelectedPublicationMissing || (!editMode && hasNoAvailableDestinations)
  278. const getSubmitButtonText = () => {
  279. if (editMode) {
  280. return existingDestination?.enabled ? 'Apply and restart' : 'Apply and start'
  281. } else {
  282. return 'Create and start'
  283. }
  284. }
  285. // Helper function to handle namespace creation if needed
  286. const resolveNamespace = async (data: z.infer<typeof FormSchema>) => {
  287. if (data.namespace === CREATE_NEW_NAMESPACE) {
  288. if (!data.newNamespaceName) throw new Error('New namespace name is required')
  289. await createNamespace({
  290. projectRef,
  291. warehouse: data.warehouseName!,
  292. namespace: data.newNamespaceName,
  293. })
  294. return data.newNamespaceName
  295. }
  296. return data.namespace
  297. }
  298. // Helper function to validate configuration
  299. const validateConfiguration = async (data: z.infer<typeof FormSchema>) => {
  300. if (!projectRef || !sourceId) return false
  301. setHasRunValidation(true)
  302. // Call both validation endpoints in parallel and wait for both to complete
  303. // even if one fails - this makes the validation feel like a single operation
  304. const results = await Promise.allSettled([
  305. validateDestination({
  306. projectRef,
  307. destinationConfig: buildDestinationConfigForValidation({ projectRef, selectedType, data }),
  308. }),
  309. validatePipeline({
  310. projectRef,
  311. sourceId,
  312. publicationName: data.publicationName,
  313. maxFillMs: data.maxFillMs,
  314. maxTableSyncWorkers: data.maxTableSyncWorkers,
  315. maxCopyConnectionsPerTable: data.maxCopyConnectionsPerTable,
  316. invalidatedSlotBehavior: data.invalidatedSlotBehavior,
  317. }),
  318. ])
  319. // Extract results from settled promises
  320. const destResult = results[0]
  321. const pipelineResult = results[1]
  322. // Check if any validation request failed completely
  323. const hasRequestError = results.some((r) => r.status === 'rejected')
  324. if (hasRequestError) {
  325. // If any request failed, surface the upstream message so users see why
  326. const rejected = results.find((r): r is PromiseRejectedResult => r.status === 'rejected')
  327. const reason =
  328. rejected?.reason instanceof Error ? rejected.reason.message : 'Please try again.'
  329. toast.error(`Failed to validate configuration: ${reason}`)
  330. setHasRunValidation(false)
  331. return false
  332. }
  333. // Both requests succeeded, extract validation failures
  334. const destValidationResult =
  335. destResult.status === 'fulfilled' ? destResult.value : { validation_failures: [] }
  336. const pipelineValidationResult =
  337. pipelineResult.status === 'fulfilled' ? pipelineResult.value : { validation_failures: [] }
  338. setDestinationValidationFailures(destValidationResult.validation_failures)
  339. setPipelineValidationFailures(pipelineValidationResult.validation_failures)
  340. // Check if there are critical failures or warnings
  341. const allFailures = [
  342. ...destValidationResult.validation_failures,
  343. ...pipelineValidationResult.validation_failures,
  344. ]
  345. const hasCriticalFailures = allFailures.some((f) => f.failure_type === 'critical')
  346. const hasAnyFailures = allFailures.length > 0
  347. // Scroll to validation section if there are any failures
  348. if (hasAnyFailures) {
  349. setTimeout(() => {
  350. validationSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
  351. }, 100)
  352. }
  353. return !hasCriticalFailures
  354. }
  355. const submitPipeline = async (data: z.infer<typeof FormSchema>) => {
  356. if (!projectRef) return console.error('Project ref is required')
  357. if (!sourceId) return console.error('Source id is required')
  358. if (isSelectedPublicationMissing) {
  359. return toast.error('Please select another publication before continuing')
  360. }
  361. try {
  362. const destinationConfig = await buildDestinationConfig({
  363. projectRef,
  364. selectedType,
  365. warehouseName,
  366. data,
  367. createS3AccessKey,
  368. resolveNamespace,
  369. })
  370. if (!destinationConfig) throw new Error('Destination configuration is missing')
  371. const batchConfig: BatchConfig | undefined =
  372. data.maxFillMs !== undefined ? { maxFillMs: data.maxFillMs } : undefined
  373. const hasBatchFields = batchConfig !== undefined
  374. const pipelineConfig = {
  375. publicationName: data.publicationName,
  376. maxTableSyncWorkers: data.maxTableSyncWorkers,
  377. maxCopyConnectionsPerTable: data.maxCopyConnectionsPerTable,
  378. invalidatedSlotBehavior: data.invalidatedSlotBehavior,
  379. ...(hasBatchFields ? { batch: batchConfig } : {}),
  380. }
  381. if (editMode && existingDestination) {
  382. if (!existingDestination.pipelineId) return console.error('Pipeline id is required')
  383. await updateDestinationPipeline({
  384. destinationId: existingDestination.destinationId,
  385. pipelineId: existingDestination.pipelineId,
  386. projectRef,
  387. destinationName: data.name,
  388. destinationConfig,
  389. pipelineConfig,
  390. sourceId,
  391. })
  392. // Set request status only right before starting, then fire and close
  393. const snapshot =
  394. existingDestination.statusName ?? (existingDestination.enabled ? 'started' : 'stopped')
  395. if (existingDestination.enabled) {
  396. setRequestStatus(
  397. existingDestination.pipelineId,
  398. PipelineStatusRequestStatus.RestartRequested,
  399. snapshot
  400. )
  401. toast.success('Settings applied. Restarting the pipeline...')
  402. restartPipeline({ projectRef, pipelineId: existingDestination.pipelineId })
  403. } else {
  404. setRequestStatus(
  405. existingDestination.pipelineId,
  406. PipelineStatusRequestStatus.StartRequested,
  407. snapshot
  408. )
  409. toast.success('Settings applied. Starting the pipeline...')
  410. startPipeline({ projectRef, pipelineId: existingDestination.pipelineId })
  411. }
  412. onClose()
  413. } else {
  414. const { pipeline_id: pipelineId } = await createDestinationPipeline({
  415. projectRef,
  416. destinationName: data.name,
  417. destinationConfig,
  418. pipelineConfig,
  419. sourceId,
  420. })
  421. // Set request status only right before starting, then fire and close
  422. setRequestStatus(pipelineId, PipelineStatusRequestStatus.StartRequested, undefined)
  423. toast.success('Destination created. Starting the pipeline...')
  424. startPipeline({ projectRef, pipelineId })
  425. onClose()
  426. }
  427. } catch (error) {
  428. const action = editMode ? 'apply and run' : 'create and start'
  429. toast.error(`Failed to ${action} destination: ${(error as ResponseError).message}`)
  430. }
  431. }
  432. const onSubmit = async (data: z.infer<typeof FormSchema>) => {
  433. if (!editMode) {
  434. // For new pipelines, validate configuration first if not already validated
  435. // OR if user has critical failures and clicks "Validate again"
  436. if (!hasRunValidation || isValidating || hasValidationFailures) {
  437. const isValid = await validateConfiguration(data)
  438. if (!isValid) {
  439. // Validation failed with critical errors, show inline and stop
  440. return
  441. }
  442. // Validation passed or only has warnings, continue to disclaimer
  443. }
  444. // Validation passed or only warnings, proceed to disclaimer
  445. setPendingFormValues(data)
  446. setShowDisclaimerDialog(true)
  447. return
  448. }
  449. await submitPipeline(data)
  450. }
  451. const handleDisclaimerDialogChange = (open: boolean) => {
  452. setShowDisclaimerDialog(open)
  453. if (!open) {
  454. setPendingFormValues(null)
  455. }
  456. }
  457. const handleDisclaimerConfirm = async () => {
  458. if (!pendingFormValues) return
  459. const values = pendingFormValues
  460. setPendingFormValues(null)
  461. setShowDisclaimerDialog(false)
  462. await submitPipeline(values)
  463. }
  464. useEffect(() => {
  465. if (editMode && destinationData && pipelineData && !isFormInteracting) {
  466. form.reset(defaultValues)
  467. }
  468. }, [destinationData, pipelineData, editMode, defaultValues, form, isFormInteracting])
  469. // Ensure the form always reflects the freshest data whenever the panel opens
  470. useEffect(() => {
  471. if (visible) {
  472. form.reset(defaultValues)
  473. setIsFormInteracting(false)
  474. setHasRunValidation(false)
  475. setDestinationValidationFailures([])
  476. setPipelineValidationFailures([])
  477. }
  478. }, [visible, defaultValues, form])
  479. useEffect(() => {
  480. if (visible && projectRef && sourceId) {
  481. refetchPublications()
  482. }
  483. }, [visible, projectRef, sourceId, refetchPublications])
  484. return (
  485. <>
  486. <SheetSection className="grow overflow-auto px-0 py-0">
  487. {hasNoAvailableDestinations && !editMode ? (
  488. <NoDestinationsAvailable />
  489. ) : (
  490. <Form {...form}>
  491. <form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
  492. <div className="p-5 flex flex-col gap-y-6">
  493. <p className="text-sm font-medium text-foreground">Destination details</p>
  494. <div className="space-y-4">
  495. <DestinationNameInput form={form} />
  496. <PublicationSelection
  497. form={form}
  498. sourceId={sourceId}
  499. visible={visible}
  500. onSelectNewPublication={() => setPublicationPanelVisible(true)}
  501. />
  502. </div>
  503. </div>
  504. <DialogSectionSeparator />
  505. {selectedType === 'BigQuery' && etlEnableBigQuery ? (
  506. <BigQueryFields form={form} />
  507. ) : selectedType === 'Analytics Bucket' && etlEnableIceberg ? (
  508. <AnalyticsBucketFields
  509. form={form}
  510. setIsFormInteracting={setIsFormInteracting}
  511. onSelectNewBucket={() => setNewBucketSheetVisible(true)}
  512. />
  513. ) : selectedType === 'DuckLake' && etlEnableDucklake ? (
  514. <DuckLakeFields form={form} />
  515. ) : null}
  516. <DialogSectionSeparator />
  517. <AdvancedSettings type={selectedType} form={form} />
  518. {!editMode && hasRunValidation && !isValidating && (
  519. <>
  520. <DialogSectionSeparator />
  521. <div ref={validationSectionRef}>
  522. <ValidationFailuresSection
  523. destinationFailures={destinationValidationFailures}
  524. pipelineFailures={pipelineValidationFailures}
  525. />
  526. </div>
  527. </>
  528. )}
  529. </form>
  530. </Form>
  531. )}
  532. </SheetSection>
  533. <SheetFooter className="justify-between!">
  534. <AnimatePresence mode="wait">
  535. {isValidating || isSaving ? (
  536. <motion.div
  537. className="flex items-center gap-x-2"
  538. initial={{ opacity: 0, y: 5 }}
  539. animate={{ opacity: 1, y: 0 }}
  540. exit={{ opacity: 0, y: 5 }}
  541. transition={{ duration: 0.2, ease: 'easeOut' }}
  542. >
  543. <Loader2 className="animate-spin" size={14} />
  544. <p className="text-foreground-light text-sm">
  545. {isValidating
  546. ? 'Validating destination configuration...'
  547. : `${editMode ? 'Updating' : 'Creating'} destination...`}
  548. </p>
  549. </motion.div>
  550. ) : (
  551. <div />
  552. )}
  553. </AnimatePresence>
  554. <div className="flex items-center gap-x-2">
  555. <Button disabled={isSaving} type="default" onClick={onClose}>
  556. Cancel
  557. </Button>
  558. <Button disabled={isSubmitDisabled} loading={isSaving} form={formId} htmlType="submit">
  559. {getSubmitButtonText()}
  560. </Button>
  561. </div>
  562. </SheetFooter>
  563. <NewPublicationPanel
  564. sourceId={sourceId}
  565. visible={publicationPanelVisible}
  566. onClose={() => setPublicationPanelVisible(false)}
  567. />
  568. <CreateAnalyticsBucketSheet
  569. open={newBucketSheetVisible}
  570. onOpenChange={setNewBucketSheetVisible}
  571. />
  572. <ReplicationDisclaimerDialog
  573. open={showDisclaimerDialog}
  574. onOpenChange={handleDisclaimerDialogChange}
  575. isLoading={isSaving}
  576. onConfirm={handleDisclaimerConfirm}
  577. />
  578. </>
  579. )
  580. }