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