WrapperTableEditor.tsx 17 KB

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