CreateWrapperSheet.tsx506 lines · main
| 1 | // @ts-nocheck |
| 2 | import { zodResolver } from '@hookform/resolvers/zod' |
| 3 | import { useQueryClient } from '@tanstack/react-query' |
| 4 | import { Edit, Trash } from 'lucide-react' |
| 5 | import { useEffect, useState } from 'react' |
| 6 | import { SubmitHandler, useFieldArray, useForm, useWatch } from 'react-hook-form' |
| 7 | import { toast } from 'sonner' |
| 8 | import { |
| 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' |
| 24 | import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' |
| 25 | import { |
| 26 | PageSection, |
| 27 | PageSectionContent, |
| 28 | PageSectionDescription, |
| 29 | PageSectionMeta, |
| 30 | PageSectionSummary, |
| 31 | PageSectionTitle, |
| 32 | } from 'ui-patterns/PageSection' |
| 33 | import * as z from 'zod' |
| 34 | |
| 35 | import InputField from './InputField' |
| 36 | import { WrapperMeta } from './Wrappers.types' |
| 37 | import { FormattedWrapperTable, getWrapperCreationFormSchema, NewTable } from './Wrappers.utils' |
| 38 | import WrapperTableEditor from './WrapperTableEditor' |
| 39 | import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query' |
| 40 | import { useSchemaCreateMutation } from '@/data/database/schema-create-mutation' |
| 41 | import { invalidateSchemasQuery, useSchemasQuery } from '@/data/database/schemas-query' |
| 42 | import { useFDWCreateMutation } from '@/data/fdw/fdw-create-mutation' |
| 43 | import { useSendEventMutation } from '@/data/telemetry/send-event-mutation' |
| 44 | import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' |
| 45 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 46 | |
| 47 | const FORM_ID = 'create-wrapper-form' |
| 48 | |
| 49 | export interface CreateWrapperSheetProps { |
| 50 | wrapperMeta: WrapperMeta |
| 51 | onDirty: (isDirty: boolean) => void |
| 52 | onClose: () => void |
| 53 | onCloseWithConfirmation: () => void |
| 54 | } |
| 55 | |
| 56 | export 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 | } |