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