WrapperTableEditor.tsx519 lines · main
1import { zodResolver } from '@hookform/resolvers/zod'
2import { Check, ChevronsUpDown, XIcon } from 'lucide-react'
3import { useEffect, useId, useMemo, useState } from 'react'
4import {
5 Control,
6 FieldValues,
7 SubmitHandler,
8 useFieldArray,
9 useForm,
10 useWatch,
11} from 'react-hook-form'
12import {
13 Button,
14 cn,
15 Command,
16 CommandEmpty,
17 CommandGroup,
18 CommandInput,
19 CommandItem,
20 CommandList,
21 Form,
22 FormControl,
23 FormField,
24 Input,
25 Label,
26 Popover,
27 PopoverContent,
28 PopoverTrigger,
29 ScrollArea,
30 Select,
31 SelectContent,
32 SelectItem,
33 SelectSeparator,
34 SelectTrigger,
35 SelectValue,
36 SidePanel,
37} from 'ui'
38import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
39import {
40 MultiSelector,
41 MultiSelectorContent,
42 MultiSelectorItem,
43 MultiSelectorList,
44 MultiSelectorTrigger,
45} from 'ui-patterns/multi-select'
46import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
47import * as z from 'zod'
48
49import { ColumnType } from './ColumnType'
50import type { AvailableColumn, Table, TableOption } from './Wrappers.types'
51import { getTableFormSchema } from './Wrappers.utils'
52import { ActionBar } from '@/components/interfaces/TableGridEditor/SidePanelEditor/ActionBar'
53import { useSchemasQuery } from '@/data/database/schemas-query'
54import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
55
56export type WrapperTableEditorProps = {
57 visible: boolean
58 onCancel: () => void
59 onSave: (values: any) => void
60
61 tables: Table[]
62 initialData: any
63}
64
65const WrapperTableEditor = ({
66 visible,
67 onCancel,
68 onSave,
69 tables,
70 initialData,
71}: WrapperTableEditorProps) => {
72 const [open, setOpen] = useState(false)
73 const listboxId = useId()
74 const [selectedTableIndex, setSelectedTableIndex] = useState<string>('')
75
76 useEffect(() => {
77 if (initialData && Object.keys(initialData).length > 0) {
78 setSelectedTableIndex(String(initialData.index))
79 }
80 }, [initialData])
81
82 const selectedTable = selectedTableIndex === '' ? undefined : tables[parseInt(selectedTableIndex)]
83
84 const handleCancel = () => {
85 setSelectedTableIndex('')
86 onCancel()
87 }
88
89 const onSubmit: SubmitHandler<FieldValues> = (values) => {
90 onSave({
91 ...values,
92 index: parseInt(selectedTableIndex),
93 schema_name: values.schema === 'custom' ? values.schema_name : values.schema,
94 is_new_schema: values.schema === 'custom',
95 })
96 setSelectedTableIndex('')
97 }
98
99 return (
100 <SidePanel
101 key="WrapperTableEditor"
102 size="medium"
103 visible={visible}
104 onCancel={handleCancel}
105 header={<span>Edit foreign table</span>}
106 customFooter={
107 <ActionBar
108 backButtonLabel="Cancel"
109 applyButtonLabel="Save"
110 formId="wrapper-table-editor-form"
111 closePanel={handleCancel}
112 />
113 }
114 >
115 <SidePanel.Content>
116 <div className="my-4 flex flex-col gap-y-6">
117 <div className="flex flex-col gap-y-2">
118 <Label className="text-foreground-light">Select a target the table will point to</Label>
119 <Popover open={open} onOpenChange={setOpen}>
120 <PopoverTrigger asChild>
121 <Button
122 type="default"
123 role="combobox"
124 aria-expanded={open}
125 aria-controls={listboxId}
126 className={cn(
127 'w-full justify-between',
128 !selectedTableIndex && 'text-muted-foreground'
129 )}
130 size="small"
131 iconRight={
132 <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" strokeWidth={1} />
133 }
134 >
135 {!!selectedTableIndex ? tables[Number(selectedTableIndex)].label : '---'}
136 </Button>
137 </PopoverTrigger>
138 <PopoverContent id={listboxId} className="p-0" sameWidthAsTrigger>
139 <Command>
140 <CommandInput placeholder="Find a table..." />
141 <CommandList>
142 <CommandEmpty>No targets found</CommandEmpty>
143 <CommandGroup>
144 <ScrollArea className={(tables ?? []).length > 7 ? 'h-[200px]' : ''}>
145 {(tables ?? []).map((table, i) => (
146 <CommandItem
147 key={table.label}
148 className="cursor-pointer flex items-center justify-between space-x-2 w-full"
149 onSelect={() => {
150 setSelectedTableIndex(String(i))
151 setOpen(false)
152 }}
153 onClick={() => {
154 setSelectedTableIndex(String(i))
155 setOpen(false)
156 }}
157 >
158 <div className="space-y-1">
159 <p>{table.label}</p>
160 <p className="text-foreground-lighter">{table.description}</p>
161 </div>
162 {String(i) === selectedTableIndex && (
163 <Check className={cn('mr-2 h-4 w-4')} />
164 )}
165 </CommandItem>
166 ))}
167 </ScrollArea>
168 </CommandGroup>
169 </CommandList>
170 </Command>
171 </PopoverContent>
172 </Popover>
173 </div>
174
175 {selectedTable && (
176 <TableForm table={selectedTable} onSubmit={onSubmit} initialData={initialData} />
177 )}
178 </div>
179 </SidePanel.Content>
180 </SidePanel>
181 )
182}
183
184export default WrapperTableEditor
185
186const Option = ({ option, control }: { option: TableOption; control: Control<FieldValues> }) => {
187 if (option.type === 'select') {
188 return (
189 <FormField
190 control={control}
191 name={option.name}
192 defaultValue={option.defaultValue}
193 render={({ field }) => (
194 <FormItemLayout layout="vertical" label={option.label} name={option.name}>
195 <FormControl>
196 <Select value={field.value} onValueChange={field.onChange}>
197 <SelectTrigger>
198 <SelectValue placeholder="Select an option" />
199 </SelectTrigger>
200 <SelectContent>
201 <SelectSeparator />
202 {option.options.map((subOption) => (
203 <SelectItem key={subOption.value} value={subOption.value}>
204 {subOption.label}
205 </SelectItem>
206 ))}
207 </SelectContent>
208 </Select>
209 </FormControl>
210 </FormItemLayout>
211 )}
212 />
213 )
214 }
215
216 return (
217 <FormField
218 control={control}
219 name={option.name}
220 defaultValue={option.defaultValue ?? ''}
221 render={({ field }) => (
222 <FormItemLayout layout="vertical" label={option.label} name={option.name}>
223 <FormControl>
224 <Input {...field} id={option.name} placeholder={option.placeholder ?? ''} />
225 </FormControl>
226 </FormItemLayout>
227 )}
228 />
229 )
230}
231
232const TableForm = ({
233 table,
234 onSubmit,
235 initialData,
236}: {
237 table: Table
238 onSubmit: SubmitHandler<FieldValues>
239 initialData: any
240}) => {
241 const { data: project } = useSelectedProjectQuery()
242 const { data: schemas, isPending: isLoading } = useSchemasQuery({
243 projectRef: project?.ref,
244 connectionString: project?.connectionString,
245 })
246
247 const requiredOptions: TableOption[] = []
248 const optionalOptions: TableOption[] = []
249 const nonEditableOptions: TableOption[] = []
250
251 table.options.forEach((option) => {
252 if (option.editable) {
253 if (option.required && !option.defaultValue) {
254 requiredOptions.push(option)
255 return
256 }
257 optionalOptions.push(option)
258 return
259 }
260 nonEditableOptions.push(option)
261 })
262
263 const defaultValues = useMemo(() => {
264 if (initialData && Object.keys(initialData).length > 0) {
265 const { schema } = initialData
266 const existingSchema = schemas?.find((s) => s.name === schema)
267
268 return {
269 schema_name: existingSchema ? '' : schema,
270 schema: existingSchema ? existingSchema.name : 'custom',
271 ...Object.fromEntries(
272 table.options.map((option) => [option.name, option.defaultValue ?? ''])
273 ),
274 ...initialData,
275 }
276 }
277 return {
278 table_name: '',
279 columns: table.availableColumns ?? [],
280 schema: 'public',
281 ...Object.fromEntries(
282 table.options.map((option) => [option.name, option.defaultValue ?? ''])
283 ),
284 }
285 }, [initialData, table, schemas])
286
287 const formSchema = getTableFormSchema(table)
288 type FormSchema = z.infer<typeof formSchema>
289
290 const form = useForm<FormSchema>({
291 defaultValues,
292 resolver: zodResolver(formSchema as any),
293 shouldUnregister: true,
294 })
295
296 const {
297 fields: columnFields,
298 append: appendColumn,
299 replace: replaceColumns,
300 remove: removeColumn,
301 } = useFieldArray({
302 control: form.control,
303 name: 'columns',
304 })
305
306 const { reset } = form
307 useEffect(() => {
308 reset(defaultValues)
309 // Workaround bug in react-hook-form
310 replaceColumns(defaultValues.columns ?? [])
311 }, [reset, replaceColumns, defaultValues])
312
313 const handleSubmit: SubmitHandler<FieldValues> = (values) => {
314 const { schema_name, schema, ...valuesWithoutSchema } = values
315 onSubmit({
316 ...valuesWithoutSchema,
317 // Ensure all options are accounted for.
318 ...Object.fromEntries(
319 table.options.map((option) => [
320 option.name,
321 values[option.name] ?? option.defaultValue ?? '',
322 ])
323 ),
324 schema,
325 schema_name: schema === 'custom' ? schema_name : schema,
326 is_new_schema: schema === 'custom',
327 })
328 reset()
329 }
330
331 const { errors } = form.formState
332 const schema = useWatch({ name: 'schema', control: form.control })
333
334 return (
335 <Form {...form}>
336 <form
337 id="wrapper-table-editor-form"
338 onSubmit={form.handleSubmit(handleSubmit)}
339 className="space-y-4"
340 >
341 {isLoading && <ShimmeringLoader className="py-4" />}
342
343 <FormField
344 control={form.control}
345 name="schema"
346 render={({ field }) => (
347 <FormItemLayout layout="vertical" label="Select a schema for the foreign table">
348 <FormControl>
349 <Select
350 name="schema"
351 value={field.value}
352 onValueChange={(schema) => {
353 field.onChange(schema)
354 form.resetField('schema_name')
355 }}
356 >
357 <SelectTrigger>
358 <SelectValue placeholder="Select an option" />
359 </SelectTrigger>
360 <SelectContent>
361 <SelectItem value="custom">Create a new schema</SelectItem>
362 <SelectSeparator />
363 {(schemas ?? [])?.map((schema) => {
364 return (
365 <SelectItem key={schema.name} value={schema.name}>
366 {schema.name}
367 </SelectItem>
368 )
369 })}
370 </SelectContent>
371 </Select>
372 </FormControl>
373 </FormItemLayout>
374 )}
375 />
376 {schema === 'custom' && (
377 <FormField
378 control={form.control}
379 name="schema_name"
380 render={({ field }) => (
381 <FormItemLayout name="schema_name" layout="vertical" label="Schema name">
382 <FormControl>
383 <Input {...field} id="schema_name" />
384 </FormControl>
385 </FormItemLayout>
386 )}
387 />
388 )}
389
390 <FormField
391 control={form.control}
392 name="table_name"
393 render={({ field }) => (
394 <FormItemLayout
395 layout="vertical"
396 name="table_name"
397 label="Table name"
398 description="You can query from this table after the wrapper is enabled."
399 >
400 <FormControl>
401 <Input {...field} id="table_name" />
402 </FormControl>
403 </FormItemLayout>
404 )}
405 />
406 {requiredOptions.map((option) => (
407 <Option key={option.name} option={option} control={form.control} />
408 ))}
409 {nonEditableOptions.map((option) => (
410 <input key={option.name} type="hidden" {...form.register(option.name)} />
411 ))}
412 {table.availableColumns != null ? (
413 <FormField
414 control={form.control}
415 name="selected_columns"
416 render={() => (
417 <FormItemLayout
418 layout="vertical"
419 label="Select the columns to be added to your table."
420 >
421 <div>
422 <MultiSelector
423 onValuesChange={(selectedColumns) => {
424 const newColumnFieldsValue: AvailableColumn[] = []
425
426 table.availableColumns!.forEach((availableColumn) => {
427 if (selectedColumns.includes(availableColumn.name)) {
428 newColumnFieldsValue.push(availableColumn)
429 }
430 })
431 replaceColumns(newColumnFieldsValue)
432 }}
433 values={columnFields.map(
434 (column) =>
435 // @ts-expect-error FIXME: cannot make inference work properly
436 column.name
437 )}
438 size="small"
439 className="w-full"
440 >
441 <MultiSelectorTrigger
442 mode="inline-combobox"
443 badgeLimit="wrap"
444 showIcon={false}
445 deletableBadge
446 className="w-full min-w-lg!"
447 />
448 <MultiSelectorContent>
449 <MultiSelectorList>
450 {table.availableColumns!.map((availableColumn) => (
451 <MultiSelectorItem
452 key={availableColumn.name}
453 value={availableColumn.name}
454 >
455 {availableColumn.name}
456 </MultiSelectorItem>
457 ))}
458 </MultiSelectorList>
459 </MultiSelectorContent>
460 </MultiSelector>
461 </div>
462 </FormItemLayout>
463 )}
464 />
465 ) : (
466 <div className="flex flex-col gap-y-2">
467 {columnFields.map((column, columnIndex) => (
468 <div key={column.id} className="flex items-center gap-x-2">
469 <FormField
470 control={form.control}
471 name={`columns.${columnIndex}.name`}
472 render={({ field }) => (
473 <FormItemLayout
474 layout="vertical"
475 name={`columns.${columnIndex}.name`}
476 label="Name"
477 >
478 <FormControl>
479 <Input {...field} id={`columns.${columnIndex}.name`} />
480 </FormControl>
481 </FormItemLayout>
482 )}
483 />
484 <ColumnType
485 control={form.control}
486 className="w-1/2"
487 name={`columns.${columnIndex}.type`}
488 enumTypes={[]}
489 />
490 <Button
491 type="outline"
492 icon={<XIcon strokeWidth={1.5} />}
493 onClick={() => removeColumn(columnIndex)}
494 className="self-end -translate-y-1.5 px-1.5"
495 // @ts-expect-error FIXME: cannot make inference work
496 aria-label={`Remove column ${column.name}`}
497 />
498 </div>
499 ))}
500 <Button
501 type="default"
502 onClick={() => appendColumn({ name: '', type: 'text' })}
503 className="self-start"
504 >
505 Add column
506 </Button>
507 {errors.columns != null && errors.columns.message != null && (
508 <span className="text-red-900 text-sm mt-2">{errors.columns.message.toString()}</span>
509 )}
510 </div>
511 )}
512
513 {optionalOptions.map((option) => (
514 <Option key={option.name} option={option} control={form.control} />
515 ))}
516 </form>
517 </Form>
518 )
519}