Skip to content

Commit 3182df1

Browse files
authored
feat(etl): Improve destination panel UI (supabase#40001)
1 parent f88277a commit 3182df1

File tree

15 files changed

+825
-638
lines changed

15 files changed

+825
-638
lines changed
Lines changed: 64 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,81 +1,98 @@
1-
import { UseFormReturn } from 'react-hook-form'
1+
import type { ChangeEvent } from 'react'
2+
import type { UseFormReturn } from 'react-hook-form'
23

34
import {
45
Accordion_Shadcn_,
56
AccordionContent_Shadcn_,
67
AccordionItem_Shadcn_,
78
AccordionTrigger_Shadcn_,
9+
Badge,
810
FormControl_Shadcn_,
911
FormField_Shadcn_,
1012
Input_Shadcn_,
1113
} from 'ui'
1214
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
13-
import { DestinationPanelSchemaType } from './DestinationPanel.schema'
15+
import type { DestinationPanelSchemaType } from './DestinationPanel.schema'
1416

1517
export const AdvancedSettings = ({ form }: { form: UseFormReturn<DestinationPanelSchemaType> }) => {
1618
const { type } = form.watch()
1719

20+
const handleNumberChange =
21+
(field: { onChange: (value?: number) => void }) => (e: ChangeEvent<HTMLInputElement>) => {
22+
const val = e.target.value
23+
field.onChange(val === '' ? undefined : Number(val))
24+
}
25+
1826
return (
19-
<Accordion_Shadcn_ type="single" collapsible>
20-
<AccordionItem_Shadcn_ value="item-1" className="border-none">
21-
<AccordionTrigger_Shadcn_ className="font-normal gap-2 justify-between text-sm">
22-
Advanced Settings
23-
</AccordionTrigger_Shadcn_>
24-
<AccordionContent_Shadcn_ asChild className="!pb-0">
25-
<FormField_Shadcn_
26-
control={form.control}
27-
name="maxFillMs"
28-
render={({ field }) => (
29-
<FormItemLayout
30-
className="mb-4"
31-
label="Max fill milliseconds"
32-
layout="vertical"
33-
description="The maximum amount of time to fill the data in milliseconds. Leave empty to use default value."
34-
>
35-
<FormControl_Shadcn_>
36-
<Input_Shadcn_
37-
{...field}
38-
type="number"
39-
value={field.value ?? ''}
40-
onChange={(e) => {
41-
const val = e.target.value
42-
field.onChange(val === '' ? undefined : Number(val))
43-
}}
44-
placeholder="Leave empty for default"
45-
/>
46-
</FormControl_Shadcn_>
47-
</FormItemLayout>
48-
)}
49-
/>
50-
{type === 'BigQuery' && (
27+
<div className="px-5">
28+
<Accordion_Shadcn_ type="single" collapsible>
29+
<AccordionItem_Shadcn_ value="item-1" className="border-none">
30+
<AccordionTrigger_Shadcn_ className="font-normal gap-2 justify-between text-sm py-3 hover:no-underline">
31+
<div className="flex flex-col items-start gap-0.5">
32+
<span className="text-sm font-medium">Advanced settings</span>
33+
<span className="text-sm text-foreground-lighter font-normal">
34+
Optional performance tuning
35+
</span>
36+
</div>
37+
</AccordionTrigger_Shadcn_>
38+
<AccordionContent_Shadcn_ className="!pb-0 space-y-6 pt-3">
39+
{/* Batch wait time - applies to all destinations */}
5140
<FormField_Shadcn_
5241
control={form.control}
53-
name="maxStalenessMins"
42+
name="maxFillMs"
5443
render={({ field }) => (
5544
<FormItemLayout
56-
className="mb-4"
57-
label="Max staleness minutes"
45+
label="Batch wait time (milliseconds)"
5846
layout="vertical"
59-
description="Maximum staleness time allowed in minutes. Leave empty to use default value."
47+
description="How long to wait for more changes before sending. Shorter times mean more real-time updates but higher overhead."
6048
>
6149
<FormControl_Shadcn_>
6250
<Input_Shadcn_
6351
{...field}
6452
type="number"
6553
value={field.value ?? ''}
66-
onChange={(e) => {
67-
const val = e.target.value
68-
field.onChange(val === '' ? undefined : Number(val))
69-
}}
70-
placeholder="Leave empty for default"
54+
onChange={handleNumberChange(field)}
55+
placeholder="e.g., 5000 (5 seconds)"
7156
/>
7257
</FormControl_Shadcn_>
7358
</FormItemLayout>
7459
)}
7560
/>
76-
)}
77-
</AccordionContent_Shadcn_>
78-
</AccordionItem_Shadcn_>
79-
</Accordion_Shadcn_>
61+
62+
{/* BigQuery-specific: Max staleness */}
63+
{type === 'BigQuery' && (
64+
<div className="pt-2">
65+
<FormField_Shadcn_
66+
control={form.control}
67+
name="maxStalenessMins"
68+
render={({ field }) => (
69+
<FormItemLayout
70+
label={
71+
<div className="flex items-center gap-2">
72+
<span>Maximum staleness (minutes)</span>
73+
<Badge>BigQuery only</Badge>
74+
</div>
75+
}
76+
layout="vertical"
77+
description="Maximum age of cached data before BigQuery reads from base tables at query time. Lower values ensure fresher results but may increase query costs."
78+
>
79+
<FormControl_Shadcn_>
80+
<Input_Shadcn_
81+
{...field}
82+
type="number"
83+
value={field.value ?? ''}
84+
onChange={handleNumberChange(field)}
85+
placeholder="e.g. 60 (1 hour)"
86+
/>
87+
</FormControl_Shadcn_>
88+
</FormItemLayout>
89+
)}
90+
/>
91+
</div>
92+
)}
93+
</AccordionContent_Shadcn_>
94+
</AccordionItem_Shadcn_>
95+
</Accordion_Shadcn_>
96+
</div>
8097
)
8198
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { UseFormReturn } from 'react-hook-form'
2+
3+
import { FormControl_Shadcn_, FormField_Shadcn_, Input_Shadcn_ } from 'ui'
4+
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
5+
import type { DestinationPanelSchemaType } from './DestinationPanel.schema'
6+
7+
type DestinationNameInputProps = {
8+
form: UseFormReturn<DestinationPanelSchemaType>
9+
}
10+
11+
export const DestinationNameInput = ({ form }: DestinationNameInputProps) => {
12+
return (
13+
<FormField_Shadcn_
14+
control={form.control}
15+
name="name"
16+
render={({ field }) => (
17+
<FormItemLayout
18+
label="Name"
19+
layout="vertical"
20+
description="A descriptive name to identify this destination"
21+
>
22+
<FormControl_Shadcn_>
23+
<Input_Shadcn_ {...field} placeholder="My destination" />
24+
</FormControl_Shadcn_>
25+
</FormItemLayout>
26+
)}
27+
/>
28+
)
29+
}

apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.schema.ts

Lines changed: 36 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -26,38 +26,44 @@ export const DestinationPanelFormSchema = z
2626
s3SecretAccessKey: z.string().optional(),
2727
s3Region: z.string().optional(),
2828
})
29-
.refine(
30-
(data) => {
31-
if (data.type === 'BigQuery') {
32-
return (
33-
data.projectId &&
34-
data.projectId.length > 0 &&
35-
data.datasetId &&
36-
data.datasetId.length > 0 &&
37-
data.serviceAccountKey &&
38-
data.serviceAccountKey.length > 0
39-
)
40-
} else if (data.type === 'Analytics Bucket') {
41-
const hasValidNamespace =
42-
(data.namespace && data.namespace.length > 0) ||
43-
(data.namespace === 'create-new-namespace' &&
44-
data.newNamespaceName &&
45-
data.newNamespaceName.length > 0)
46-
47-
return (
48-
data.warehouseName &&
49-
data.warehouseName.length > 0 &&
50-
hasValidNamespace &&
51-
data.s3Region &&
52-
data.s3Region.length > 0
29+
.superRefine((data, ctx) => {
30+
const addRequiredFieldError = (path: string, message: string) => {
31+
ctx.addIssue({
32+
code: z.ZodIssueCode.custom,
33+
message,
34+
path: [path],
35+
})
36+
}
37+
38+
if (data.type === 'BigQuery') {
39+
if (!data.projectId?.length) addRequiredFieldError('projectId', 'Project ID is required')
40+
if (!data.datasetId?.length) addRequiredFieldError('datasetId', 'Dataset ID is required')
41+
if (!data.serviceAccountKey?.length)
42+
addRequiredFieldError('serviceAccountKey', 'Service Account Key is required')
43+
} else if (data.type === 'Analytics Bucket') {
44+
if (!data.warehouseName?.length) addRequiredFieldError('warehouseName', 'Bucket is required')
45+
46+
const hasValidNamespace =
47+
(data.namespace?.length && data.namespace !== 'create-new-namespace') ||
48+
(data.namespace === 'create-new-namespace' && data.newNamespaceName?.length)
49+
50+
if (!hasValidNamespace) {
51+
const isCreatingNew = data.namespace === 'create-new-namespace'
52+
addRequiredFieldError(
53+
isCreatingNew ? 'newNamespaceName' : 'namespace',
54+
isCreatingNew ? 'Namespace name is required' : 'Namespace is required'
5355
)
5456
}
55-
return true
56-
},
57-
{
58-
message: 'All fields are required for the selected destination type',
59-
path: ['projectId'],
57+
58+
if (!data.s3Region?.length) addRequiredFieldError('s3Region', 'S3 Region is required')
59+
60+
if (!data.s3AccessKeyId?.length)
61+
addRequiredFieldError('s3AccessKeyId', 'S3 Access Key ID is required')
62+
63+
if (data.s3AccessKeyId !== 'create-new' && !data.s3SecretAccessKey?.length) {
64+
addRequiredFieldError('s3SecretAccessKey', 'S3 Secret Access Key is required')
65+
}
6066
}
61-
)
67+
})
6268

6369
export type DestinationPanelSchemaType = z.infer<typeof DestinationPanelFormSchema>

0 commit comments

Comments
 (0)