CreateWrapperSheet.tsx 19 KB

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