CreateIcebergWrapperSheet.tsx457 lines · main
1// @ts-nocheck
2import { zodResolver } from '@hookform/resolvers/zod'
3import { useEffect, useRef } from 'react'
4import { SubmitHandler, useForm, useWatch } from 'react-hook-form'
5import { toast } from 'sonner'
6import {
7 Button,
8 Card,
9 CardContent,
10 Form,
11 FormControl,
12 FormField,
13 Input,
14 RadioGroupStacked,
15 RadioGroupStackedItem,
16 SheetFooter,
17 SheetHeader,
18 SheetSection,
19 SheetTitle,
20} from 'ui'
21import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
22import {
23 PageSection,
24 PageSectionContent,
25 PageSectionDescription,
26 PageSectionMeta,
27 PageSectionSummary,
28 PageSectionTitle,
29} from 'ui-patterns/PageSection'
30import * as z from 'zod'
31
32import { CreateWrapperSheetProps } from './CreateWrapperSheet'
33import InputField from './InputField'
34import { useSchemaCreateMutation } from '@/data/database/schema-create-mutation'
35import { useSchemasQuery } from '@/data/database/schemas-query'
36import { useFDWCreateMutation } from '@/data/fdw/fdw-create-mutation'
37import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
38import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
39import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
40
41const FORM_ID = 'create-wrapper-form'
42
43const S3TableSchema = z.object({
44 target: z.literal('S3Tables'),
45 source_schema: z.string().min(1, 'Please provide a namespace name'),
46 wrapper_name: z.string().min(1, 'Please provide a name for your wrapper'),
47 target_schema: z.string().min(1, 'Please provide an unique target schema'),
48 vault_aws_access_key_id: z.string().min(1, 'Required'),
49 vault_aws_secret_access_key: z.string().min(1, 'Required'),
50 region_name: z.string().min(1, 'Required'),
51 vault_aws_s3table_bucket_arn: z.string().min(1, 'Required'),
52})
53const R2CatalogSchema = z.object({
54 target: z.literal('R2Catalog'),
55 source_schema: z.string().min(1, 'Please provide a namespace name'),
56 wrapper_name: z.string().min(1, 'Please provide a name for your wrapper'),
57 target_schema: z.string().min(1, 'Please provide an unique target schema'),
58 vault_aws_access_key_id: z.string().min(1, 'Required'),
59 vault_aws_secret_access_key: z.string().min(1, 'Required'),
60 vault_token: z.string().min(1, 'Required'),
61 warehouse: z.string().min(1, 'Required'),
62 s3: z.object({ endpoint: z.string().min(1, 'Required') }),
63 catalog_uri: z.string().min(1, 'Required'),
64})
65const IcebergRestCatalogSchema = z.object({
66 target: z.literal('IcebergRestCatalog'),
67 source_schema: z.string().min(1, 'Please provide a namespace name'),
68 wrapper_name: z.string().min(1, 'Please provide a name for your wrapper'),
69 target_schema: z.string().min(1, 'Please provide an unique target schema'),
70 vault_aws_access_key_id: z.string().optional(),
71 vault_aws_secret_access_key: z.string().optional(),
72 region_name: z.string().optional(),
73 vault_aws_s3table_bucket_arn: z.string().optional(),
74 vault_token: z.string().optional(),
75 warehouse: z.string().optional(),
76 s3: z.object({ endpoint: z.string().min(1, 'Required') }),
77 catalog_uri: z.string().optional(),
78})
79const formSchema = z.discriminatedUnion('target', [
80 S3TableSchema,
81 R2CatalogSchema,
82 IcebergRestCatalogSchema,
83])
84
85type FormSchema = z.infer<typeof formSchema>
86
87const targetFields: Record<Target, { name: string; required: boolean }[]> = {
88 S3Tables: [
89 { name: 'vault_aws_access_key_id', required: true },
90 { name: 'vault_aws_secret_access_key', required: true },
91 { name: 'region_name', required: true },
92 { name: 'vault_aws_s3table_bucket_arn', required: true },
93 ],
94 R2Catalog: [
95 { name: 'vault_aws_access_key_id', required: true },
96 { name: 'vault_aws_secret_access_key', required: true },
97 { name: 'vault_token', required: true },
98 { name: 'warehouse', required: true },
99 { name: 's3.endpoint', required: true },
100 { name: 'catalog_uri', required: true },
101 ],
102 IcebergRestCatalog: [
103 { name: 'vault_aws_access_key_id', required: false },
104 { name: 'vault_aws_secret_access_key', required: false },
105 { name: 'region_name', required: false },
106 { name: 'vault_aws_s3table_bucket_arn', required: false },
107 { name: 'vault_token', required: false },
108 { name: 'warehouse', required: false },
109 { name: 's3.endpoint', required: false },
110 { name: 'catalog_uri', required: false },
111 ],
112} as const
113
114type Target = 'S3Tables' | 'R2Catalog' | 'IcebergRestCatalog'
115
116const INITIAL_VALUES = {
117 wrapper_name: '',
118 source_schema: '',
119 target_schema: '',
120 target: 'S3Tables',
121 vault_aws_access_key_id: '',
122 vault_aws_s3table_bucket_arn: '',
123 vault_aws_secret_access_key: '',
124 region_name: '',
125} satisfies FormSchema
126
127export const CreateIcebergWrapperSheet = ({
128 wrapperMeta,
129 onDirty,
130 onClose,
131 onCloseWithConfirmation,
132}: CreateWrapperSheetProps) => {
133 const { data: project } = useSelectedProjectQuery()
134 const { data: org } = useSelectedOrganizationQuery()
135 const { mutate: sendEvent } = useSendEventMutation()
136
137 const { mutateAsync: createFDW, isPending: isCreatingWrapper } = useFDWCreateMutation({
138 onSuccess: () => {
139 toast.success(`Successfully created ${wrapperMeta?.label} foreign data wrapper`)
140 onClose()
141 },
142 })
143
144 const { data: schemas } = useSchemasQuery({
145 projectRef: project?.ref!,
146 connectionString: project?.connectionString,
147 })
148
149 const { mutateAsync: createSchema } = useSchemaCreateMutation()
150
151 const form = useForm<FormSchema>({
152 resolver: zodResolver(formSchema as any),
153 defaultValues: INITIAL_VALUES,
154 })
155 const { resetField, formState, setError, watch } = form
156 const { isDirty, isSubmitting } = formState
157
158 useEffect(() => {
159 onDirty(isDirty)
160 }, [onDirty, isDirty])
161
162 const currentTarget = useRef<FormSchema['target']>(INITIAL_VALUES.target)
163 useEffect(() => {
164 const subscription = watch((values) => {
165 if (!values.target || values.target === currentTarget.current) return
166 currentTarget.current = values.target
167
168 const fields = targetFields[values.target]
169 if (!fields) return
170
171 wrapperMeta.server.options.forEach((option) => {
172 // @ts-expect-error Can't reconcile with form schema
173 resetField(option.name, { defaultValue: option.defaultValue ?? '' })
174 })
175 })
176
177 return () => subscription.unsubscribe()
178 }, [resetField, watch, wrapperMeta])
179
180 const onSubmit: SubmitHandler<FormSchema> = async (values) => {
181 const foundSchema = schemas?.find((s) => s.name === values.target_schema)
182 if (foundSchema) {
183 setError('target_schema', {
184 type: 'validate',
185 message: 'This schema already exists. Please specify a unique schema name.',
186 })
187 return
188 }
189
190 let formValues: Record<string, string> = {}
191 if (values.target === 'R2Catalog' || values.target === 'IcebergRestCatalog') {
192 const { s3, ...otherFormValues } = values
193 formValues = otherFormValues
194 formValues['s3.endpoint'] = s3.endpoint
195 } else {
196 formValues = values
197 }
198
199 try {
200 await createSchema({
201 projectRef: project?.ref,
202 connectionString: project?.connectionString,
203 name: values.target_schema,
204 })
205
206 await createFDW({
207 projectRef: project?.ref,
208 connectionString: project?.connectionString,
209 wrapperMeta,
210 formState: {
211 ...formValues,
212 server_name: `${values.wrapper_name}_server`,
213 briven_target_schema: values.target_schema,
214 },
215 mode: 'schema',
216 tables: [],
217 sourceSchema: values.source_schema,
218 targetSchema: values.target_schema,
219 })
220
221 sendEvent({
222 action: 'foreign_data_wrapper_created',
223 properties: {
224 wrapperType: wrapperMeta.label,
225 },
226 groups: {
227 project: project?.ref ?? 'Unknown',
228 organization: org?.slug ?? 'Unknown',
229 },
230 })
231 } catch (error) {
232 console.error(error)
233 // The error will be handled by the mutation onError callback (toast.error)
234 }
235 }
236
237 const isLoading = isCreatingWrapper || isSubmitting
238 const wrapperName = useWatch({ name: 'wrapper_name', control: form.control })
239 const target = useWatch({ name: 'target', control: form.control })
240
241 const targetOptions = wrapperMeta.server.options
242 .filter((option) => targetFields[target].find((field) => field.name === option.name))
243 .map((option) => {
244 return {
245 ...option,
246 required: !!targetFields[target].find((field) => field.name === option.name)?.required,
247 }
248 })
249 return (
250 <>
251 <div className="h-full" tabIndex={-1}>
252 <Form {...form}>
253 <form
254 id={FORM_ID}
255 onSubmit={form.handleSubmit(onSubmit)}
256 className="flex flex-col h-full"
257 >
258 <SheetHeader>
259 <SheetTitle>Create a {wrapperMeta.label} wrapper</SheetTitle>
260 </SheetHeader>
261 <SheetSection className="grow overflow-y-auto">
262 <PageSection>
263 <PageSectionMeta>
264 <PageSectionSummary>
265 <PageSectionTitle>Wrapper Configuration</PageSectionTitle>
266 </PageSectionSummary>
267 </PageSectionMeta>
268 <PageSectionContent>
269 <Card>
270 <CardContent>
271 <FormField
272 control={form.control}
273 name="wrapper_name"
274 render={({ field }) => (
275 <FormItemLayout
276 layout="horizontal"
277 label="Wrapper Name"
278 description={
279 wrapperName.length > 0 ? (
280 <>
281 Your wrapper's server name will be{' '}
282 <code className="text-code-inline">{wrapperName}_server</code>
283 </>
284 ) : (
285 ''
286 )
287 }
288 >
289 <FormControl>
290 <Input {...field} />
291 </FormControl>
292 </FormItemLayout>
293 )}
294 />
295 </CardContent>
296 </Card>
297 </PageSectionContent>
298 </PageSection>
299 <PageSection>
300 <PageSectionMeta>
301 <PageSectionSummary>
302 <PageSectionTitle>Data target</PageSectionTitle>
303 </PageSectionSummary>
304 </PageSectionMeta>
305 <PageSectionContent>
306 <Card>
307 <CardContent>
308 <FormField
309 control={form.control}
310 name="target"
311 render={({ field }) => (
312 <FormItemLayout layout="vertical">
313 <div>
314 <RadioGroupStacked value={field.value} onValueChange={field.onChange}>
315 <RadioGroupStackedItem
316 key="S3Tables"
317 value="S3Tables"
318 label="AWS S3 Tables"
319 showIndicator={false}
320 >
321 <div className="flex gap-x-5">
322 <div className="flex flex-col">
323 <p className="text-foreground-light text-left">
324 AWS S3 storage that's optimized for analytics workloads.
325 </p>
326 </div>
327 </div>
328 </RadioGroupStackedItem>
329 <RadioGroupStackedItem
330 key="R2Catalog"
331 value="R2Catalog"
332 label="Cloudflare R2 Catalog"
333 showIndicator={false}
334 >
335 <div className="flex gap-x-5">
336 <div className="flex flex-col">
337 <p className="text-foreground-light text-left">
338 Managed Apache Iceberg built directly into your R2 bucket.
339 </p>
340 </div>
341 </div>
342 </RadioGroupStackedItem>
343 <RadioGroupStackedItem
344 key="IcebergRestCatalog"
345 value="IcebergRestCatalog"
346 label="Iceberg REST Catalog"
347 showIndicator={false}
348 >
349 <div className="flex gap-x-5">
350 <div className="flex flex-col">
351 <p className="text-foreground-light text-left">
352 Can be used with any S3-compatible storage.
353 </p>
354 </div>
355 </div>
356 </RadioGroupStackedItem>
357 </RadioGroupStacked>
358 </div>
359 </FormItemLayout>
360 )}
361 />
362 </CardContent>
363 </Card>
364 </PageSectionContent>
365 </PageSection>
366
367 <PageSection>
368 <PageSectionMeta>
369 <PageSectionSummary>
370 <PageSectionTitle>{wrapperMeta.label} Configuration</PageSectionTitle>
371 </PageSectionSummary>
372 </PageSectionMeta>
373 <PageSectionContent>
374 <Card>
375 {targetOptions.map((option) =>
376 option.hidden ? (
377 <input
378 key={`${option.name}-${option.required}-${option.hidden}`}
379 type="hidden"
380 // @ts-expect-error Can't reconcile with form schema
381 {...form.register(option.name)}
382 />
383 ) : (
384 <CardContent key={`${option.name}-${option.required}-${option.hidden}`}>
385 <InputField control={form.control} option={option} />
386 </CardContent>
387 )
388 )}
389 </Card>
390 </PageSectionContent>
391 </PageSection>
392 <PageSection>
393 <PageSectionMeta>
394 <PageSectionSummary>
395 <PageSectionTitle>Foreign Schema</PageSectionTitle>
396 <PageSectionDescription>
397 You can query your data from the foreign tables in the specified schema after
398 the wrapper is created.
399 </PageSectionDescription>
400 </PageSectionSummary>
401 </PageSectionMeta>
402 <PageSectionContent>
403 <Card>
404 <CardContent>
405 {wrapperMeta.sourceSchemaOption && (
406 <InputField
407 control={form.control}
408 option={wrapperMeta.sourceSchemaOption}
409 />
410 )}
411 </CardContent>
412 <CardContent>
413 <InputField
414 control={form.control}
415 option={{
416 name: 'target_schema',
417 label: 'Specify a new schema to create all wrapper tables in',
418 description:
419 'A new schema will be created. For security purposes, the wrapper tables from the foreign schema cannot be created within an existing schema.',
420 required: true,
421 encrypted: false,
422 secureEntry: false,
423 }}
424 />
425 </CardContent>
426 </Card>
427 </PageSectionContent>
428 </PageSection>
429 </SheetSection>
430
431 <SheetFooter>
432 <Button
433 size="tiny"
434 type="default"
435 htmlType="button"
436 onClick={onCloseWithConfirmation}
437 disabled={isLoading}
438 >
439 Cancel
440 </Button>
441 <Button
442 size="tiny"
443 type="primary"
444 form={FORM_ID}
445 htmlType="submit"
446 loading={isLoading}
447 disabled={isLoading || !isDirty}
448 >
449 Create wrapper
450 </Button>
451 </SheetFooter>
452 </form>
453 </Form>
454 </div>
455 </>
456 )
457}