EditWrapperSheet.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404
  1. import { zodResolver } from '@hookform/resolvers/zod'
  2. import { useQueryClient } from '@tanstack/react-query'
  3. import { compact } from 'lodash'
  4. import { Edit, Trash } from 'lucide-react'
  5. import { useEffect, useMemo, 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. SheetFooter,
  17. SheetHeader,
  18. SheetSection,
  19. SheetTitle,
  20. } from 'ui'
  21. import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
  22. import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
  23. import {
  24. PageSection,
  25. PageSectionContent,
  26. PageSectionDescription,
  27. PageSectionMeta,
  28. PageSectionSummary,
  29. PageSectionTitle,
  30. } from 'ui-patterns/PageSection'
  31. import * as z from 'zod'
  32. import InputField from './InputField'
  33. import { WrapperMeta } from './Wrappers.types'
  34. import {
  35. convertKVStringArrayToJson,
  36. FormattedWrapperTable,
  37. formatWrapperTables,
  38. getEditionFormSchema,
  39. NewTable,
  40. } from './Wrappers.utils'
  41. import WrapperTableEditor from './WrapperTableEditor'
  42. import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
  43. import { invalidateSchemasQuery } from '@/data/database/schemas-query'
  44. import { useFDWUpdateMutation } from '@/data/fdw/fdw-update-mutation'
  45. import { FDW } from '@/data/fdw/fdws-query'
  46. import { getDecryptedValues } from '@/data/vault/vault-secret-decrypted-value-query'
  47. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  48. import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
  49. import { UUID_REGEX } from '@/lib/constants'
  50. export interface EditWrapperSheetProps {
  51. wrapper: FDW
  52. isClosing: boolean
  53. wrapperMeta: WrapperMeta
  54. setIsClosing: (v: boolean) => void
  55. onClose: () => void
  56. }
  57. const FORM_ID = 'edit-wrapper-form'
  58. export const EditWrapperSheet = ({
  59. wrapper,
  60. wrapperMeta,
  61. isClosing,
  62. setIsClosing,
  63. onClose,
  64. }: EditWrapperSheetProps) => {
  65. const queryClient = useQueryClient()
  66. const { data: project } = useSelectedProjectQuery()
  67. const { mutate: updateFDW, isPending: isSaving } = useFDWUpdateMutation({
  68. onSuccess: () => {
  69. toast.success(`Successfully updated ${wrapperMeta?.label} foreign data wrapper`)
  70. const { tables } = getValues()
  71. const hasNewSchema = (tables as Record<string, any>[]).some((table) => table.is_new_schema)
  72. if (hasNewSchema) invalidateSchemasQuery(queryClient, project?.ref)
  73. },
  74. })
  75. const initialValues: Record<string, any> = useMemo(
  76. () => ({
  77. wrapper_name: wrapper?.name,
  78. server_name: wrapper?.server_name,
  79. ...convertKVStringArrayToJson(wrapper?.server_options ?? []),
  80. tables: formatWrapperTables(wrapper, wrapperMeta),
  81. }),
  82. [wrapper, wrapperMeta]
  83. )
  84. const formSchema = getEditionFormSchema(wrapperMeta)
  85. type FormSchema = z.infer<typeof formSchema>
  86. const form = useForm<FormSchema>({
  87. defaultValues: initialValues,
  88. resolver: zodResolver(formSchema as any),
  89. })
  90. const { getValues, resetField, setError } = form
  91. const { errors, isDirty, isSubmitting } = form.formState
  92. const {
  93. fields: tablesField,
  94. append: appendTable,
  95. remove: removeTable,
  96. update: updateTable,
  97. } = useFieldArray({
  98. control: form.control,
  99. name: 'tables',
  100. })
  101. const [selectedTableToEdit, setSelectedTableToEdit] = useState<FormattedWrapperTable | undefined>(
  102. undefined
  103. )
  104. const [isUpdateConfirmationOpen, setIsUpdateConfirmationOpen] = useState(false)
  105. const onUpdateTable = (values: FormattedWrapperTable) => {
  106. if (values.index !== undefined) {
  107. updateTable(values.index, values)
  108. } else {
  109. appendTable(values)
  110. }
  111. setSelectedTableToEdit(undefined)
  112. }
  113. const onSubmit: SubmitHandler<FormSchema> = async (values) => {
  114. const { tables } = values
  115. if (tables.length === 0) {
  116. setError('tables', {
  117. type: 'validate',
  118. message: 'Please provide at least one table.',
  119. })
  120. return
  121. }
  122. setIsUpdateConfirmationOpen(true)
  123. }
  124. const { confirmOnClose, modalProps } = useConfirmOnClose({
  125. checkIsDirty: () => isDirty,
  126. onClose,
  127. })
  128. useEffect(() => {
  129. if (!isClosing) return
  130. if (isDirty) {
  131. confirmOnClose()
  132. } else {
  133. onClose()
  134. }
  135. setIsClosing(false)
  136. }, [isDirty, confirmOnClose, isClosing, onClose, setIsClosing])
  137. const wrapper_name = useWatch({ name: 'wrapper_name', control: form.control })
  138. const [isLoadingSecrets, setIsLoadingSecrets] = useState(false)
  139. useEffect(() => {
  140. const encryptedOptions = wrapperMeta.server.options.filter((option) => option.encrypted)
  141. const encryptedIdsToFetch = compact(
  142. encryptedOptions.map((option) => {
  143. const value = initialValues[option.name]
  144. return value ?? null
  145. })
  146. ).filter((x) => UUID_REGEX.test(x))
  147. // [Joshen] ^ Validate UUID to filter out already decrypted values
  148. const fetchEncryptedValues = async (ids: string[]) => {
  149. try {
  150. setIsLoadingSecrets(true)
  151. // If the secrets haven't loaded, escape and run the effect again when they're loaded
  152. const decryptedValues = await getDecryptedValues({
  153. projectRef: project?.ref,
  154. connectionString: project?.connectionString,
  155. ids: ids,
  156. })
  157. encryptedOptions.forEach((option) => {
  158. const encryptedId = initialValues[option.name]
  159. resetField(option.name, { defaultValue: decryptedValues[encryptedId] })
  160. })
  161. } catch (error) {
  162. toast.error('Failed to fetch encrypted values')
  163. } finally {
  164. setIsLoadingSecrets(false)
  165. }
  166. }
  167. if (encryptedIdsToFetch.length > 0) {
  168. fetchEncryptedValues(encryptedIdsToFetch)
  169. }
  170. }, [initialValues, wrapperMeta, resetField, project?.ref, project?.connectionString])
  171. return (
  172. <>
  173. <div className="flex flex-col h-full" tabIndex={-1}>
  174. <Form {...form}>
  175. <form
  176. id={FORM_ID}
  177. onSubmit={form.handleSubmit(onSubmit)}
  178. className="flex flex-col h-full"
  179. >
  180. <SheetHeader>
  181. <SheetTitle>
  182. Edit {wrapperMeta.label} wrapper: {wrapper.name}
  183. </SheetTitle>
  184. </SheetHeader>
  185. <SheetSection className="grow overflow-y-auto">
  186. <PageSection>
  187. <PageSectionMeta>
  188. <PageSectionSummary>
  189. <PageSectionTitle>Wrapper Configuration</PageSectionTitle>
  190. </PageSectionSummary>
  191. </PageSectionMeta>
  192. <PageSectionContent>
  193. <Card>
  194. <CardContent>
  195. <FormField
  196. control={form.control}
  197. name="wrapper_name"
  198. render={({ field }) => (
  199. <FormItemLayout
  200. layout="vertical"
  201. label="Wrapper Name"
  202. description={
  203. wrapper_name !== initialValues.wrapper_name ? (
  204. <>
  205. Your wrapper's server name will be updated to{' '}
  206. <code className="text-code-inline">{wrapper_name}_server</code>
  207. </>
  208. ) : (
  209. <>
  210. Your wrapper's server name is{' '}
  211. <code className="text-code-inline">{wrapper_name}_server</code>
  212. </>
  213. )
  214. }
  215. >
  216. <FormControl>
  217. <Input {...field} />
  218. </FormControl>
  219. </FormItemLayout>
  220. )}
  221. />
  222. </CardContent>
  223. </Card>
  224. </PageSectionContent>
  225. </PageSection>
  226. <PageSection>
  227. <PageSectionMeta>
  228. <PageSectionSummary>
  229. <PageSectionTitle>{wrapperMeta.label} Configuration</PageSectionTitle>
  230. </PageSectionSummary>
  231. </PageSectionMeta>
  232. <PageSectionContent>
  233. <Card>
  234. {wrapperMeta.server.options
  235. .filter((option) => !option.hidden)
  236. .map((option) => (
  237. <CardContent key={option.name}>
  238. <InputField
  239. option={option}
  240. control={form.control}
  241. loading={option.secureEntry ? isLoadingSecrets : undefined}
  242. />
  243. </CardContent>
  244. ))}
  245. </Card>
  246. </PageSectionContent>
  247. </PageSection>
  248. <PageSection>
  249. <PageSectionMeta>
  250. <PageSectionSummary>
  251. <PageSectionTitle>Foreign Tables</PageSectionTitle>
  252. <PageSectionDescription>
  253. You can query your data from these foreign tables after the wrapper is created
  254. </PageSectionDescription>
  255. </PageSectionSummary>
  256. </PageSectionMeta>
  257. <PageSectionContent className="flex flex-col space-y-2">
  258. {tablesField.map((t, tableIndex) => {
  259. // FIXME: make inference work
  260. const table = t as unknown as FormattedWrapperTable
  261. return (
  262. <div
  263. key={t.id}
  264. className="flex items-center justify-between px-4 py-2 border rounded-md border-control"
  265. >
  266. <div>
  267. <p className="text-sm">
  268. {table.schema_name}.{table.table_name}
  269. </p>
  270. <p className="text-sm text-foreground-light">
  271. Columns:{' '}
  272. {(table.columns ?? []).map((column: any) => column.name).join(', ')}
  273. </p>
  274. </div>
  275. <div className="flex items-center space-x-2">
  276. <Button
  277. type="default"
  278. className="px-1"
  279. icon={<Edit />}
  280. onClick={() => {
  281. setSelectedTableToEdit(table)
  282. }}
  283. />
  284. <Button
  285. type="default"
  286. className="px-1"
  287. icon={<Trash />}
  288. onClick={() => {
  289. removeTable(tableIndex)
  290. }}
  291. />
  292. </div>
  293. </div>
  294. )
  295. })}
  296. <div className="flex justify-end">
  297. <Button type="default" onClick={() => setSelectedTableToEdit(NewTable)}>
  298. Add foreign table
  299. </Button>
  300. </div>
  301. {tablesField.length === 0 && errors.tables && (
  302. <p className="text-sm text-right text-red-900">
  303. {errors.tables.message?.toString()}
  304. </p>
  305. )}
  306. </PageSectionContent>
  307. </PageSection>
  308. </SheetSection>
  309. <SheetFooter>
  310. <Button
  311. size="tiny"
  312. type="default"
  313. htmlType="button"
  314. onClick={confirmOnClose}
  315. disabled={isSubmitting}
  316. >
  317. Cancel
  318. </Button>
  319. <Button
  320. size="tiny"
  321. type="primary"
  322. form={FORM_ID}
  323. htmlType="submit"
  324. disabled={isSubmitting || !isDirty}
  325. loading={isSubmitting}
  326. >
  327. Save wrapper
  328. </Button>
  329. </SheetFooter>
  330. </form>
  331. </Form>
  332. </div>
  333. <ConfirmationModal
  334. visible={isUpdateConfirmationOpen}
  335. title="Recreate wrapper?"
  336. size="medium"
  337. variant="warning"
  338. confirmLabel="Recreate wrapper"
  339. confirmLabelLoading="Recreating wrapper"
  340. loading={isSaving}
  341. onCancel={() => {
  342. setIsUpdateConfirmationOpen(false)
  343. onClose()
  344. }}
  345. onConfirm={() => {
  346. const { tables, ...values } = getValues()
  347. updateFDW({
  348. projectRef: project?.ref,
  349. connectionString: project?.connectionString,
  350. wrapper,
  351. wrapperMeta,
  352. formState: values,
  353. tables,
  354. })
  355. setIsUpdateConfirmationOpen(false)
  356. }}
  357. >
  358. <p className="text-sm text-foreground-light">
  359. Saving changes will drop the existing wrapper and recreate it. Foreign servers and tables
  360. will be recreated, and dependent objects like functions or views that reference those
  361. tables may need to be updated manually afterwards.
  362. </p>
  363. <p className="text-sm text-foreground-light mt-2">Are you sure you want to continue?</p>
  364. </ConfirmationModal>
  365. <DiscardChangesConfirmationDialog {...modalProps} />
  366. <WrapperTableEditor
  367. visible={selectedTableToEdit != null}
  368. tables={wrapperMeta.tables}
  369. onCancel={() => {
  370. setSelectedTableToEdit(undefined)
  371. }}
  372. onSave={onUpdateTable}
  373. initialData={selectedTableToEdit}
  374. />
  375. </>
  376. )
  377. }