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

jshao pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new f3e3673eb [#5450] subtask(web): support web ui for creating table 
(basic column type) (#5524)
f3e3673eb is described below

commit f3e3673eb2639808b4a7c68f3e05cfbf1120364d
Author: JUN <[email protected]>
AuthorDate: Wed Nov 13 19:26:11 2024 +0800

    [#5450] subtask(web): support web ui for creating table (basic column type) 
(#5524)
    
    ### Proposed Changes in This Pull Request
    
    - Added support for a web UI to create relational tables (basic
    operations).
    - Enhanced the table list view with options to edit properties and
    delete tables.
    
    ### Why Are These Changes Necessary?
    
    These changes introduce the ability to create tables directly from the
    UI, improving user experience and efficiency.
    
    Closes: #5450
    
    ### User-Facing Changes
    
    - Added a "Create Table" button in the table list view.
    - Introduced a dialog for creating and updating tables.
    - Enabled actions for viewing, editing properties, and deleting tables
    from the table list view.
    
    ### How Was This Patch Tested?
    
    1. Started the Gravitino playground.
    2. Ran the web UI to test the create table functionality, as well as the
    view, edit properties, and delete actions.
    3. Conducted a lint check using Prettier for code formatting.
    4. Built the entire project to ensure no build errors.
    
    ![2024-11-08
    
002032](https://github.com/user-attachments/assets/004f7355-d0de-420a-922c-046a2b91d024)
    
    
    
![image](https://github.com/user-attachments/assets/16642425-adb7-4ca4-a9b7-283879dedb22)
    
    
    
![image](https://github.com/user-attachments/assets/ad90bab5-6a89-4ef9-8396-4b73d0f4dc99)
    
    ---------
    
    Co-authored-by: Qian Xia <[email protected]>
---
 .../metalake/rightContent/CreateTableDialog.js     | 688 +++++++++++++++++++++
 .../metalake/rightContent/RightContent.js          |  29 +
 .../tabsContent/tableView/TableView.js             |  47 +-
 web/web/src/lib/api/tables/index.js                |  20 +-
 web/web/src/lib/store/metalakes/index.js           |  63 +-
 web/web/src/lib/utils/index.js                     |  51 ++
 web/web/src/lib/utils/initial.js                   |  19 +
 7 files changed, 907 insertions(+), 10 deletions(-)

diff --git 
a/web/web/src/app/metalakes/metalake/rightContent/CreateTableDialog.js 
b/web/web/src/app/metalakes/metalake/rightContent/CreateTableDialog.js
new file mode 100644
index 000000000..1132964b4
--- /dev/null
+++ b/web/web/src/app/metalakes/metalake/rightContent/CreateTableDialog.js
@@ -0,0 +1,688 @@
+/*
+ * 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.
+ */
+
+/**
+ * CreateTableDialog component
+ *
+ * A dialog component for creating and editing tables in a metalake catalog.
+ *
+ * Features:
+ * - Create new tables or edit existing ones
+ * - Configure table name, comment and properties
+ * - Add/edit/remove table columns with name, type, nullable, and comment 
fields
+ * - Add/edit/remove custom table properties
+ * - Form validation using yup schema
+ * - Responsive dialog layout
+ *
+ * Props:
+ * @param {boolean} open - Controls dialog visibility
+ * @param {function} setOpen - Function to update dialog visibility
+ * @param {string} type - Dialog mode: 'create' or 'edit'
+ * @param {object} data - Table data for edit mode
+ */
+
+'use client'
+
+// Import required React hooks
+import { useState, forwardRef, useEffect, Fragment } from 'react'
+
+// Import Material UI components
+import {
+  Box,
+  Grid,
+  Button,
+  Dialog,
+  TextField,
+  Typography,
+  DialogContent,
+  DialogActions,
+  IconButton,
+  Fade,
+  FormControl,
+  FormHelperText,
+  Switch,
+  Table,
+  TableBody,
+  TableCell,
+  TableContainer,
+  TableHead,
+  TableRow,
+  Paper,
+  Select,
+  MenuItem
+} from '@mui/material'
+
+// Import custom components
+import Icon from '@/components/Icon'
+
+// Import Redux hooks and actions
+import { useAppDispatch } from '@/lib/hooks/useStore'
+import { createTable, updateTable } from '@/lib/store/metalakes'
+
+// Import form validation libraries
+import * as yup from 'yup'
+import { useForm, Controller } from 'react-hook-form'
+import { yupResolver } from '@hookform/resolvers/yup'
+
+// Import utility functions and constants
+import { groupBy } from 'lodash-es'
+import { genUpdates } from '@/lib/utils'
+import { nameRegex, nameRegexDesc, keyRegex } from '@/lib/utils/regex'
+import { useSearchParams } from 'next/navigation'
+import { relationalTypes } from '@/lib/utils/initial'
+
+// Default form values
+const defaultFormValues = {
+  name: '',
+  comment: '',
+  columns: [],
+  propItems: []
+}
+
+// Form validation schema
+const schema = yup.object().shape({
+  name: yup.string().required().matches(nameRegex, nameRegexDesc),
+  columns: yup.array().of(
+    yup.object().shape({
+      name: yup.string().required(),
+      type: yup.string().required(),
+      nullable: yup.boolean(),
+      comment: yup.string()
+    })
+  ),
+  propItems: yup.array().of(
+    yup.object().shape({
+      required: yup.boolean(),
+      key: yup.string().required(),
+      value: yup.string().when('required', {
+        is: true,
+        then: schema => schema.required()
+      })
+    })
+  )
+})
+
+// Dialog transition component
+const Transition = forwardRef(function Transition(props, ref) {
+  return <Fade ref={ref} {...props} />
+})
+
+/**
+ * Main CreateTableDialog component
+ * Handles creation and editing of tables with columns and properties
+ */
+const CreateTableDialog = props => {
+  // Destructure props
+  const { open, setOpen, type = 'create', data = {} } = props
+
+  // Get URL parameters
+  const searchParams = useSearchParams()
+  const metalake = searchParams.get('metalake')
+  const catalog = searchParams.get('catalog')
+  const catalogType = searchParams.get('type')
+  const schemaName = searchParams.get('schema')
+
+  // Component state
+  const [innerProps, setInnerProps] = useState([])
+  const [tableColumns, setTableColumns] = useState([{ name: '', type: '', 
nullable: true, comment: '' }])
+  const [initialTableData, setInitialTableData] = useState()
+  const dispatch = useAppDispatch()
+
+  // Initialize form with react-hook-form
+  const {
+    control,
+    reset,
+    setValue,
+    getValues,
+    handleSubmit,
+    trigger,
+    formState: { errors }
+  } = useForm({
+    defaultValues: defaultFormValues,
+    mode: 'all',
+    resolver: yupResolver(schema)
+  })
+
+  /**
+   * Handle changes to property form fields
+   * Validates keys and checks for duplicates
+   */
+  const handlePropertyChange = ({ index, event }) => {
+    let updatedProps = [...innerProps]
+    updatedProps[index][event.target.name] = event.target.value
+
+    if (event.target.name === 'key') {
+      const isInvalidKey = !keyRegex.test(event.target.value)
+      updatedProps[index].invalid = isInvalidKey
+    }
+
+    const nonEmptyKeys = updatedProps.filter(item => item.key.trim() !== '')
+    const groupedKeys = groupBy(nonEmptyKeys, 'key')
+    const hasDuplicateKeys = Object.keys(groupedKeys).some(key => 
groupedKeys[key].length > 1)
+
+    if (hasDuplicateKeys) {
+      updatedProps[index].hasDuplicateKey = hasDuplicateKeys
+    } else {
+      updatedProps.forEach(item => (item.hasDuplicateKey = false))
+    }
+
+    setInnerProps(updatedProps)
+    setValue('propItems', updatedProps)
+  }
+
+  /**
+   * Handle changes to column fields
+   */
+  const handleColumnChange = ({ index, field, value }) => {
+    let updatedColumns = [...tableColumns]
+    updatedColumns[index][field] = value
+
+    if (field === 'name') {
+      const nonEmptyNames = updatedColumns.filter(col => col.name.trim() !== 
'')
+      const groupedNames = groupBy(nonEmptyNames, 'name')
+      const hasDuplicateNames = Object.keys(groupedNames).some(name => 
groupedNames[name].length > 1)
+
+      if (hasDuplicateNames) {
+        updatedColumns[index].hasDuplicateName = hasDuplicateNames
+      } else {
+        updatedColumns.forEach(col => (col.hasDuplicateName = false))
+      }
+    }
+
+    setTableColumns(updatedColumns)
+    setValue('columns', updatedColumns)
+  }
+
+  /**
+   * Add a new empty column
+   */
+  const addColumn = () => {
+    const newColumn = { name: '', type: '', nullable: true, comment: '' }
+    setTableColumns([...tableColumns, newColumn])
+    setValue('columns', [...tableColumns, newColumn])
+  }
+
+  /**
+   * Remove a column at specified index
+   */
+  const removeColumn = index => {
+    let updatedColumns = [...tableColumns]
+    updatedColumns.splice(index, 1)
+    setTableColumns(updatedColumns)
+    setValue('columns', updatedColumns)
+  }
+
+  /**
+   * Add a new property field
+   * Checks for duplicate keys before adding
+   */
+  const addProperty = () => {
+    const hasDuplicateKeys = innerProps
+      .filter(item => item.key.trim() !== '')
+      .some(
+        (item, index, filteredItems) =>
+          filteredItems.findIndex(otherItem => otherItem !== item && 
otherItem.key.trim() === item.key.trim()) !== -1
+      )
+
+    if (hasDuplicateKeys) {
+      return
+    }
+
+    const newProperty = { key: '', value: '', required: false }
+
+    setInnerProps([...innerProps, newProperty])
+    setValue('propItems', [...innerProps, newProperty])
+  }
+
+  /**
+   * Remove a property field at specified index
+   */
+  const removeProperty = index => {
+    let updatedProps = [...innerProps]
+    updatedProps.splice(index, 1)
+    setInnerProps(updatedProps)
+    setValue('propItems', updatedProps)
+  }
+
+  /**
+   * Handle dialog close
+   * Resets form and clears state
+   */
+  const handleDialogClose = () => {
+    reset()
+    setInnerProps([])
+    setTableColumns([{ name: '', type: '', nullable: true, comment: '' }])
+    setValue('propItems', [])
+    setValue('columns', [])
+    setOpen(false)
+  }
+
+  /**
+   * Handle form submission
+   */
+  const handleFormSubmit = e => {
+    e.preventDefault()
+
+    return handleSubmit(submitForm(getValues()), handleValidationError)
+  }
+
+  /**
+   * Process form submission
+   * Validates data and dispatches create/update actions
+   */
+  const submitForm = formData => {
+    const hasDuplicateKeys = innerProps
+      .filter(item => item.key.trim() !== '')
+      .some(
+        (item, index, filteredItems) =>
+          filteredItems.findIndex(otherItem => otherItem !== item && 
otherItem.key.trim() === item.key.trim()) !== -1
+      )
+
+    const hasInvalidKeys = innerProps.some(prop => prop.invalid)
+
+    const hasDuplicateColumnNames = tableColumns
+      .filter(col => col.name.trim() !== '')
+      .some(
+        (col, index, filteredCols) =>
+          filteredCols.findIndex(otherCol => otherCol !== col && 
otherCol.name.trim() === col.name.trim()) !== -1
+      )
+
+    if (hasDuplicateKeys || hasInvalidKeys || hasDuplicateColumnNames) {
+      return
+    }
+
+    trigger()
+
+    schema
+      .validate(formData)
+      .then(() => {
+        const properties = innerProps.reduce((acc, item) => {
+          acc[item.key] = item.value
+
+          return acc
+        }, {})
+
+        const tableData = {
+          name: formData.name,
+          comment: formData.comment,
+          columns: formData.columns.map(({ hasDuplicateName, ...rest }) => 
rest),
+          properties
+        }
+
+        if (type === 'create') {
+          dispatch(createTable({ data: tableData, metalake, catalog, type: 
catalogType, schema: schemaName })).then(
+            res => {
+              if (!res.payload?.err) {
+                handleDialogClose()
+              }
+            }
+          )
+        } else {
+          const updates = genUpdates(initialTableData, tableData)
+
+          if (updates.length !== 0) {
+            dispatch(
+              updateTable({
+                metalake,
+                catalog,
+                type: catalogType,
+                schema: schemaName,
+                table: initialTableData.name,
+                data: { updates }
+              })
+            ).then(res => {
+              if (!res.payload?.err) {
+                handleDialogClose()
+              }
+            })
+          }
+        }
+      })
+      .catch(err => {
+        console.error('Validation error:', err)
+      })
+  }
+
+  /**
+   * Handle form validation errors
+   */
+  const handleValidationError = errors => {
+    console.error('Form validation errors:', errors)
+  }
+
+  /**
+   * Effect to populate form when editing existing table
+   */
+  useEffect(() => {
+    if (open && JSON.stringify(data) !== '{}') {
+      const { properties = {}, columns = [] } = data
+
+      setInitialTableData(data)
+      setValue('name', data.name)
+      setValue('comment', data.comment)
+
+      const columnsData = columns.map(column => {
+        // Set uniqueId to the column name to detect changes
+        column.uniqueId = column.name
+
+        return {
+          ...column
+        }
+      })
+
+      setTableColumns(columnsData)
+      setValue('columns', columns)
+
+      const propertyItems = Object.entries(properties).map(([key, value]) => {
+        return {
+          key,
+          value
+        }
+      })
+
+      setInnerProps(propertyItems)
+      setValue('propItems', propertyItems)
+    }
+  }, [open, data, setValue, type])
+
+  return (
+    <Dialog
+      fullWidth
+      maxWidth='md'
+      scroll='body'
+      TransitionComponent={Transition}
+      open={open}
+      onClose={handleDialogClose}
+    >
+      <form onSubmit={e => handleFormSubmit(e)}>
+        <DialogContent
+          sx={{
+            position: 'relative',
+            pb: theme => `${theme.spacing(8)} !important`,
+            px: theme => [`${theme.spacing(5)} !important`, 
`${theme.spacing(15)} !important`],
+            pt: theme => [`${theme.spacing(8)} !important`, 
`${theme.spacing(12.5)} !important`]
+          }}
+        >
+          <IconButton
+            size='small'
+            onClick={() => handleDialogClose()}
+            sx={{ position: 'absolute', right: '1rem', top: '1rem' }}
+          >
+            <Icon icon='bx:x' />
+          </IconButton>
+          <Box sx={{ mb: 8, textAlign: 'center' }}>
+            <Typography variant='h5' sx={{ mb: 3 }}>
+              {type === 'create' ? 'Create' : 'Edit'} Table
+            </Typography>
+          </Box>
+
+          <Grid container spacing={6}>
+            <Grid item xs={12}>
+              <FormControl fullWidth>
+                <Controller
+                  name='name'
+                  control={control}
+                  rules={{ required: true }}
+                  render={({ field: { value, onChange } }) => (
+                    <TextField
+                      value={value}
+                      label='Name'
+                      onChange={onChange}
+                      placeholder=''
+                      error={Boolean(errors.name)}
+                      data-refer='table-name-field'
+                    />
+                  )}
+                />
+                {errors.name && <FormHelperText sx={{ color: 'error.main' 
}}>{errors.name.message}</FormHelperText>}
+              </FormControl>
+            </Grid>
+
+            <Grid item xs={12}>
+              <FormControl fullWidth>
+                <Controller
+                  name='comment'
+                  control={control}
+                  rules={{ required: false }}
+                  render={({ field: { value, onChange } }) => (
+                    <TextField
+                      value={value}
+                      label='Comment'
+                      multiline
+                      rows={2}
+                      onChange={onChange}
+                      placeholder=''
+                      error={Boolean(errors.comment)}
+                      data-refer='table-comment-field'
+                    />
+                  )}
+                />
+              </FormControl>
+            </Grid>
+
+            <Grid item xs={12} data-refer='table-columns-layout'>
+              <Typography sx={{ mb: 2 }} variant='body2'>
+                Columns
+              </Typography>
+              <TableContainer component={Paper} sx={{ maxHeight: 440 }}>
+                <Table stickyHeader>
+                  <TableHead>
+                    <TableRow>
+                      <TableCell sx={{ minWidth: 100 }}>Name</TableCell>
+                      <TableCell sx={{ minWidth: 100 }}>Type</TableCell>
+                      <TableCell sx={{ minWidth: 100 }}>Nullable</TableCell>
+                      <TableCell sx={{ minWidth: 200 }}>Comment</TableCell>
+                      <TableCell sx={{ minWidth: 50 }}>Action</TableCell>
+                    </TableRow>
+                  </TableHead>
+                  <TableBody>
+                    {tableColumns.map((column, index) => (
+                      <TableRow key={index}>
+                        <TableCell sx={{ verticalAlign: 'top' }}>
+                          <FormControl fullWidth>
+                            <TextField
+                              size='small'
+                              fullWidth
+                              value={column.name}
+                              onChange={e => handleColumnChange({ index, 
field: 'name', value: e.target.value })}
+                              error={!column.name.trim() || 
column.hasDuplicateName}
+                              data-refer={`column-name-${index}`}
+                            />
+                            {column.hasDuplicateName && (
+                              <FormHelperText 
className={'twc-text-error-main'}>Name already exists</FormHelperText>
+                            )}
+                            {!column.name.trim() && (
+                              <FormHelperText 
className={'twc-text-error-main'}>Name is required</FormHelperText>
+                            )}
+                          </FormControl>
+                        </TableCell>
+                        <TableCell sx={{ verticalAlign: 'top' }}>
+                          <FormControl fullWidth>
+                            <Select
+                              size='small'
+                              fullWidth
+                              value={column.type}
+                              onChange={e => handleColumnChange({ index, 
field: 'type', value: e.target.value })}
+                              error={!column.type.trim()}
+                              data-refer={`column-type-${index}`}
+                            >
+                              {relationalTypes.map(type => (
+                                <MenuItem key={type.value} value={type.value}>
+                                  {type.label}
+                                </MenuItem>
+                              ))}
+                            </Select>
+                            {!column.type.trim() && (
+                              <FormHelperText 
className={'twc-text-error-main'}>Type is required</FormHelperText>
+                            )}
+                          </FormControl>
+                        </TableCell>
+                        <TableCell sx={{ verticalAlign: 'top' }}>
+                          <Switch
+                            checked={column.nullable || false}
+                            onChange={e => handleColumnChange({ index, field: 
'nullable', value: e.target.checked })}
+                            data-refer={`column-nullable-${index}`}
+                          />
+                        </TableCell>
+                        <TableCell sx={{ verticalAlign: 'top' }}>
+                          <TextField
+                            size='small'
+                            fullWidth
+                            value={column.comment}
+                            onChange={e => handleColumnChange({ index, field: 
'comment', value: e.target.value })}
+                            data-refer={`column-comment-${index}`}
+                          />
+                        </TableCell>
+                        <TableCell sx={{ verticalAlign: 'top' }}>
+                          {tableColumns.length > 1 && (
+                            <IconButton onClick={() => removeColumn(index)}>
+                              <Icon icon='mdi:minus-circle-outline' />
+                            </IconButton>
+                          )}
+                        </TableCell>
+                      </TableRow>
+                    ))}
+                  </TableBody>
+                </Table>
+              </TableContainer>
+            </Grid>
+
+            <Grid item xs={12}>
+              <Button
+                size='small'
+                onClick={addColumn}
+                variant='outlined'
+                startIcon={<Icon icon='mdi:plus-circle-outline' />}
+                data-refer='add-table-column'
+              >
+                Add Column
+              </Button>
+            </Grid>
+
+            <Grid item xs={12} data-refer='table-props-layout'>
+              <Typography sx={{ mb: 2 }} variant='body2'>
+                Properties
+              </Typography>
+              {innerProps.map((item, index) => {
+                return (
+                  <Fragment key={index}>
+                    <Grid item xs={12} sx={{ '& + &': { mt: 2 } }}>
+                      <FormControl fullWidth>
+                        <Box>
+                          <Box
+                            sx={{ display: 'flex', alignItems: 'center', 
justifyContent: 'space-between' }}
+                            data-refer={`table-props-${index}`}
+                          >
+                            <Box>
+                              <TextField
+                                size='small'
+                                name='key'
+                                label='Key'
+                                value={item.key}
+                                disabled={item.disabled}
+                                onChange={event => handlePropertyChange({ 
index, event })}
+                                error={item.hasDuplicateKey || item.invalid || 
!item.key.trim()}
+                                data-refer={`props-key-${index}`}
+                              />
+                            </Box>
+                            <Box>
+                              <TextField
+                                size='small'
+                                name='value'
+                                label='Value'
+                                error={item.required && item.value === ''}
+                                value={item.value}
+                                disabled={item.disabled}
+                                onChange={event => handlePropertyChange({ 
index, event })}
+                                data-refer={`props-value-${index}`}
+                                data-prev-refer={`props-${item.key}`}
+                              />
+                            </Box>
+
+                            {!item.disabled ? (
+                              <Box sx={{ minWidth: 40 }}>
+                                <IconButton onClick={() => 
removeProperty(index)}>
+                                  <Icon icon='mdi:minus-circle-outline' />
+                                </IconButton>
+                              </Box>
+                            ) : (
+                              <Box sx={{ minWidth: 40 }}></Box>
+                            )}
+                          </Box>
+                        </Box>
+                        <FormHelperText
+                          sx={{
+                            color: item.required && item.value === '' ? 
'error.main' : 'text.main',
+                            maxWidth: 'calc(100% - 40px)'
+                          }}
+                        >
+                          {item.description}
+                        </FormHelperText>
+                        {item.hasDuplicateKey && (
+                          <FormHelperText 
className={'twc-text-error-main'}>Key already exists</FormHelperText>
+                        )}
+                        {item.key && item.invalid && (
+                          <FormHelperText className={'twc-text-error-main'}>
+                            Invalid key, matches strings starting with a 
letter/underscore, followed by alphanumeric
+                            characters, underscores, hyphens, or dots.
+                          </FormHelperText>
+                        )}
+                        {!item.key.trim() && (
+                          <FormHelperText 
className={'twc-text-error-main'}>Key is required field</FormHelperText>
+                        )}
+                      </FormControl>
+                    </Grid>
+                  </Fragment>
+                )
+              })}
+            </Grid>
+
+            <Grid item xs={12}>
+              <Button
+                size='small'
+                onClick={addProperty}
+                variant='outlined'
+                startIcon={<Icon icon='mdi:plus-circle-outline' />}
+                data-refer='add-table-props'
+              >
+                Add Property
+              </Button>
+            </Grid>
+          </Grid>
+        </DialogContent>
+        <DialogActions
+          sx={{
+            justifyContent: 'center',
+            px: theme => [`${theme.spacing(5)} !important`, 
`${theme.spacing(15)} !important`],
+            pb: theme => [`${theme.spacing(5)} !important`, 
`${theme.spacing(12.5)} !important`]
+          }}
+        >
+          <Button variant='contained' sx={{ mr: 1 }} type='submit' 
data-refer='handle-submit-table'>
+            {type === 'create' ? 'Create' : 'Update'}
+          </Button>
+          <Button variant='outlined' onClick={handleDialogClose}>
+            Cancel
+          </Button>
+        </DialogActions>
+      </form>
+    </Dialog>
+  )
+}
+
+export default CreateTableDialog
diff --git a/web/web/src/app/metalakes/metalake/rightContent/RightContent.js 
b/web/web/src/app/metalakes/metalake/rightContent/RightContent.js
index 4dfd091a4..a4a59099f 100644
--- a/web/web/src/app/metalakes/metalake/rightContent/RightContent.js
+++ b/web/web/src/app/metalakes/metalake/rightContent/RightContent.js
@@ -27,6 +27,7 @@ import MetalakePath from './MetalakePath'
 import CreateCatalogDialog from './CreateCatalogDialog'
 import CreateSchemaDialog from './CreateSchemaDialog'
 import CreateFilesetDialog from './CreateFilesetDialog'
+import CreateTableDialog from './CreateTableDialog'
 import TabsContent from './tabsContent/TabsContent'
 import { useSearchParams } from 'next/navigation'
 import { useAppSelector } from '@/lib/hooks/useStore'
@@ -35,10 +36,12 @@ const RightContent = () => {
   const [open, setOpen] = useState(false)
   const [openSchema, setOpenSchema] = useState(false)
   const [openFileset, setOpenFileset] = useState(false)
+  const [openTable, setOpenTable] = useState(false)
   const searchParams = useSearchParams()
   const [isShowBtn, setBtnVisible] = useState(true)
   const [isShowSchemaBtn, setSchemaBtnVisible] = useState(false)
   const [isShowFilesetBtn, setFilesetBtnVisible] = useState(false)
+  const [isShowTableBtn, setTableBtnVisible] = useState(false)
   const store = useAppSelector(state => state.metalakes)
 
   const handleCreateCatalog = () => {
@@ -53,6 +56,10 @@ const RightContent = () => {
     setOpenFileset(true)
   }
 
+  const handleCreateTable = () => {
+    setOpenTable(true)
+  }
+
   useEffect(() => {
     const paramsSize = [...searchParams.keys()].length
     const isCatalogList = paramsSize == 1 && searchParams.get('metalake')
@@ -77,6 +84,14 @@ const RightContent = () => {
         !['lakehouse-hudi', 'kafka'].includes(currentCatalog?.provider)
       setSchemaBtnVisible(isSchemaList)
     }
+
+    const isTableList =
+      paramsSize == 4 &&
+      searchParams.has('metalake') &&
+      searchParams.has('catalog') &&
+      searchParams.get('type') === 'relational' &&
+      searchParams.has('schema')
+    setTableBtnVisible(isTableList)
   }, [searchParams, store.catalogs, store.catalogs.length])
 
   return (
@@ -140,6 +155,20 @@ const RightContent = () => {
             <CreateFilesetDialog open={openFileset} setOpen={setOpenFileset} />
           </Box>
         )}
+        {isShowTableBtn && (
+          <Box className={`twc-flex twc-items-center`}>
+            <Button
+              variant='contained'
+              startIcon={<Icon icon='mdi:plus-box' />}
+              onClick={handleCreateTable}
+              sx={{ width: 200 }}
+              data-refer='create-table-btn'
+            >
+              Create Table
+            </Button>
+            <CreateTableDialog open={openTable} setOpen={setOpenTable} />
+          </Box>
+        )}
       </Box>
 
       <Box sx={{ height: 'calc(100% - 4.1rem)' }}>
diff --git 
a/web/web/src/app/metalakes/metalake/rightContent/tabsContent/tableView/TableView.js
 
b/web/web/src/app/metalakes/metalake/rightContent/tabsContent/tableView/TableView.js
index 964b7d6b8..f414ccfec 100644
--- 
a/web/web/src/app/metalakes/metalake/rightContent/tabsContent/tableView/TableView.js
+++ 
b/web/web/src/app/metalakes/metalake/rightContent/tabsContent/tableView/TableView.js
@@ -42,22 +42,17 @@ import ConfirmDeleteDialog from 
'@/components/ConfirmDeleteDialog'
 import CreateCatalogDialog from '../../CreateCatalogDialog'
 import CreateSchemaDialog from '../../CreateSchemaDialog'
 import CreateFilesetDialog from '../../CreateFilesetDialog'
+import CreateTableDialog from '../../CreateTableDialog'
 
 import { useAppSelector, useAppDispatch } from '@/lib/hooks/useStore'
-import {
-  deleteCatalog,
-  deleteFileset,
-  deleteSchema,
-  resetExpandNode,
-  setCatalogInUse,
-  setIntoTreeNodes
-} from '@/lib/store/metalakes'
+import { deleteCatalog, deleteFileset, deleteSchema, deleteTable, 
setCatalogInUse } from '@/lib/store/metalakes'
 
 import { to } from '@/lib/utils'
 import { getCatalogDetailsApi, switchInUseApi } from '@/lib/api/catalogs'
 import { getSchemaDetailsApi } from '@/lib/api/schemas'
 import { useSearchParams } from 'next/navigation'
 import { getFilesetDetailsApi } from '@/lib/api/filesets'
+import { getTableDetailsApi } from '@/lib/api/tables'
 
 const fonts = Inconsolata({ subsets: ['latin'] })
 
@@ -110,6 +105,7 @@ const TableView = () => {
   const [openDialog, setOpenDialog] = useState(false)
   const [openSchemaDialog, setOpenSchemaDialog] = useState(false)
   const [openFilesetDialog, setOpenFilesetDialog] = useState(false)
+  const [openTableDialog, setOpenTableDialog] = useState(false)
   const [dialogData, setDialogData] = useState({})
   const [dialogType, setDialogType] = useState('create')
   const [isHideSchemaEdit, setIsHideSchemaEdit] = useState(true)
@@ -504,6 +500,17 @@ const TableView = () => {
 
         setDrawerData(res.fileset)
         setOpenDrawer(true)
+        break
+      }
+      case 'table': {
+        const [err, res] = await to(getTableDetailsApi({ metalake, catalog, 
schema, table: row.name }))
+        if (err || !res) {
+          throw new Error(err)
+        }
+
+        setDrawerData(res.table)
+        setOpenDrawer(true)
+        break
       }
       default:
         return
@@ -551,6 +558,20 @@ const TableView = () => {
           setDialogData(res.fileset)
           setOpenFilesetDialog(true)
         }
+        break
+      }
+      case 'table': {
+        if (metalake && catalog && schema) {
+          const [err, res] = await to(getTableDetailsApi({ metalake, catalog, 
schema, table: data.row?.name }))
+          if (err || !res) {
+            throw new Error(err)
+          }
+
+          setDialogType('update')
+          setDialogData(res.table)
+          setOpenTableDialog(true)
+        }
+        break
       }
       default:
         return
@@ -579,6 +600,9 @@ const TableView = () => {
         case 'fileset':
           dispatch(deleteFileset({ metalake, catalog, type, schema, fileset: 
confirmCacheData.name }))
           break
+        case 'table':
+          dispatch(deleteTable({ metalake, catalog, type, schema, table: 
confirmCacheData.name }))
+          break
         default:
           break
       }
@@ -603,6 +627,11 @@ const TableView = () => {
         searchParams.has('metalake') &&
         searchParams.has('catalog') &&
         searchParams.get('type') === 'fileset' &&
+        searchParams.has('schema')) ||
+      (paramsSize == 4 &&
+        searchParams.has('metalake') &&
+        searchParams.has('catalog') &&
+        searchParams.get('type') === 'relational' &&
         searchParams.has('schema'))
     ) {
       return actionsColumns
@@ -657,6 +686,8 @@ const TableView = () => {
         data={dialogData}
         type={dialogType}
       />
+
+      <CreateTableDialog open={openTableDialog} setOpen={setOpenTableDialog} 
data={dialogData} type={dialogType} />
     </Box>
   )
 }
diff --git a/web/web/src/lib/api/tables/index.js 
b/web/web/src/lib/api/tables/index.js
index 4c835dcd8..f2a786014 100644
--- a/web/web/src/lib/api/tables/index.js
+++ b/web/web/src/lib/api/tables/index.js
@@ -27,7 +27,13 @@ const Apis = {
   GET_DETAIL: ({ metalake, catalog, schema, table }) =>
     
`/api/metalakes/${encodeURIComponent(metalake)}/catalogs/${encodeURIComponent(
       catalog
-    
)}/schemas/${encodeURIComponent(schema)}/tables/${encodeURIComponent(table)}`
+    
)}/schemas/${encodeURIComponent(schema)}/tables/${encodeURIComponent(table)}`,
+  CREATE: ({ metalake, catalog, schema }) =>
+    
`/api/metalakes/${encodeURIComponent(metalake)}/catalogs/${encodeURIComponent(catalog)}/schemas/${encodeURIComponent(schema)}/tables`,
+  UPDATE: ({ metalake, catalog, schema, table }) =>
+    
`/api/metalakes/${encodeURIComponent(metalake)}/catalogs/${encodeURIComponent(catalog)}/schemas/${encodeURIComponent(schema)}/tables/${encodeURIComponent(table)}`,
+  DELETE: ({ metalake, catalog, schema, table }) =>
+    
`/api/metalakes/${encodeURIComponent(metalake)}/catalogs/${encodeURIComponent(catalog)}/schemas/${encodeURIComponent(schema)}/tables/${encodeURIComponent(table)}`
 }
 
 export const getTablesApi = params => {
@@ -41,3 +47,15 @@ export const getTableDetailsApi = ({ metalake, catalog, 
schema, table }) => {
     url: `${Apis.GET_DETAIL({ metalake, catalog, schema, table })}`
   })
 }
+
+export const createTableApi = ({ metalake, catalog, schema, data }) => {
+  return defHttp.post({ url: `${Apis.CREATE({ metalake, catalog, schema })}`, 
data })
+}
+
+export const updateTableApi = ({ metalake, catalog, schema, table, data }) => {
+  return defHttp.put({ url: `${Apis.UPDATE({ metalake, catalog, schema, table 
})}`, data })
+}
+
+export const deleteTableApi = ({ metalake, catalog, schema, table }) => {
+  return defHttp.delete({ url: `${Apis.DELETE({ metalake, catalog, schema, 
table })}` })
+}
diff --git a/web/web/src/lib/store/metalakes/index.js 
b/web/web/src/lib/store/metalakes/index.js
index 0499380af..6b8fedf32 100644
--- a/web/web/src/lib/store/metalakes/index.js
+++ b/web/web/src/lib/store/metalakes/index.js
@@ -46,7 +46,7 @@ import {
   updateSchemaApi,
   deleteSchemaApi
 } from '@/lib/api/schemas'
-import { getTablesApi, getTableDetailsApi } from '@/lib/api/tables'
+import { getTablesApi, getTableDetailsApi, createTableApi, updateTableApi, 
deleteTableApi } from '@/lib/api/tables'
 import {
   getFilesetsApi,
   getFilesetDetailsApi,
@@ -802,6 +802,67 @@ export const getTableDetails = createAsyncThunk(
   }
 )
 
+export const createTable = createAsyncThunk(
+  'appMetalakes/createTable',
+  async ({ data, metalake, catalog, type, schema }, { dispatch }) => {
+    dispatch(setTableLoading(true))
+    const [err, res] = await to(createTableApi({ data, metalake, catalog, 
schema }))
+    dispatch(setTableLoading(false))
+
+    if (err || !res) {
+      return { err: true }
+    }
+
+    const { table: tableItem } = res
+
+    const tableData = {
+      ...tableItem,
+      node: 'table',
+      id: 
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${tableItem.name}}}`,
+      key: 
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${tableItem.name}}}`,
+      path: `?${new URLSearchParams({ metalake, catalog, type, schema, table: 
tableItem.name }).toString()}`,
+      name: tableItem.name,
+      title: tableItem.name,
+      tables: [],
+      children: []
+    }
+
+    dispatch(fetchTables({ metalake, catalog, schema, type, init: true }))
+
+    return tableData
+  }
+)
+
+export const updateTable = createAsyncThunk(
+  'appMetalakes/updateTable',
+  async ({ metalake, catalog, type, schema, table, data }, { dispatch }) => {
+    const [err, res] = await to(updateTableApi({ metalake, catalog, schema, 
table, data }))
+    if (err || !res) {
+      return { err: true }
+    }
+    dispatch(fetchTables({ metalake, catalog, type, schema, init: true }))
+
+    return res.catalog
+  }
+)
+
+export const deleteTable = createAsyncThunk(
+  'appMetalakes/deleteTable',
+  async ({ metalake, catalog, type, schema, table }, { dispatch }) => {
+    dispatch(setTableLoading(true))
+    const [err, res] = await to(deleteTableApi({ metalake, catalog, schema, 
table }))
+    dispatch(setTableLoading(false))
+
+    if (err || !res) {
+      throw new Error(err)
+    }
+
+    dispatch(fetchTables({ metalake, catalog, type, schema, page: 'schemas', 
init: true }))
+
+    return res
+  }
+)
+
 export const fetchFilesets = createAsyncThunk(
   'appMetalakes/fetchFilesets',
   async ({ init, page, metalake, catalog, schema }, { getState, dispatch }) => 
{
diff --git a/web/web/src/lib/utils/index.js b/web/web/src/lib/utils/index.js
index 1524f1a23..d2dde2a4b 100644
--- a/web/web/src/lib/utils/index.js
+++ b/web/web/src/lib/utils/index.js
@@ -75,6 +75,57 @@ export const genUpdates = (originalData, newData) => {
     }
   }
 
+  const originalColumnsMap = _.keyBy(originalData.columns || [], 'uniqueId')
+  const newColumnsMap = _.keyBy(newData.columns || [], 'uniqueId')
+
+  for (const key in newColumnsMap) {
+    if (!(key in originalColumnsMap)) {
+      const { uniqueId, ...newColumn } = newColumnsMap[key]
+      updates.push({
+        '@type': 'addColumn',
+        fieldName: [newColumn.name],
+        type: newColumn.type,
+        nullable: newColumn.nullable,
+        comment: newColumn.comment
+      })
+    }
+  }
+
+  for (const key in originalColumnsMap) {
+    if (!(key in newColumnsMap)) {
+      updates.push({ '@type': 'deleteColumn', fieldName: 
[originalColumnsMap[key].name] })
+    } else {
+      if (originalColumnsMap[key].name !== newColumnsMap[key].name) {
+        updates.push({
+          '@type': 'renameColumn',
+          oldFieldName: [originalColumnsMap[key].name],
+          newFieldName: newColumnsMap[key].name
+        })
+      }
+      if (originalColumnsMap[key].type !== newColumnsMap[key].type) {
+        updates.push({
+          '@type': 'updateColumnType',
+          fieldName: [newColumnsMap[key].name],
+          newType: newColumnsMap[key].type
+        })
+      }
+      if (originalColumnsMap[key].nullable !== newColumnsMap[key].nullable) {
+        updates.push({
+          '@type': 'updateColumnNullability',
+          fieldName: [newColumnsMap[key].name],
+          nullable: newColumnsMap[key].nullable
+        })
+      }
+      if (originalColumnsMap[key].comment !== newColumnsMap[key].comment) {
+        updates.push({
+          '@type': 'updateColumnComment',
+          fieldName: [newColumnsMap[key].name],
+          newComment: newColumnsMap[key].comment
+        })
+      }
+    }
+  }
+
   return updates
 }
 
diff --git a/web/web/src/lib/utils/initial.js b/web/web/src/lib/utils/initial.js
index d8cad0944..9efd67a7a 100644
--- a/web/web/src/lib/utils/initial.js
+++ b/web/web/src/lib/utils/initial.js
@@ -346,3 +346,22 @@ export const providers = [
     ]
   }
 ]
+
+export const relationalTypes = [
+  { label: 'Boolean', value: 'boolean' },
+  { label: 'Byte', value: 'byte' },
+  { label: 'Short', value: 'short' },
+  { label: 'Integer', value: 'integer' },
+  { label: 'Long', value: 'long' },
+  { label: 'Float', value: 'float' },
+  { label: 'Double', value: 'double' },
+  { label: 'Date', value: 'date' },
+  { label: 'Time', value: 'time' },
+  { label: 'Timestamp', value: 'timestamp' },
+  { label: 'Timestamp_tz', value: 'timestamp_tz' },
+  { label: 'String', value: 'string' },
+  { label: 'Interval_day', value: 'interval_day' },
+  { label: 'Interval_year', value: 'interval_year' },
+  { label: 'Uuid', value: 'uuid' },
+  { label: 'Binary', value: 'binary' }
+]


Reply via email to