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