CreateTableSheet.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. import { zodResolver } from '@hookform/resolvers/zod'
  2. import { useParams } from 'common'
  3. import { Plus, X } from 'lucide-react'
  4. import { Fragment, useState } from 'react'
  5. import { SubmitHandler, useFieldArray, useForm } from 'react-hook-form'
  6. import { toast } from 'sonner'
  7. import {
  8. Button,
  9. DialogSectionSeparator,
  10. Form,
  11. FormControl,
  12. FormField,
  13. FormInputGroupInput,
  14. Input,
  15. InputGroup,
  16. InputGroupAddon,
  17. Select,
  18. SelectContent,
  19. SelectItem,
  20. SelectSeparator,
  21. SelectTrigger,
  22. SelectValue,
  23. Sheet,
  24. SheetContent,
  25. SheetFooter,
  26. SheetHeader,
  27. SheetSection,
  28. SheetTitle,
  29. } from 'ui'
  30. import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
  31. import { z } from 'zod'
  32. import { COLUMN_TYPE_FIELDS, COLUMN_TYPES } from './CreateTableSheet.constants'
  33. import { createFormSchema } from './CreateTableSheet.schema'
  34. import { useIcebergNamespaceCreateMutation } from '@/data/storage/iceberg-namespace-create-mutation'
  35. import {
  36. NamespaceTableFields,
  37. useIcebergNamespaceTableCreateMutation,
  38. } from '@/data/storage/iceberg-namespace-table-create-mutation'
  39. import { useIcebergNamespaceTablesQuery } from '@/data/storage/iceberg-namespace-tables-query'
  40. import { useIcebergNamespacesQuery } from '@/data/storage/iceberg-namespaces-query'
  41. const formId = 'create-namespace-table'
  42. const NEW_NAMESPACE_MARKER = 'new-namespace'
  43. interface CreateTableSheetProps {
  44. open: boolean
  45. onOpenChange: (value: boolean) => void
  46. }
  47. export const CreateTableSheet = ({ open, onOpenChange }: CreateTableSheetProps) => {
  48. const { ref: projectRef, bucketId } = useParams()
  49. const [isCreating, setIsCreating] = useState(false)
  50. const FormSchema = createFormSchema()
  51. const defaultValues = {
  52. namespace: '',
  53. newNamespace: undefined,
  54. name: '',
  55. columns: [{ name: '', type: 'string' as any }],
  56. }
  57. const form = useForm<z.infer<typeof FormSchema>>({
  58. resolver: zodResolver(FormSchema as any),
  59. defaultValues,
  60. mode: 'onChange',
  61. })
  62. const { namespace } = form.watch()
  63. const {
  64. fields: columns,
  65. append: appendColumn,
  66. remove: removeColumn,
  67. } = useFieldArray({ control: form.control, name: 'columns' })
  68. const { data: namespaces = [] } = useIcebergNamespacesQuery({ projectRef, warehouse: bucketId })
  69. const { data: tables = [] } = useIcebergNamespaceTablesQuery(
  70. {
  71. projectRef,
  72. warehouse: bucketId,
  73. namespace,
  74. },
  75. { enabled: namespace !== NEW_NAMESPACE_MARKER }
  76. )
  77. const { mutateAsync: createNamespace } = useIcebergNamespaceCreateMutation()
  78. const { mutateAsync: createTable } = useIcebergNamespaceTableCreateMutation()
  79. const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = async (values) => {
  80. if (!bucketId) return console.error('Bucket ID is missing')
  81. if (namespaces.includes(values.newNamespace ?? '')) {
  82. return form.setError('newNamespace', { message: 'Namespace name already exists' })
  83. }
  84. if (tables.includes(values.name ?? '')) {
  85. return form.setError('name', { message: 'Table name already exists' })
  86. }
  87. const isCreatingNewNamespace =
  88. values.namespace === NEW_NAMESPACE_MARKER && !!values.newNamespace
  89. try {
  90. setIsCreating(true)
  91. if (isCreatingNewNamespace) {
  92. await createNamespace({
  93. projectRef,
  94. warehouse: bucketId,
  95. namespace: values.newNamespace as string,
  96. })
  97. }
  98. const fields = values.columns.map((column, idx) => {
  99. return {
  100. id: idx + 1,
  101. name: column.name,
  102. type:
  103. column.type === 'decimal'
  104. ? `decimal(${column.precision}, ${column.scale})`
  105. : column.type === 'fixed'
  106. ? `fixed[${column.length}]`
  107. : column.type,
  108. required: false,
  109. }
  110. }) as NamespaceTableFields
  111. await createTable({
  112. projectRef,
  113. warehouse: bucketId,
  114. namespace: isCreatingNewNamespace ? (values.newNamespace as string) : values.namespace,
  115. name: values.name,
  116. fields,
  117. })
  118. toast.success(`Successfully created table in ${values.newNamespace ?? values.namespace}!`)
  119. onOpenChange(false)
  120. form.reset(defaultValues)
  121. } catch (error) {
  122. } finally {
  123. setIsCreating(false)
  124. }
  125. }
  126. return (
  127. <Sheet open={open} onOpenChange={onOpenChange}>
  128. <Form {...form}>
  129. <form id={formId} className="flex flex-col gap-4" onSubmit={form.handleSubmit(onSubmit)}>
  130. <SheetContent aria-describedby={undefined} className="flex flex-col gap-0">
  131. <SheetHeader className="shrink-0 flex items-center gap-4">
  132. <SheetTitle>Create a new table</SheetTitle>
  133. </SheetHeader>
  134. <SheetSection className="overflow-auto grow p-0">
  135. <div className="flex flex-col gap-y-4 py-4 px-5">
  136. <FormField
  137. name="namespace"
  138. control={form.control}
  139. render={({ field }) => (
  140. <FormItemLayout
  141. name="namespace"
  142. label="Select a namespace to create your table in"
  143. >
  144. <FormControl>
  145. <Select
  146. value={field.value}
  147. onValueChange={(value) => {
  148. field.onChange(value)
  149. form.resetField('newNamespace')
  150. }}
  151. >
  152. <SelectTrigger>
  153. <SelectValue placeholder="Select a namespace" />
  154. </SelectTrigger>
  155. <SelectContent>
  156. {namespaces.map((x) => (
  157. <SelectItem key={x} value={x}>
  158. {x}
  159. </SelectItem>
  160. ))}
  161. {namespaces.length > 0 && <SelectSeparator />}
  162. <SelectItem value={NEW_NAMESPACE_MARKER}>
  163. <div className="flex items-center gap-x-2">
  164. <Plus size={14} />
  165. <p>Create a new namespace</p>
  166. </div>
  167. </SelectItem>
  168. </SelectContent>
  169. </Select>
  170. </FormControl>
  171. </FormItemLayout>
  172. )}
  173. />
  174. {namespace === NEW_NAMESPACE_MARKER && (
  175. <FormField
  176. name="newNamespace"
  177. control={form.control}
  178. render={({ field }) => (
  179. <FormItemLayout name="newNamespace" label="Name of new namespace">
  180. <FormControl>
  181. <Input {...field} placeholder="Provide a name for your new namespace" />
  182. </FormControl>
  183. </FormItemLayout>
  184. )}
  185. />
  186. )}
  187. </div>
  188. <DialogSectionSeparator />
  189. {!!namespace && (
  190. <div className="px-5 py-4 flex flex-col gap-y-4">
  191. <FormField
  192. name="name"
  193. control={form.control}
  194. render={({ field }) => (
  195. <FormItemLayout name="name" label="Name of table">
  196. <FormControl>
  197. <Input {...field} placeholder="Provide a name for your new table" />
  198. </FormControl>
  199. </FormItemLayout>
  200. )}
  201. />
  202. <div className="flex flex-col gap-y-2">
  203. <div className="flex items-center justify-between">
  204. <p className="text-sm">Columns</p>
  205. <Button
  206. type="default"
  207. icon={<Plus />}
  208. onClick={() => appendColumn({ name: '', type: 'string' })}
  209. >
  210. Add column
  211. </Button>
  212. </div>
  213. {columns.length === 0 ? (
  214. <div className="flex items-center justify-center rounded-sm border border-strong border-dashed py-4 text-foreground-lighter text-sm">
  215. Add a column to your table
  216. </div>
  217. ) : (
  218. <>
  219. <div className="grid grid-cols-[1fr_1fr_32px]">
  220. <p className="text-xs text-foreground-lighter">Name</p>
  221. <p className="text-xs text-foreground-lighter">Type</p>
  222. </div>
  223. {columns.map((_, idx) => {
  224. const columnType = form.watch(`columns.${idx}.type`)
  225. const additionalFields =
  226. COLUMN_TYPE_FIELDS[columnType as keyof typeof COLUMN_TYPE_FIELDS] ?? []
  227. return (
  228. <Fragment key={`column-${idx}`}>
  229. <div className="grid grid-cols-[1fr_1fr_32px] gap-x-1">
  230. <FormField
  231. control={form.control}
  232. name={`columns.${idx}.name`}
  233. render={({ field }) => (
  234. <FormItemLayout>
  235. <FormControl>
  236. <Input
  237. {...field}
  238. placeholder="Provide a column name"
  239. disabled={isCreating}
  240. className="h-auto"
  241. />
  242. </FormControl>
  243. </FormItemLayout>
  244. )}
  245. />
  246. <FormField
  247. control={form.control}
  248. name={`columns.${idx}.type`}
  249. render={({ field }) => (
  250. <FormControl>
  251. <Select value={field.value} onValueChange={field.onChange}>
  252. <SelectTrigger className="h-auto">
  253. <SelectValue placeholder="Select a type" />
  254. </SelectTrigger>
  255. <SelectContent>
  256. {COLUMN_TYPES.map((x) => (
  257. <SelectItem key={x} value={x}>
  258. {x}
  259. </SelectItem>
  260. ))}
  261. </SelectContent>
  262. </Select>
  263. </FormControl>
  264. )}
  265. />
  266. <div className="flex items-center justify-center">
  267. <Button
  268. type="text"
  269. size="tiny"
  270. icon={<X strokeWidth={1.5} size={14} />}
  271. className="w-6 h-6"
  272. onClick={() => removeColumn(idx)}
  273. />
  274. </div>
  275. {additionalFields.length > 0 && (
  276. <div className="col-span-full flex items-center mt-2">
  277. <div className="flex items-center justify-end gap-1 w-[85%] ">
  278. {additionalFields.map((x) => (
  279. <FormField
  280. control={form.control}
  281. key={`columns.${idx}.${x.name}`}
  282. name={`columns.${idx}.${x.name}` as any}
  283. render={({ field }) => (
  284. <FormItemLayout>
  285. <FormControl>
  286. <InputGroup>
  287. <InputGroupAddon align="inline-start">
  288. {x.name}
  289. </InputGroupAddon>
  290. <FormInputGroupInput
  291. {...field}
  292. type={x.type === 'number' ? 'number' : 'text'}
  293. disabled={isCreating}
  294. className="h-[34px] rounded-l-none"
  295. onChange={(event) =>
  296. field.onChange(
  297. isNaN(event.target.valueAsNumber)
  298. ? null
  299. : event.target.valueAsNumber
  300. )
  301. }
  302. />
  303. </InputGroup>
  304. </FormControl>
  305. </FormItemLayout>
  306. )}
  307. />
  308. ))}
  309. </div>
  310. <div className="w-4 h-[1.6rem] border-r border-b rounded-br mr-3 border-control -translate-y-3" />
  311. </div>
  312. )}
  313. </div>
  314. </Fragment>
  315. )
  316. })}
  317. </>
  318. )}
  319. </div>
  320. </div>
  321. )}
  322. </SheetSection>
  323. <SheetFooter>
  324. <Button
  325. disabled={isCreating}
  326. type="default"
  327. onClick={() => {
  328. onOpenChange(false)
  329. form.reset(defaultValues)
  330. }}
  331. >
  332. Cancel
  333. </Button>
  334. <Button form={formId} htmlType="submit" loading={isCreating}>
  335. Create table
  336. </Button>
  337. </SheetFooter>
  338. </SheetContent>
  339. </form>
  340. </Form>
  341. </Sheet>
  342. )
  343. }