CreateTableSheet.tsx364 lines · main
| 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 | |
| 33 | import { COLUMN_TYPE_FIELDS, COLUMN_TYPES } from './CreateTableSheet.constants' |
| 34 | import { createFormSchema } from './CreateTableSheet.schema' |
| 35 | import { useIcebergNamespaceCreateMutation } from '@/data/storage/iceberg-namespace-create-mutation' |
| 36 | import { |
| 37 | NamespaceTableFields, |
| 38 | useIcebergNamespaceTableCreateMutation, |
| 39 | } from '@/data/storage/iceberg-namespace-table-create-mutation' |
| 40 | import { useIcebergNamespaceTablesQuery } from '@/data/storage/iceberg-namespace-tables-query' |
| 41 | import { useIcebergNamespacesQuery } from '@/data/storage/iceberg-namespaces-query' |
| 42 | |
| 43 | const formId = 'create-namespace-table' |
| 44 | const NEW_NAMESPACE_MARKER = 'new-namespace' |
| 45 | |
| 46 | interface CreateTableSheetProps { |
| 47 | open: boolean |
| 48 | onOpenChange: (value: boolean) => void |
| 49 | } |
| 50 | |
| 51 | export 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 | } |