This is an automated email from the ASF dual-hosted git repository.

jbonofre pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/polaris-tools.git


The following commit(s) were added to refs/heads/main by this push:
     new b8d20e1  feat(console): implement Iceberg table creation with schema 
editor (#167)
b8d20e1 is described below

commit b8d20e12e1d6b996553c386f4f4b387120262cc9
Author: Vignesh Nayak Manel <[email protected]>
AuthorDate: Mon Mar 9 22:57:33 2026 +0530

    feat(console): implement Iceberg table creation with schema editor (#167)
    
    * feat(console): implement Iceberg table creation with comprehensive schema 
editor
    
    * fixed lint issues
    
    * fixed lint issues
    
    * fix build issues
---
 console/src/components/forms/CreateTableModal.tsx | 1211 +++++++++++++++++++++
 console/src/pages/NamespaceDetails.tsx            |   19 +-
 2 files changed, 1226 insertions(+), 4 deletions(-)

diff --git a/console/src/components/forms/CreateTableModal.tsx 
b/console/src/components/forms/CreateTableModal.tsx
new file mode 100644
index 0000000..f6190a0
--- /dev/null
+++ b/console/src/components/forms/CreateTableModal.tsx
@@ -0,0 +1,1211 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { useEffect, useState } from "react"
+import { useForm } from "react-hook-form"
+import { z } from "zod"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useMutation } from "@tanstack/react-query"
+import { toast } from "sonner"
+import { tablesApi } from "@/api/catalog/tables"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Textarea } from "@/components/ui/textarea"
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Plus, X } from "lucide-react"
+import type { CreateTableRequest, SchemaField } from "@/types/api"
+
+const COMPLEX_TYPES = ["struct", "list", "map"] as const
+
+type ComplexType = (typeof COMPLEX_TYPES)[number]
+type FieldTypeCategory = "primitive" | "complex"
+
+interface SchemaFieldState extends Omit<SchemaField, "type"> {
+  type: string | FieldTypeObject
+  typeCategory: FieldTypeCategory
+  // For parameterized types
+  decimalPrecision?: number
+  decimalScale?: number
+  fixedLength?: number
+  // For complex types
+  nestedFields?: SchemaFieldState[] // for struct
+  elementType?: string | FieldTypeObject // for list
+  elementRequired?: boolean
+  elementId?: number
+  keyType?: string | FieldTypeObject // for map
+  valueType?: string | FieldTypeObject
+  keyRequired?: boolean
+  valueRequired?: boolean
+  keyId?: number
+  valueId?: number
+  // UI state
+  expanded?: boolean
+}
+
+interface FieldTypeObject {
+  type: string
+  fields?: Array<SchemaFieldState | SchemaField>
+  [key: string]: unknown
+}
+
+const schema = z.object({
+  name: z
+    .string()
+    .min(1, "Table name is required")
+    .regex(
+      /^[a-zA-Z_][a-zA-Z0-9_]*$/,
+      "Table name must start with a letter or underscore and contain only 
alphanumeric characters and underscores"
+    ),
+  location: z
+    .string()
+    .optional()
+    .refine(
+      (val) => {
+        if (!val || val.trim() === "") return true
+        const urlPattern = /^(s3|https?|file|gs|azure|abfss?):\/\//i
+        const absolutePathPattern = /^\/[^/]/
+        return urlPattern.test(val.trim()) || 
absolutePathPattern.test(val.trim())
+      },
+      {
+        message: "Location must be a valid URL (s3://, https://, file://, 
etc.) or absolute path",
+      }
+    ),
+  schemaJson: z.string().optional(),
+  properties: z
+    .array(
+      z.object({
+        key: z.string().min(1, "Key is required"),
+        value: z.string(),
+      })
+    )
+    .optional(),
+})
+
+type FormValues = z.infer<typeof schema>
+
+interface CreateTableModalProps {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  catalogName: string
+  namespace: string[]
+  onCreated?: () => void
+}
+
+export function CreateTableModal({
+  open,
+  onOpenChange,
+  catalogName,
+  namespace,
+  onCreated,
+}: CreateTableModalProps) {
+  const [schemaMode, setSchemaMode] = useState<"manual" | "json">("manual")
+  const [fields, setFields] = useState<SchemaFieldState[]>([])
+  const [nextFieldId, setNextFieldId] = useState(1)
+  const [properties, setProperties] = useState<Array<{ key: string; value: 
string }>>([])
+  const [partitionFields, setPartitionFields] = useState<
+    Array<{ sourceColumn: string; transform: string }>
+  >([])
+
+  const {
+    register,
+    handleSubmit,
+    reset,
+    formState: { errors },
+    setValue,
+    watch,
+  } = useForm<FormValues>({
+    resolver: zodResolver(schema),
+    defaultValues: {
+      name: "",
+      location: "",
+      schemaJson: "",
+      properties: [],
+    },
+  })
+
+  const schemaJson = watch("schemaJson")
+
+  // Helper function to get next available ID recursively
+  const getNextId = (currentId: number, fieldsToCheck: SchemaFieldState[]): 
number => {
+    let maxId = currentId
+
+    for (const field of fieldsToCheck) {
+      if (field.id > maxId) maxId = field.id
+      if (field.elementId && field.elementId > maxId) maxId = field.elementId
+      if (field.keyId && field.keyId > maxId) maxId = field.keyId
+      if (field.valueId && field.valueId > maxId) maxId = field.valueId
+
+      if (field.nestedFields && field.nestedFields.length > 0) {
+        maxId = getNextId(maxId, field.nestedFields)
+      }
+    }
+
+    return maxId + 1
+  }
+
+  // Add a new field to the schema
+  const addField = (
+    parentFields?: SchemaFieldState[],
+    parentSetter?: (fields: SchemaFieldState[]) => void
+  ) => {
+    const targetFields = parentFields !== undefined ? parentFields : fields
+    const setter = parentSetter !== undefined ? parentSetter : setFields
+
+    const newField: SchemaFieldState = {
+      id: nextFieldId,
+      name: "",
+      type: "string",
+      typeCategory: "primitive",
+      required: true,
+    }
+
+    setter([...targetFields, newField])
+    setNextFieldId(nextFieldId + 1)
+  }
+
+  // Remove a field from the schema
+  const removeField = (
+    index: number,
+    parentFields?: SchemaFieldState[],
+    parentSetter?: (fields: SchemaFieldState[]) => void
+  ) => {
+    const targetFields = parentFields !== undefined ? parentFields : fields
+    const setter = parentSetter !== undefined ? parentSetter : setFields
+
+    setter(targetFields.filter((_, i) => i !== index))
+  }
+
+  const updateField = (
+    index: number,
+    updates: Partial<SchemaFieldState>,
+    parentFields?: SchemaFieldState[],
+    parentSetter?: (fields: SchemaFieldState[]) => void
+  ) => {
+    const targetFields = parentFields !== undefined ? parentFields : fields
+    const setter = parentSetter !== undefined ? parentSetter : setFields
+
+    const updated = [...targetFields]
+    updated[index] = { ...updated[index], ...updates }
+    setter(updated)
+  }
+
+  const changeFieldType = (
+    index: number,
+    newType: string,
+    parentFields?: SchemaFieldState[],
+    parentSetter?: (fields: SchemaFieldState[]) => void
+  ) => {
+    const targetFields = parentFields !== undefined ? parentFields : fields
+    const setter = parentSetter !== undefined ? parentSetter : setFields
+
+    const updated = [...targetFields]
+    const field = updated[index]
+
+    if (COMPLEX_TYPES.includes(newType as ComplexType)) {
+      field.typeCategory = "complex"
+      field.type = newType
+
+      // Initialize complex type properties
+      if (newType === "struct") {
+        field.nestedFields = []
+      } else if (newType === "list") {
+        field.elementType = "string"
+        field.elementRequired = true
+        field.elementId = nextFieldId
+        setNextFieldId(nextFieldId + 1)
+      } else if (newType === "map") {
+        field.keyType = "string"
+        field.valueType = "string"
+        field.valueRequired = true
+        field.keyId = nextFieldId
+        field.valueId = nextFieldId + 1
+        setNextFieldId(nextFieldId + 2)
+      }
+    } else {
+      field.typeCategory = "primitive"
+      field.type = newType
+
+      // Clear complex type properties
+      delete field.nestedFields
+      delete field.elementType
+      delete field.elementRequired
+      delete field.elementId
+      delete field.keyType
+      delete field.valueType
+      delete field.keyRequired
+      delete field.valueRequired
+      delete field.keyId
+      delete field.valueId
+    }
+
+    setter(updated)
+  }
+
+  const convertFieldToApi = (field: SchemaFieldState): SchemaField => {
+    let fieldType: string | FieldTypeObject
+
+    if (field.typeCategory === "complex") {
+      if (field.type === "struct" && field.nestedFields) {
+        fieldType = {
+          type: "struct",
+          fields: field.nestedFields.map(convertFieldToApi),
+        }
+      } else if (field.type === "list" && field.elementType) {
+        let elementType: string | FieldTypeObject
+
+        if (typeof field.elementType === "object") {
+          if (field.elementType.type === "struct" && field.elementType.fields) 
{
+            elementType = {
+              type: "struct",
+              fields: (field.elementType.fields as 
SchemaFieldState[]).map(convertFieldToApi),
+            }
+          } else {
+            elementType = field.elementType
+          }
+        } else {
+          elementType = field.elementType
+        }
+
+        fieldType = {
+          type: "list",
+          "element-id": field.elementId!,
+          element: elementType,
+          "element-required": field.elementRequired ?? true,
+        }
+      } else if (field.type === "map" && field.keyType && field.valueType) {
+        fieldType = {
+          type: "map",
+          "key-id": field.keyId!,
+          key: field.keyType,
+          "value-id": field.valueId!,
+          value: field.valueType,
+          "value-required": field.valueRequired ?? true,
+        }
+      } else {
+        fieldType = String(field.type)
+      }
+    } else {
+      // Handle parameterized primitives
+      if (field.type === "decimal" && field.decimalPrecision && 
field.decimalScale !== undefined) {
+        fieldType = `decimal(${field.decimalPrecision},${field.decimalScale})`
+      } else if (field.type === "fixed" && field.fixedLength) {
+        fieldType = `fixed[${field.fixedLength}]`
+      } else {
+        fieldType = String(field.type)
+      }
+    }
+
+    return {
+      id: field.id,
+      name: field.name,
+      type: fieldType,
+      required: field.required,
+      ...(field.comment && { comment: field.comment }),
+    }
+  }
+
+  const parseJsonSchema = () => {
+    if (!schemaJson) {
+      toast.error("Please provide a JSON schema")
+      return
+    }
+
+    try {
+      const parsed = JSON.parse(schemaJson)
+
+      if (!parsed.type || parsed.type !== "struct") {
+        toast.error("Schema must be a struct type with a fields array")
+        return
+      }
+
+      if (!Array.isArray(parsed.fields)) {
+        toast.error("Schema must have a fields array")
+        return
+      }
+
+      // Convert parsed JSON to SchemaFieldState
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      const convertFromJson = (jsonField: any): SchemaFieldState => {
+        let fieldType: string
+        let typeCategory: FieldTypeCategory = "primitive"
+        let decimalPrecision: number | undefined
+        let decimalScale: number | undefined
+        let fixedLength: number | undefined
+
+        // Parse the type
+        if (typeof jsonField.type === "string") {
+          fieldType = jsonField.type
+
+          // Check for parameterized types
+          // decimal(10,2)
+          const decimalMatch = fieldType.match(/^decimal\((\d+),(\d+)\)$/)
+          if (decimalMatch) {
+            fieldType = "decimal"
+            decimalPrecision = parseInt(decimalMatch[1])
+            decimalScale = parseInt(decimalMatch[2])
+          }
+
+          // fixed[16]
+          const fixedMatch = fieldType.match(/^fixed\[(\d+)\]$/)
+          if (fixedMatch) {
+            fieldType = "fixed"
+            fixedLength = parseInt(fixedMatch[1])
+          }
+        } else {
+          // Complex type
+          fieldType = jsonField.type.type
+          typeCategory = "complex"
+        }
+
+        const baseField: SchemaFieldState = {
+          id: jsonField.id,
+          name: jsonField.name,
+          type: fieldType,
+          typeCategory,
+          required: jsonField.required,
+          comment: jsonField.doc || jsonField.comment,
+          ...(decimalPrecision !== undefined && { decimalPrecision }),
+          ...(decimalScale !== undefined && { decimalScale }),
+          ...(fixedLength !== undefined && { fixedLength }),
+        }
+
+        if (typeof jsonField.type === "object") {
+          if (jsonField.type.type === "struct") {
+            baseField.nestedFields = jsonField.type.fields.map(convertFromJson)
+          } else if (jsonField.type.type === "list") {
+            baseField.elementType = jsonField.type.element
+            baseField.elementRequired = jsonField.type["element-required"]
+            baseField.elementId = jsonField.type["element-id"]
+          } else if (jsonField.type.type === "map") {
+            baseField.keyType = jsonField.type.key
+            baseField.valueType = jsonField.type.value
+            baseField.valueRequired = jsonField.type["value-required"]
+            baseField.keyId = jsonField.type["key-id"]
+            baseField.valueId = jsonField.type["value-id"]
+          }
+        }
+
+        return baseField
+      }
+
+      const parsedFields = parsed.fields.map(convertFromJson)
+      setFields(parsedFields)
+
+      // Update next field ID
+      const maxId = getNextId(0, parsedFields)
+      setNextFieldId(maxId)
+
+      toast.success("Schema imported successfully")
+      setSchemaMode("manual")
+    } catch (error) {
+      toast.error("Invalid JSON schema", {
+        description: error instanceof Error ? error.message : "Failed to parse 
JSON",
+      })
+    }
+  }
+
+  const createMutation = useMutation({
+    mutationFn: async (values: FormValues) => {
+      let schemaFields: SchemaField[]
+
+      if (schemaMode === "json" && values.schemaJson) {
+        const parsed = JSON.parse(values.schemaJson)
+        schemaFields = parsed.fields
+      } else {
+        if (fields.length === 0) {
+          throw new Error("At least one schema field is required")
+        }
+        schemaFields = fields.map(convertFieldToApi)
+      }
+
+      const request: CreateTableRequest = {
+        name: values.name,
+        schema: {
+          type: "struct",
+          fields: schemaFields,
+        },
+        properties: {},
+      }
+
+      if (values.location && values.location.trim()) {
+        request.properties!.location = values.location.trim()
+      }
+
+      // Add custom properties
+      if (values.properties && values.properties.length > 0) {
+        values.properties.forEach((prop) => {
+          if (prop.key.trim()) {
+            request.properties![prop.key.trim()] = prop.value || ""
+          }
+        })
+      }
+
+      // Add partition spec if configured
+      if (partitionFields.length > 0) {
+        request.partitionSpec = {
+          fields: partitionFields.map((pf, idx) => ({
+            "source-id": fields.find((f) => f.name === pf.sourceColumn)?.id || 
0,
+            "field-id": 1000 + idx,
+            name: `${pf.sourceColumn}_${pf.transform}`,
+            transform: pf.transform,
+          })),
+        }
+      }
+
+      return tablesApi.create(catalogName, namespace, request)
+    },
+    onSuccess: () => {
+      toast.success("Table created successfully")
+      onOpenChange(false)
+      reset()
+      setFields([])
+      setProperties([])
+      setPartitionFields([])
+      setNextFieldId(1)
+      onCreated?.()
+    },
+    onError: (error: Error) => {
+      toast.error("Failed to create table", {
+        description: error.message || "An error occurred",
+      })
+    },
+  })
+
+  const onSubmit = (values: FormValues) => {
+    createMutation.mutate(values)
+  }
+
+  useEffect(() => {
+    if (!open) {
+      reset()
+      setFields([])
+      setProperties([])
+      setPartitionFields([])
+      setNextFieldId(1)
+      setSchemaMode("manual")
+    }
+  }, [open, reset])
+
+  useEffect(() => {
+    const nonEmpty = properties.filter((p) => p.key.trim().length > 0)
+    setValue("properties", nonEmpty, { shouldValidate: true })
+  }, [properties, setValue])
+
+  const addProperty = () => {
+    setProperties([...properties, { key: "", value: "" }])
+  }
+
+  const removeProperty = (index: number) => {
+    setProperties(properties.filter((_, i) => i !== index))
+  }
+
+  const updateProperty = (index: number, field: "key" | "value", value: 
string) => {
+    const updated = [...properties]
+    updated[index] = { ...updated[index], [field]: value }
+    setProperties(updated)
+  }
+
+  const addPartitionField = () => {
+    setPartitionFields([...partitionFields, { sourceColumn: "", transform: 
"identity" }])
+  }
+
+  const removePartitionField = (index: number) => {
+    setPartitionFields(partitionFields.filter((_, i) => i !== index))
+  }
+
+  const updatePartitionField = (
+    index: number,
+    field: "sourceColumn" | "transform",
+    value: string
+  ) => {
+    const updated = [...partitionFields]
+    updated[index] = { ...updated[index], [field]: value }
+    setPartitionFields(updated)
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
+        <DialogHeader>
+          <DialogTitle>Create Iceberg Table</DialogTitle>
+          <DialogDescription>
+            Create a new Iceberg table in the namespace 
"{namespace.join(".")}".
+          </DialogDescription>
+        </DialogHeader>
+
+        <form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
+          {/* Basic Fields */}
+          <div className="space-y-4">
+            <div className="space-y-2">
+              <Label htmlFor="name">Table Name *</Label>
+              <Input id="name" placeholder="my_table" {...register("name")} />
+              {errors.name && <p className="text-sm 
text-red-600">{errors.name.message}</p>}
+            </div>
+
+            <div className="space-y-2">
+              <Label htmlFor="location">Location (optional)</Label>
+              <Input
+                id="location"
+                placeholder="s3://bucket/path/to/table"
+                {...register("location")}
+              />
+              {errors.location && <p className="text-sm 
text-red-600">{errors.location.message}</p>}
+              <p className="text-xs text-muted-foreground">
+                Storage location for this table. Must be within the catalog's 
allowed locations.
+              </p>
+            </div>
+          </div>
+
+          {/* Schema Definition */}
+          <div className="space-y-2">
+            <Label>Table Schema *</Label>
+            <Tabs value={schemaMode} onValueChange={(v) => setSchemaMode(v as 
"manual" | "json")}>
+              <TabsList className="grid w-full grid-cols-2">
+                <TabsTrigger value="manual">Manual Editor</TabsTrigger>
+                <TabsTrigger value="json">JSON Schema</TabsTrigger>
+              </TabsList>
+
+              <TabsContent value="manual" className="space-y-4 mt-4">
+                <div className="space-y-2">
+                  <div className="flex items-center justify-between">
+                    <p className="text-sm text-muted-foreground">
+                      Define your table schema by adding fields
+                    </p>
+                    <Button type="button" variant="outline" size="sm" 
onClick={() => addField()}>
+                      <Plus className="h-4 w-4 mr-1" />
+                      Add Field
+                    </Button>
+                  </div>
+
+                  {fields.length === 0 ? (
+                    <div className="border border-dashed rounded-lg p-8 
text-center text-muted-foreground">
+                      No fields defined. Click "Add Field" to start building 
your schema.
+                    </div>
+                  ) : (
+                    <SchemaFieldEditor
+                      fields={fields}
+                      setFields={setFields}
+                      updateField={updateField}
+                      removeField={removeField}
+                      changeFieldType={changeFieldType}
+                      addField={addField}
+                      nextFieldId={nextFieldId}
+                      setNextFieldId={setNextFieldId}
+                    />
+                  )}
+                </div>
+              </TabsContent>
+
+              <TabsContent value="json" className="space-y-4 mt-4">
+                <div className="space-y-2">
+                  <Label htmlFor="schemaJson">Schema JSON</Label>
+                  <Textarea
+                    id="schemaJson"
+                    placeholder={`{
+  "type": "struct",
+  "fields": [
+    {
+      "id": 1,
+      "name": "id",
+      "type": "long",
+      "required": true
+    },
+    {
+      "id": 2,
+      "name": "name",
+      "type": "string",
+      "required": true
+    }
+  ]
+}`}
+                    className="min-h-[300px] font-mono text-sm"
+                    {...register("schemaJson")}
+                  />
+                  <p className="text-xs text-muted-foreground">
+                    Paste your Iceberg table schema in JSON format
+                  </p>
+                  <Button type="button" variant="secondary" 
onClick={parseJsonSchema}>
+                    Import & Validate
+                  </Button>
+                </div>
+              </TabsContent>
+            </Tabs>
+          </div>
+
+          {/* Advanced Options */}
+          <Card>
+            <CardHeader>
+              <CardTitle className="text-base">Advanced Options</CardTitle>
+            </CardHeader>
+            <CardContent className="space-y-4">
+              {/* Partition Spec */}
+              <div className="space-y-2">
+                <div className="flex items-center justify-between">
+                  <Label>Partition Spec (optional)</Label>
+                  <Button type="button" variant="outline" size="sm" 
onClick={addPartitionField}>
+                    <Plus className="h-3 w-3 mr-1" />
+                    Add Partition
+                  </Button>
+                </div>
+                {partitionFields.length === 0 ? (
+                  <p className="text-xs text-muted-foreground">
+                    No partitions configured. Table will be unpartitioned.
+                  </p>
+                ) : (
+                  <div className="space-y-2">
+                    {partitionFields.map((pf, index) => (
+                      <div key={index} className="flex gap-2 items-start">
+                        <Select
+                          value={pf.sourceColumn}
+                          onValueChange={(value) =>
+                            updatePartitionField(index, "sourceColumn", value)
+                          }
+                        >
+                          <SelectTrigger className="flex-1">
+                            <SelectValue placeholder="Select column" />
+                          </SelectTrigger>
+                          <SelectContent>
+                            {fields.map((field) => (
+                              <SelectItem key={field.id} value={field.name}>
+                                {field.name || `field_${field.id}`}
+                              </SelectItem>
+                            ))}
+                          </SelectContent>
+                        </Select>
+                        <Select
+                          value={pf.transform}
+                          onValueChange={(value) => 
updatePartitionField(index, "transform", value)}
+                        >
+                          <SelectTrigger className="flex-1">
+                            <SelectValue />
+                          </SelectTrigger>
+                          <SelectContent>
+                            <SelectItem value="identity">Identity</SelectItem>
+                            <SelectItem value="year">Year</SelectItem>
+                            <SelectItem value="month">Month</SelectItem>
+                            <SelectItem value="day">Day</SelectItem>
+                            <SelectItem value="hour">Hour</SelectItem>
+                            <SelectItem 
value="bucket[16]">Bucket[16]</SelectItem>
+                            <SelectItem 
value="truncate[10]">Truncate[10]</SelectItem>
+                          </SelectContent>
+                        </Select>
+                        <Button
+                          type="button"
+                          variant="ghost"
+                          size="icon"
+                          onClick={() => removePartitionField(index)}
+                        >
+                          <X className="h-4 w-4" />
+                        </Button>
+                      </div>
+                    ))}
+                  </div>
+                )}
+              </div>
+
+              {/* Table Properties */}
+              <div className="space-y-2">
+                <div className="flex items-center justify-between">
+                  <Label>Table Properties (optional)</Label>
+                  <Button type="button" variant="outline" size="sm" 
onClick={addProperty}>
+                    <Plus className="h-3 w-3 mr-1" />
+                    Add Property
+                  </Button>
+                </div>
+                {properties.length === 0 ? (
+                  <p className="text-xs text-muted-foreground">No properties 
added.</p>
+                ) : (
+                  <div className="space-y-2">
+                    {properties.map((prop, index) => (
+                      <div key={index} className="flex gap-2 items-start">
+                        <Input
+                          placeholder="Key"
+                          value={prop.key}
+                          onChange={(e) => updateProperty(index, "key", 
e.target.value)}
+                          className="flex-1"
+                        />
+                        <Input
+                          placeholder="Value"
+                          value={prop.value}
+                          onChange={(e) => updateProperty(index, "value", 
e.target.value)}
+                          className="flex-1"
+                        />
+                        <Button
+                          type="button"
+                          variant="ghost"
+                          size="icon"
+                          onClick={() => removeProperty(index)}
+                        >
+                          <X className="h-4 w-4" />
+                        </Button>
+                      </div>
+                    ))}
+                  </div>
+                )}
+              </div>
+            </CardContent>
+          </Card>
+
+          <DialogFooter>
+            <Button
+              type="button"
+              variant="outline"
+              onClick={() => onOpenChange(false)}
+              disabled={createMutation.isPending}
+            >
+              Cancel
+            </Button>
+            <Button type="submit" disabled={createMutation.isPending}>
+              {createMutation.isPending ? "Creating..." : "Create Table"}
+            </Button>
+          </DialogFooter>
+        </form>
+      </DialogContent>
+    </Dialog>
+  )
+}
+
+// Schema Field Editor Component
+interface SchemaFieldEditorProps {
+  fields: SchemaFieldState[]
+  setFields: (fields: SchemaFieldState[]) => void
+  updateField: (
+    index: number,
+    updates: Partial<SchemaFieldState>,
+    parentFields?: SchemaFieldState[],
+    parentSetter?: (fields: SchemaFieldState[]) => void
+  ) => void
+  removeField: (
+    index: number,
+    parentFields?: SchemaFieldState[],
+    parentSetter?: (fields: SchemaFieldState[]) => void
+  ) => void
+  changeFieldType: (
+    index: number,
+    newType: string,
+    parentFields?: SchemaFieldState[],
+    parentSetter?: (fields: SchemaFieldState[]) => void
+  ) => void
+  addField: (
+    parentFields?: SchemaFieldState[],
+    parentSetter?: (fields: SchemaFieldState[]) => void
+  ) => void
+  nextFieldId: number
+  setNextFieldId: (id: number) => void
+  level?: number
+}
+
+function SchemaFieldEditor({
+  fields,
+  setFields,
+  updateField,
+  removeField,
+  changeFieldType,
+  addField,
+  nextFieldId,
+  setNextFieldId,
+  level = 0,
+}: SchemaFieldEditorProps) {
+  return (
+    <div className="space-y-2">
+      {fields.map((field, index) => (
+        <div
+          key={field.id}
+          className="border rounded-lg p-3"
+          style={{ marginLeft: `${level * 20}px` }}
+        >
+          <div className="grid grid-cols-12 gap-2 items-start">
+            {/* Field Name */}
+            <div className="col-span-3">
+              <Input
+                placeholder="Field name"
+                value={field.name}
+                onChange={(e) => updateField(index, { name: e.target.value }, 
fields, setFields)}
+              />
+            </div>
+
+            {/* Field Type */}
+            <div className="col-span-3">
+              <Select
+                value={String(field.type)}
+                onValueChange={(value) => changeFieldType(index, value, 
fields, setFields)}
+              >
+                <SelectTrigger>
+                  <SelectValue />
+                </SelectTrigger>
+                <SelectContent>
+                  <SelectItem value="string">String</SelectItem>
+                  <SelectItem value="int">Int</SelectItem>
+                  <SelectItem value="long">Long</SelectItem>
+                  <SelectItem value="float">Float</SelectItem>
+                  <SelectItem value="double">Double</SelectItem>
+                  <SelectItem value="boolean">Boolean</SelectItem>
+                  <SelectItem value="date">Date</SelectItem>
+                  <SelectItem value="time">Time</SelectItem>
+                  <SelectItem value="timestamp">Timestamp</SelectItem>
+                  <SelectItem value="timestamptz">Timestamp TZ</SelectItem>
+                  <SelectItem value="uuid">UUID</SelectItem>
+                  <SelectItem value="binary">Binary</SelectItem>
+                  <SelectItem value="decimal">Decimal</SelectItem>
+                  <SelectItem value="fixed">Fixed</SelectItem>
+                  <SelectItem value="struct">Struct</SelectItem>
+                  <SelectItem value="list">List</SelectItem>
+                  <SelectItem value="map">Map</SelectItem>
+                </SelectContent>
+              </Select>
+            </div>
+
+            {/* Required Checkbox */}
+            <div className="col-span-2 flex items-center gap-2">
+              <input
+                type="checkbox"
+                checked={field.required}
+                onChange={(e) =>
+                  updateField(index, { required: e.target.checked }, fields, 
setFields)
+                }
+                className="h-4 w-4"
+              />
+              <label className="text-sm">Required</label>
+            </div>
+
+            {/* Doc/Comment */}
+            <div className="col-span-3">
+              <Input
+                placeholder="Description (optional)"
+                value={field.comment || ""}
+                onChange={(e) => updateField(index, { comment: e.target.value 
}, fields, setFields)}
+              />
+            </div>
+
+            {/* Remove Button */}
+            <div className="col-span-1 flex justify-end">
+              <Button
+                type="button"
+                variant="ghost"
+                size="icon"
+                onClick={() => removeField(index, fields, setFields)}
+              >
+                <X className="h-4 w-4" />
+              </Button>
+            </div>
+          </div>
+
+          {/* Parameterized Type Configuration */}
+          {field.type === "decimal" && (
+            <div className="grid grid-cols-2 gap-2 mt-2">
+              <Input
+                type="number"
+                placeholder="Precision"
+                value={field.decimalPrecision || ""}
+                onChange={(e) =>
+                  updateField(
+                    index,
+                    { decimalPrecision: parseInt(e.target.value) || undefined 
},
+                    fields,
+                    setFields
+                  )
+                }
+              />
+              <Input
+                type="number"
+                placeholder="Scale"
+                value={field.decimalScale || ""}
+                onChange={(e) =>
+                  updateField(
+                    index,
+                    { decimalScale: parseInt(e.target.value) || undefined },
+                    fields,
+                    setFields
+                  )
+                }
+              />
+            </div>
+          )}
+
+          {field.type === "fixed" && (
+            <div className="mt-2">
+              <Input
+                type="number"
+                placeholder="Length"
+                value={field.fixedLength || ""}
+                onChange={(e) =>
+                  updateField(
+                    index,
+                    { fixedLength: parseInt(e.target.value) || undefined },
+                    fields,
+                    setFields
+                  )
+                }
+              />
+            </div>
+          )}
+
+          {/* Complex Type Configuration */}
+          {field.type === "struct" && (
+            <div className="mt-3 space-y-2">
+              <div className="flex items-center justify-between">
+                <Label className="text-sm">Nested Fields</Label>
+                <Button
+                  type="button"
+                  variant="outline"
+                  size="sm"
+                  onClick={() => {
+                    const nestedFields = field.nestedFields || []
+                    const newNestedField: SchemaFieldState = {
+                      id: nextFieldId,
+                      name: "",
+                      type: "string",
+                      typeCategory: "primitive",
+                      required: true,
+                    }
+                    updateField(
+                      index,
+                      { nestedFields: [...nestedFields, newNestedField] },
+                      fields,
+                      setFields
+                    )
+                    setNextFieldId(nextFieldId + 1)
+                  }}
+                >
+                  <Plus className="h-3 w-3 mr-1" />
+                  Add Nested Field
+                </Button>
+              </div>
+              {field.nestedFields && field.nestedFields.length > 0 && (
+                <SchemaFieldEditor
+                  fields={field.nestedFields}
+                  setFields={(newNestedFields) =>
+                    updateField(index, { nestedFields: newNestedFields }, 
fields, setFields)
+                  }
+                  updateField={updateField}
+                  removeField={removeField}
+                  changeFieldType={changeFieldType}
+                  addField={addField}
+                  nextFieldId={nextFieldId}
+                  setNextFieldId={setNextFieldId}
+                  level={level + 1}
+                />
+              )}
+            </div>
+          )}
+
+          {field.type === "list" && (
+            <div className="mt-3 space-y-2">
+              <div className="grid grid-cols-2 gap-2">
+                <div className="space-y-2">
+                  <Label className="text-sm">Element Type</Label>
+                  <Select
+                    value={
+                      typeof field.elementType === "object"
+                        ? field.elementType.type
+                        : String(field.elementType || "string")
+                    }
+                    onValueChange={(value) => {
+                      if (value === "struct") {
+                        updateField(
+                          index,
+                          {
+                            elementType: { type: "struct", fields: [] },
+                          },
+                          fields,
+                          setFields
+                        )
+                      } else {
+                        updateField(index, { elementType: value }, fields, 
setFields)
+                      }
+                    }}
+                  >
+                    <SelectTrigger>
+                      <SelectValue />
+                    </SelectTrigger>
+                    <SelectContent>
+                      <SelectItem value="string">String</SelectItem>
+                      <SelectItem value="int">Int</SelectItem>
+                      <SelectItem value="long">Long</SelectItem>
+                      <SelectItem value="float">Float</SelectItem>
+                      <SelectItem value="double">Double</SelectItem>
+                      <SelectItem value="boolean">Boolean</SelectItem>
+                      <SelectItem value="date">Date</SelectItem>
+                      <SelectItem value="timestamp">Timestamp</SelectItem>
+                      <SelectItem value="struct">Struct</SelectItem>
+                    </SelectContent>
+                  </Select>
+                </div>
+                <div className="flex items-end">
+                  <div className="flex items-center gap-2">
+                    <input
+                      type="checkbox"
+                      checked={field.elementRequired ?? true}
+                      onChange={(e) =>
+                        updateField(index, { elementRequired: e.target.checked 
}, fields, setFields)
+                      }
+                      className="h-4 w-4"
+                    />
+                    <label className="text-sm">Element Required</label>
+                  </div>
+                </div>
+              </div>
+
+              {/* Nested fields for struct element type */}
+              {typeof field.elementType === "object" && field.elementType.type 
=== "struct" && (
+                <div className="space-y-2 border-l-2 border-muted pl-3">
+                  <div className="flex items-center justify-between">
+                    <Label className="text-sm">Struct Fields</Label>
+                    <Button
+                      type="button"
+                      variant="outline"
+                      size="sm"
+                      onClick={() => {
+                        const structFields =
+                          (typeof field.elementType === "object" && 
field.elementType.fields) || []
+                        const newField: SchemaFieldState = {
+                          id: nextFieldId,
+                          name: "",
+                          type: "string",
+                          typeCategory: "primitive",
+                          required: true,
+                        }
+                        updateField(
+                          index,
+                          {
+                            elementType: {
+                              type: "struct",
+                              fields: [...structFields, newField],
+                            },
+                          },
+                          fields,
+                          setFields
+                        )
+                        setNextFieldId(nextFieldId + 1)
+                      }}
+                    >
+                      <Plus className="h-3 w-3 mr-1" />
+                      Add Field
+                    </Button>
+                  </div>
+                  {typeof field.elementType === "object" &&
+                    field.elementType.fields &&
+                    field.elementType.fields.length > 0 && (
+                      <SchemaFieldEditor
+                        fields={field.elementType.fields as SchemaFieldState[]}
+                        setFields={(newFields) =>
+                          updateField(
+                            index,
+                            {
+                              elementType: {
+                                type: "struct",
+                                fields: newFields,
+                              },
+                            },
+                            fields,
+                            setFields
+                          )
+                        }
+                        updateField={updateField}
+                        removeField={removeField}
+                        changeFieldType={changeFieldType}
+                        addField={addField}
+                        nextFieldId={nextFieldId}
+                        setNextFieldId={setNextFieldId}
+                        level={level + 1}
+                      />
+                    )}
+                </div>
+              )}
+            </div>
+          )}
+
+          {field.type === "map" && (
+            <div className="mt-3 space-y-2">
+              <div className="grid grid-cols-2 gap-2">
+                <div className="space-y-2">
+                  <Label className="text-sm">Key Type</Label>
+                  <Select
+                    value={String(field.keyType || "string")}
+                    onValueChange={(value) =>
+                      updateField(index, { keyType: value }, fields, setFields)
+                    }
+                  >
+                    <SelectTrigger>
+                      <SelectValue />
+                    </SelectTrigger>
+                    <SelectContent>
+                      <SelectItem value="string">String</SelectItem>
+                      <SelectItem value="int">Int</SelectItem>
+                      <SelectItem value="long">Long</SelectItem>
+                      <SelectItem value="uuid">UUID</SelectItem>
+                    </SelectContent>
+                  </Select>
+                </div>
+                <div className="space-y-2">
+                  <Label className="text-sm">Value Type</Label>
+                  <Select
+                    value={String(field.valueType || "string")}
+                    onValueChange={(value) =>
+                      updateField(index, { valueType: value }, fields, 
setFields)
+                    }
+                  >
+                    <SelectTrigger>
+                      <SelectValue />
+                    </SelectTrigger>
+                    <SelectContent>
+                      <SelectItem value="string">String</SelectItem>
+                      <SelectItem value="int">Int</SelectItem>
+                      <SelectItem value="long">Long</SelectItem>
+                      <SelectItem value="double">Double</SelectItem>
+                      <SelectItem value="boolean">Boolean</SelectItem>
+                    </SelectContent>
+                  </Select>
+                </div>
+              </div>
+              <div className="flex items-center gap-2">
+                <input
+                  type="checkbox"
+                  checked={field.valueRequired ?? true}
+                  onChange={(e) =>
+                    updateField(index, { valueRequired: e.target.checked }, 
fields, setFields)
+                  }
+                  className="h-4 w-4"
+                />
+                <label className="text-sm">Value Required</label>
+              </div>
+            </div>
+          )}
+        </div>
+      ))}
+    </div>
+  )
+}
diff --git a/console/src/pages/NamespaceDetails.tsx 
b/console/src/pages/NamespaceDetails.tsx
index 517eb8b..55a6165 100644
--- a/console/src/pages/NamespaceDetails.tsx
+++ b/console/src/pages/NamespaceDetails.tsx
@@ -48,6 +48,7 @@ import { Input } from "@/components/ui/input"
 import { Label } from "@/components/ui/label"
 import { useState } from "react"
 import { CreateViewModal } from "@/components/forms/CreateViewModal"
+import { CreateTableModal } from "@/components/forms/CreateTableModal"
 
 export function NamespaceDetails() {
   const { catalogName, namespace: namespaceParam } = useParams<{
@@ -59,6 +60,7 @@ export function NamespaceDetails() {
   const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
   const [createGenericTableOpen, setCreateGenericTableOpen] = useState(false)
   const [createViewOpen, setCreateViewOpen] = useState(false)
+  const [createTableOpen, setCreateTableOpen] = useState(false)
   const [genericTableForm, setGenericTableForm] = useState({
     name: "",
     format: "",
@@ -363,10 +365,8 @@ export function NamespaceDetails() {
                   <CardDescription>Iceberg tables in this 
namespace</CardDescription>
                 </div>
                 <Button
-                  onClick={() => {
-                    // TODO: Open create table modal (future implementation)
-                    console.log("Create table - to be implemented")
-                  }}
+                  onClick={() => setCreateTableOpen(true)}
+                  disabled={!catalogName || !namespaceParam}
                 >
                   <Plus className="mr-2 h-4 w-4" />
                   Iceberg Table
@@ -700,6 +700,17 @@ export function NamespaceDetails() {
         </DialogContent>
       </Dialog>
 
+      {/* Create Iceberg Table Modal */}
+      <CreateTableModal
+        open={createTableOpen}
+        onOpenChange={setCreateTableOpen}
+        catalogName={catalogName!}
+        namespace={namespaceArray}
+        onCreated={() => {
+          tablesQuery.refetch()
+        }}
+      />
+
       {/* Create Iceberg View Modal */}
       <CreateViewModal
         open={createViewOpen}


Reply via email to