CreateWrapperSheet.tsx506 lines · main
1// @ts-nocheck
2import { zodResolver } from '@hookform/resolvers/zod'
3import { useQueryClient } from '@tanstack/react-query'
4import { Edit, Trash } from 'lucide-react'
5import { useEffect, useState } from 'react'
6import { SubmitHandler, useFieldArray, useForm, useWatch } from 'react-hook-form'
7import { toast } from 'sonner'
8import {
9 Button,
10 Card,
11 CardContent,
12 Form,
13 FormControl,
14 FormField,
15 Input,
16 RadioGroupStacked,
17 RadioGroupStackedItem,
18 SheetFooter,
19 SheetHeader,
20 SheetSection,
21 SheetTitle,
22 WarningIcon,
23} from 'ui'
24import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
25import {
26 PageSection,
27 PageSectionContent,
28 PageSectionDescription,
29 PageSectionMeta,
30 PageSectionSummary,
31 PageSectionTitle,
32} from 'ui-patterns/PageSection'
33import * as z from 'zod'
34
35import InputField from './InputField'
36import { WrapperMeta } from './Wrappers.types'
37import { FormattedWrapperTable, getWrapperCreationFormSchema, NewTable } from './Wrappers.utils'
38import WrapperTableEditor from './WrapperTableEditor'
39import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query'
40import { useSchemaCreateMutation } from '@/data/database/schema-create-mutation'
41import { invalidateSchemasQuery, useSchemasQuery } from '@/data/database/schemas-query'
42import { useFDWCreateMutation } from '@/data/fdw/fdw-create-mutation'
43import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
44import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
45import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
46
47const FORM_ID = 'create-wrapper-form'
48
49export interface CreateWrapperSheetProps {
50 wrapperMeta: WrapperMeta
51 onDirty: (isDirty: boolean) => void
52 onClose: () => void
53 onCloseWithConfirmation: () => void
54}
55
56export const CreateWrapperSheet = ({
57 wrapperMeta,
58 onDirty,
59 onClose,
60 onCloseWithConfirmation,
61}: CreateWrapperSheetProps) => {
62 const queryClient = useQueryClient()
63
64 const { data: project } = useSelectedProjectQuery()
65 const { data: org } = useSelectedOrganizationQuery()
66 const { mutate: sendEvent } = useSendEventMutation()
67
68 const [selectedTableToEdit, setSelectedTableToEdit] = useState<
69 FormattedWrapperTable | undefined
70 >()
71
72 const { data: extensions } = useDatabaseExtensionsQuery({
73 projectRef: project?.ref,
74 connectionString: project?.connectionString,
75 })
76
77 const wrappersExtension = extensions?.find((ext) => ext.name === 'wrappers')
78 // The import foreign schema requires a minimum extension version of 0.5.0
79 const hasRequiredVersionForeignSchema = wrappersExtension?.installed_version
80 ? wrappersExtension?.installed_version >= '0.5.0'
81 : false
82
83 const { data: schemas } = useSchemasQuery({
84 projectRef: project?.ref!,
85 connectionString: project?.connectionString,
86 })
87
88 const initialValues = {
89 wrapper_name: '',
90 server_name: '',
91 mode: wrapperMeta.tables.length > 0 ? 'tables' : 'schema',
92 source_schema: wrapperMeta.sourceSchemaOption?.defaultValue ?? '',
93 target_schema: '',
94 ...Object.fromEntries(
95 wrapperMeta.server.options.map((option) => [option.name, option.defaultValue ?? ''])
96 ),
97 tables: [] as Array<FormattedWrapperTable>,
98 }
99
100 const formSchema = getWrapperCreationFormSchema(wrapperMeta)
101 type FormSchema = z.infer<typeof formSchema>
102 const form = useForm<FormSchema>({
103 defaultValues: initialValues,
104 resolver: zodResolver(formSchema as any),
105 })
106
107 const { getValues, setError } = form
108 const { errors, isDirty, isSubmitting } = form.formState
109
110 useEffect(() => {
111 onDirty(isDirty)
112 }, [onDirty, isDirty])
113
114 const {
115 fields: tablesField,
116 append: appendTable,
117 remove: removeTable,
118 insert: insertTable,
119 } = useFieldArray({
120 control: form.control,
121 name: 'tables',
122 })
123
124 const { mutateAsync: createSchema, isPending: isCreatingSchema } = useSchemaCreateMutation()
125
126 const onUpdateTable = (values: FormattedWrapperTable) => {
127 if (values.index !== undefined) {
128 removeTable(values.index)
129 insertTable(values.index, values)
130 } else {
131 appendTable(values)
132 }
133 setSelectedTableToEdit(undefined)
134 }
135
136 const { mutateAsync: createFDW, isPending: isCreatingWrapper } = useFDWCreateMutation({
137 onSuccess: () => {
138 toast.success(`Successfully created ${wrapperMeta?.label} foreign data wrapper`)
139
140 const { tables } = getValues()
141 const hasNewSchema = (tables as Record<string, any>[]).some((table) => table.is_new_schema)
142 if (hasNewSchema) invalidateSchemasQuery(queryClient, project?.ref)
143
144 onClose()
145 form.reset()
146 },
147 })
148
149 const onSubmit: SubmitHandler<FormSchema> = async (values) => {
150 const { mode, tables = [], ...wrapperValues } = values
151 if (mode === 'tables' && tables.length === 0) {
152 setError('tables', {
153 type: 'validate',
154 message: 'Please provide at least one table.',
155 })
156 return
157 }
158 if (mode === 'schema') {
159 const foundSchema = schemas?.find((s) => s.name === wrapperValues.target_schema)
160 if (foundSchema) {
161 setError('target_schema', {
162 type: 'validate',
163 message: 'This schema already exists. Please specify a unique schema name.',
164 })
165 return
166 }
167 }
168
169 try {
170 if (mode === 'schema') {
171 await createSchema({
172 projectRef: project?.ref,
173 connectionString: project?.connectionString,
174 name: wrapperValues.target_schema,
175 })
176 }
177
178 await createFDW({
179 projectRef: project?.ref,
180 connectionString: project?.connectionString,
181 wrapperMeta,
182 formState: {
183 ...wrapperValues,
184 server_name: `${wrapperValues.wrapper_name}_server`,
185 briven_target_schema: mode === 'schema' ? wrapperValues.target_schema : undefined,
186 },
187 mode: mode === 'schema' ? (wrapperMeta.sourceSchemaOption ? 'schema' : 'skip') : 'tables',
188 tables,
189 sourceSchema: wrapperValues.source_schema,
190 targetSchema: wrapperValues.target_schema,
191 })
192
193 sendEvent({
194 action: 'foreign_data_wrapper_created',
195 properties: {
196 wrapperType: wrapperMeta.label,
197 },
198 groups: {
199 project: project?.ref ?? 'Unknown',
200 organization: org?.slug ?? 'Unknown',
201 },
202 })
203 } catch (error) {
204 console.error(error)
205 // The error will be handled by the mutation onError callback (toast.error)
206 }
207 }
208
209 const isLoading = isCreatingWrapper || isCreatingSchema
210 const wrapper_name = useWatch({ name: 'wrapper_name', control: form.control })
211 const mode = useWatch({ name: 'mode', control: form.control })
212
213 return (
214 <>
215 <div className="h-full" tabIndex={-1}>
216 <Form {...form}>
217 <form
218 id={FORM_ID}
219 onSubmit={form.handleSubmit(onSubmit)}
220 className="flex flex-col h-full"
221 >
222 <SheetHeader>
223 <SheetTitle>Create a {wrapperMeta.label} wrapper</SheetTitle>
224 </SheetHeader>
225 <SheetSection className="grow overflow-y-auto">
226 <PageSection>
227 <PageSectionMeta>
228 <PageSectionSummary>
229 <PageSectionTitle>Wrapper Configuration</PageSectionTitle>
230 </PageSectionSummary>
231 </PageSectionMeta>
232 <PageSectionContent>
233 <Card>
234 <CardContent>
235 <FormField
236 control={form.control}
237 name="wrapper_name"
238 render={({ field }) => (
239 <FormItemLayout
240 layout="vertical"
241 label="Wrapper Name"
242 name="wrapper_name"
243 description={
244 wrapper_name.length > 0 ? (
245 <>
246 Your wrapper's server name will be{' '}
247 <code className="text-code-inline">{wrapper_name}_server</code>
248 </>
249 ) : (
250 ''
251 )
252 }
253 >
254 <FormControl>
255 <Input id="wrapper_name" {...field} />
256 </FormControl>
257 </FormItemLayout>
258 )}
259 />
260 </CardContent>
261 </Card>
262 </PageSectionContent>
263 </PageSection>
264 <PageSection>
265 <PageSectionMeta>
266 <PageSectionSummary>
267 <PageSectionTitle>{wrapperMeta.label} Configuration</PageSectionTitle>
268 </PageSectionSummary>
269 </PageSectionMeta>
270 <PageSectionContent>
271 <Card>
272 {wrapperMeta.server.options
273 .filter((option) => !option.hidden)
274 .map((option) => (
275 <CardContent key={option.name}>
276 <InputField option={option} control={form.control} />
277 </CardContent>
278 ))}
279 </Card>
280 </PageSectionContent>
281 </PageSection>
282 <PageSection>
283 <PageSectionMeta>
284 <PageSectionSummary>
285 <PageSectionTitle>Data target</PageSectionTitle>
286 </PageSectionSummary>
287 </PageSectionMeta>
288 <PageSectionContent>
289 <FormField
290 control={form.control}
291 name="mode"
292 render={({ field }) => (
293 <FormItemLayout layout="vertical">
294 <FormControl>
295 <RadioGroupStacked
296 value={field.value as string}
297 onValueChange={field.onChange}
298 >
299 <RadioGroupStackedItem
300 key="tables"
301 value="tables"
302 disabled={wrapperMeta.tables.length === 0}
303 label="Tables"
304 showIndicator={false}
305 >
306 <div className="flex gap-x-5">
307 <div className="flex flex-col">
308 <p className="text-foreground-light text-left">
309 Create foreign tables to query data from {wrapperMeta.label}.
310 </p>
311 </div>
312 </div>
313 {wrapperMeta.tables.length === 0 ? (
314 <div className="w-full flex gap-x-2 py-2 items-center">
315 <WarningIcon />
316 <span className="text-xs">
317 This wrapper doesn't support using foreign tables.
318 </span>
319 </div>
320 ) : null}
321 </RadioGroupStackedItem>
322 <RadioGroupStackedItem
323 key="schema"
324 value="schema"
325 disabled={
326 !wrapperMeta.canTargetSchema || !hasRequiredVersionForeignSchema
327 }
328 label="Schema"
329 showIndicator={false}
330 >
331 <div className="flex gap-x-5">
332 <div className="flex flex-col">
333 <p className="text-foreground-light text-left">
334 Create all foreign tables from {wrapperMeta.label} in a
335 specified schema.
336 </p>
337 </div>
338 </div>
339 {wrapperMeta.canTargetSchema ? (
340 hasRequiredVersionForeignSchema ? null : (
341 <div className="w-full flex gap-x-2 py-2 items-center">
342 <WarningIcon />
343 <span className="text-xs text-left">
344 This feature requires the{' '}
345 <span className="text-brand">wrappers</span> extension to be
346 of minimum version of 0.5.0.
347 </span>
348 </div>
349 )
350 ) : (
351 <div className="w-full flex gap-x-2 py-2 items-center">
352 <WarningIcon />
353 <span className="text-xs">
354 This wrapper doesn't support using a foreign schema.
355 </span>
356 </div>
357 )}
358 </RadioGroupStackedItem>
359 </RadioGroupStacked>
360 </FormControl>
361 </FormItemLayout>
362 )}
363 />
364 </PageSectionContent>
365 </PageSection>
366 {mode === 'tables' && (
367 <PageSection>
368 <PageSectionMeta>
369 <PageSectionSummary>
370 <PageSectionTitle>Foreign Tables</PageSectionTitle>
371 <PageSectionDescription>
372 You can query your data from these foreign tables after the wrapper is
373 created
374 </PageSectionDescription>
375 </PageSectionSummary>
376 </PageSectionMeta>
377 <PageSectionContent className="flex flex-col space-y-2">
378 {tablesField.map((t, tableIndex) => {
379 // FIXME: make inference work
380 const table = t as unknown as FormattedWrapperTable
381 return (
382 <div
383 key={t.id}
384 className="flex items-center justify-between px-4 py-2 border rounded-md border-control"
385 >
386 <div>
387 <p className="text-sm">
388 {table.schema_name}.{table.table_name}
389 </p>
390 <p className="text-sm text-foreground-light">
391 Columns:{' '}
392 {(table.columns ?? []).map((column: any) => column.name).join(', ')}
393 </p>
394 </div>
395 <div className="flex items-center space-x-2">
396 <Button
397 type="default"
398 className="px-1"
399 icon={<Edit />}
400 onClick={() => {
401 setSelectedTableToEdit(table)
402 }}
403 />
404 <Button
405 type="default"
406 className="px-1"
407 icon={<Trash />}
408 onClick={() => {
409 removeTable(tableIndex)
410 }}
411 />
412 </div>
413 </div>
414 )
415 })}
416
417 <div className="flex justify-end">
418 <Button type="default" onClick={() => setSelectedTableToEdit(NewTable)}>
419 Add foreign table
420 </Button>
421 </div>
422 {tablesField.length === 0 && errors.tables && (
423 <p className="text-sm text-right text-red-900">
424 {errors.tables.message?.toString()}
425 </p>
426 )}
427 </PageSectionContent>
428 </PageSection>
429 )}
430 {mode === 'schema' && (
431 <PageSection>
432 <PageSectionMeta>
433 <PageSectionSummary>
434 <PageSectionTitle>Foreign Schema</PageSectionTitle>
435 <PageSectionDescription>
436 You can query your data from the foreign tables in the specified schema
437 after the wrapper is created.
438 </PageSectionDescription>
439 </PageSectionSummary>
440 </PageSectionMeta>
441 <PageSectionContent>
442 {wrapperMeta.sourceSchemaOption &&
443 !wrapperMeta.sourceSchemaOption?.readOnly && (
444 // Hide the field if the source schema is read-only
445 <InputField
446 key="source_schema"
447 option={wrapperMeta.sourceSchemaOption}
448 control={form.control}
449 />
450 )}
451 <div className="flex flex-col gap-2">
452 <InputField
453 key="target_schema"
454 option={{
455 name: 'target_schema',
456 label: 'Specify a new schema to create all wrapper tables in',
457 description:
458 'A new schema will be created. For security purposes, the wrapper tables from the foreign schema cannot be created within an existing schema.',
459 required: true,
460 encrypted: false,
461 secureEntry: false,
462 }}
463 control={form.control}
464 />
465 </div>
466 </PageSectionContent>
467 </PageSection>
468 )}
469 </SheetSection>
470 <SheetFooter>
471 <Button
472 size="tiny"
473 type="default"
474 htmlType="button"
475 onClick={onCloseWithConfirmation}
476 disabled={isLoading}
477 >
478 Cancel
479 </Button>
480 <Button
481 size="tiny"
482 type="primary"
483 form={FORM_ID}
484 htmlType="submit"
485 disabled={isSubmitting || isLoading}
486 loading={isLoading}
487 >
488 Create wrapper
489 </Button>
490 </SheetFooter>
491 </form>
492 </Form>
493 </div>
494
495 <WrapperTableEditor
496 visible={selectedTableToEdit != null}
497 tables={wrapperMeta.tables}
498 onCancel={() => {
499 setSelectedTableToEdit(undefined)
500 }}
501 onSave={onUpdateTable}
502 initialData={selectedTableToEdit}
503 />
504 </>
505 )
506}