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 ab3e7cd32 [#5141][#5142][#5143] web(ui): Add support for creating, 
editing, and deleting schema (#5164)
ab3e7cd32 is described below

commit ab3e7cd328d30c8be5ff7fdb765e3ffaaa0a60e1
Author: Qian Xia <[email protected]>
AuthorDate: Tue Oct 22 18:34:18 2024 +0800

    [#5141][#5142][#5143] web(ui): Add support for creating, editing, and 
deleting schema (#5164)
    
    ### What changes were proposed in this pull request?
    Add support for creating, editing, and deleting schema
    <img width="831" alt="image"
    
src="https://github.com/user-attachments/assets/8194b0e1-0da4-4ed3-967a-3f7467745681";>
    <img width="1168" alt="image"
    
src="https://github.com/user-attachments/assets/e4735560-c928-4bbc-8452-aab82a0559b1";>
    <img width="613" alt="image"
    
src="https://github.com/user-attachments/assets/90c04707-a0be-47dc-a425-233759eab84f";>
    <img width="752" alt="image"
    
src="https://github.com/user-attachments/assets/59ec3f21-b3f9-4a2d-bf0b-44c396db1555";>
    <img width="954" alt="image"
    
src="https://github.com/user-attachments/assets/6b7993c1-9848-435e-8cc0-874f3a70195b";>
    <img width="1149" alt="image"
    
src="https://github.com/user-attachments/assets/889287f1-d4e9-45b2-8e7b-18970b42f108";>
    
    
    ### Why are the changes needed?
    N/A
    
    Fix: #5141, #5142, #5143
    
    ### Does this PR introduce _any_ user-facing change?
    N/A
    
    ### How was this patch tested?
    manually
---
 .../test/web/ui/pages/CatalogsPage.java            |   6 +-
 .../metalake/rightContent/CreateSchemaDialog.js    | 439 +++++++++++++++++++++
 .../metalake/rightContent/RightContent.js          |  37 +-
 .../tabsContent/tableView/TableView.js             | 163 +++++---
 web/web/src/components/DetailsDrawer.js            |  81 ++--
 web/web/src/lib/api/schemas/index.js               |  20 +-
 web/web/src/lib/icons/iconify-icons.css            |   2 +-
 web/web/src/lib/icons/svg/hudi.svg                 |   2 +-
 web/web/src/lib/store/metalakes/index.js           |  84 +++-
 9 files changed, 730 insertions(+), 104 deletions(-)

diff --git 
a/web/integration-test/src/test/java/org/apache/gravitino/integration/test/web/ui/pages/CatalogsPage.java
 
b/web/integration-test/src/test/java/org/apache/gravitino/integration/test/web/ui/pages/CatalogsPage.java
index 222cbd227..b397c26a7 100644
--- 
a/web/integration-test/src/test/java/org/apache/gravitino/integration/test/web/ui/pages/CatalogsPage.java
+++ 
b/web/integration-test/src/test/java/org/apache/gravitino/integration/test/web/ui/pages/CatalogsPage.java
@@ -186,7 +186,7 @@ public class CatalogsPage extends BaseWebIT {
 
   public void clickViewCatalogBtn(String name) {
     try {
-      String xpath = "//button[@data-refer='view-catalog-" + name + "']";
+      String xpath = "//button[@data-refer='view-entity-" + name + "']";
       WebElement btn = driver.findElement(By.xpath(xpath));
       WebDriverWait wait = new WebDriverWait(driver, MAX_TIMEOUT);
       wait.until(ExpectedConditions.elementToBeClickable(By.xpath(xpath)));
@@ -198,7 +198,7 @@ public class CatalogsPage extends BaseWebIT {
 
   public void clickEditCatalogBtn(String name) {
     try {
-      String xpath = "//button[@data-refer='edit-catalog-" + name + "']";
+      String xpath = "//button[@data-refer='edit-entity-" + name + "']";
       WebElement btn = driver.findElement(By.xpath(xpath));
       WebDriverWait wait = new WebDriverWait(driver, MAX_TIMEOUT);
       wait.until(ExpectedConditions.elementToBeClickable(By.xpath(xpath)));
@@ -210,7 +210,7 @@ public class CatalogsPage extends BaseWebIT {
 
   public void clickDeleteCatalogBtn(String name) {
     try {
-      String xpath = "//button[@data-refer='delete-catalog-" + name + "']";
+      String xpath = "//button[@data-refer='delete-entity-" + name + "']";
       WebElement btn = driver.findElement(By.xpath(xpath));
       WebDriverWait wait = new WebDriverWait(driver, MAX_TIMEOUT);
       wait.until(ExpectedConditions.elementToBeClickable(By.xpath(xpath)));
diff --git 
a/web/web/src/app/metalakes/metalake/rightContent/CreateSchemaDialog.js 
b/web/web/src/app/metalakes/metalake/rightContent/CreateSchemaDialog.js
new file mode 100644
index 000000000..9b90c0bb6
--- /dev/null
+++ b/web/web/src/app/metalakes/metalake/rightContent/CreateSchemaDialog.js
@@ -0,0 +1,439 @@
+/*
+ * 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.
+ */
+
+'use client'
+
+import { useState, forwardRef, useEffect, Fragment } from 'react'
+
+import {
+  Box,
+  Grid,
+  Button,
+  Dialog,
+  TextField,
+  Typography,
+  DialogContent,
+  DialogActions,
+  IconButton,
+  Fade,
+  Select,
+  MenuItem,
+  InputLabel,
+  FormControl,
+  FormHelperText
+} from '@mui/material'
+
+import Icon from '@/components/Icon'
+
+import { useAppDispatch } from '@/lib/hooks/useStore'
+import { createSchema, updateSchema } from '@/lib/store/metalakes'
+
+import * as yup from 'yup'
+import { useForm, Controller } from 'react-hook-form'
+import { yupResolver } from '@hookform/resolvers/yup'
+
+import { groupBy } from 'lodash-es'
+import { genUpdates } from '@/lib/utils'
+import { nameRegex, nameRegexDesc, keyRegex } from '@/lib/utils/regex'
+import { useSearchParams } from 'next/navigation'
+import { useAppSelector } from '@/lib/hooks/useStore'
+
+const defaultValues = {
+  name: '',
+  comment: '',
+  propItems: []
+}
+
+const schema = yup.object().shape({
+  name: yup.string().required().matches(nameRegex, nameRegexDesc),
+  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()
+      })
+    })
+  )
+})
+
+const Transition = forwardRef(function Transition(props, ref) {
+  return <Fade ref={ref} {...props} />
+})
+
+const CreateSchemaDialog = props => {
+  const { open, setOpen, type = 'create', data = {} } = props
+  const searchParams = useSearchParams()
+  const metalake = searchParams.get('metalake')
+  const catalog = searchParams.get('catalog')
+  const catalogType = searchParams.get('type')
+  const [innerProps, setInnerProps] = useState([])
+  const dispatch = useAppDispatch()
+  const store = useAppSelector(state => state.metalakes)
+  const activatedCatalogDetail = store.activatedDetails
+  const [cacheData, setCacheData] = useState()
+
+  const {
+    control,
+    reset,
+    watch,
+    setValue,
+    getValues,
+    handleSubmit,
+    trigger,
+    formState: { errors }
+  } = useForm({
+    defaultValues,
+    mode: 'all',
+    resolver: yupResolver(schema)
+  })
+
+  const handleFormChange = ({ index, event }) => {
+    let data = [...innerProps]
+    data[index][event.target.name] = event.target.value
+
+    if (event.target.name === 'key') {
+      const invalidKey = !keyRegex.test(event.target.value)
+      data[index].invalid = invalidKey
+    }
+
+    const nonEmptyKeys = data.filter(item => item.key.trim() !== '')
+    const grouped = groupBy(nonEmptyKeys, 'key')
+    const duplicateKeys = Object.keys(grouped).some(key => grouped[key].length 
> 1)
+
+    if (duplicateKeys) {
+      data[index].hasDuplicateKey = duplicateKeys
+    } else {
+      data.forEach(it => (it.hasDuplicateKey = false))
+    }
+
+    setInnerProps(data)
+    setValue('propItems', data)
+  }
+
+  const addFields = () => {
+    const duplicateKeys = innerProps
+      .filter(item => item.key.trim() !== '')
+      .some(
+        (item, index, filteredItems) =>
+          filteredItems.findIndex(otherItem => otherItem !== item && 
otherItem.key.trim() === item.key.trim()) !== -1
+      )
+
+    if (duplicateKeys) {
+      return
+    }
+
+    let newField = { key: '', value: '', required: false }
+
+    setInnerProps([...innerProps, newField])
+    setValue('propItems', [...innerProps, newField])
+  }
+
+  const removeFields = index => {
+    let data = [...innerProps]
+    data.splice(index, 1)
+    setInnerProps(data)
+    setValue('propItems', data)
+  }
+
+  const handleClose = () => {
+    reset()
+    setInnerProps([])
+    setValue('propItems', [])
+    setOpen(false)
+  }
+
+  const handleClickSubmit = e => {
+    e.preventDefault()
+
+    return handleSubmit(onSubmit(getValues()), onError)
+  }
+
+  const onSubmit = data => {
+    const duplicateKeys = innerProps
+      .filter(item => item.key.trim() !== '')
+      .some(
+        (item, index, filteredItems) =>
+          filteredItems.findIndex(otherItem => otherItem !== item && 
otherItem.key.trim() === item.key.trim()) !== -1
+      )
+
+    const invalidKeys = innerProps.some(i => i.invalid)
+
+    if (duplicateKeys || invalidKeys) {
+      return
+    }
+
+    trigger()
+
+    schema
+      .validate(data)
+      .then(() => {
+        const properties = innerProps.reduce((acc, item) => {
+          acc[item.key] = item.value
+
+          return acc
+        }, {})
+
+        const schemaData = {
+          name: data.name,
+          comment: data.comment,
+          properties
+        }
+
+        if (type === 'create') {
+          dispatch(createSchema({ data: schemaData, metalake, catalog, type: 
catalogType })).then(res => {
+            if (!res.payload?.err) {
+              handleClose()
+            }
+          })
+        } else {
+          const reqData = { updates: genUpdates(cacheData, schemaData) }
+
+          if (reqData.updates.length !== 0) {
+            dispatch(
+              updateSchema({ metalake, catalog, type: catalogType, schema: 
cacheData.name, data: reqData })
+            ).then(res => {
+              if (!res.payload?.err) {
+                handleClose()
+              }
+            })
+          }
+        }
+      })
+      .catch(err => {
+        console.error('valid error', err)
+      })
+  }
+
+  const onError = errors => {
+    console.error('fields error', errors)
+  }
+
+  useEffect(() => {
+    if (open && JSON.stringify(data) !== '{}') {
+      const { properties = {} } = data
+
+      setCacheData(data)
+      setValue('name', data.name)
+      setValue('comment', data.comment)
+
+      const propsItems = Object.entries(properties).map(([key, value]) => {
+        return {
+          key,
+          value
+        }
+      })
+
+      setInnerProps(propsItems)
+      setValue('propItems', propsItems)
+    }
+  }, [open, data, setValue, type])
+
+  return (
+    <Dialog fullWidth maxWidth='sm' scroll='body' 
TransitionComponent={Transition} open={open} onClose={handleClose}>
+      <form onSubmit={e => handleClickSubmit(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={() => handleClose()}
+            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'} Schema
+            </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=''
+                      disabled={type === 'update'}
+                      error={Boolean(errors.name)}
+                      data-refer='schema-name-field'
+                    />
+                  )}
+                />
+                {errors.name && <FormHelperText sx={{ color: 'error.main' 
}}>{errors.name.message}</FormHelperText>}
+              </FormControl>
+            </Grid>
+
+            {!['jdbc-mysql', 
'lakehouse-paimon'].includes(activatedCatalogDetail?.provider) && (
+              <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=''
+                        disabled={type === 'update'}
+                        error={Boolean(errors.comment)}
+                        data-refer='schema-comment-field'
+                      />
+                    )}
+                  />
+                </FormControl>
+              </Grid>
+            )}
+
+            {!['jdbc-postgresql', 'lakehouse-paimon', 'kafka', 
'jdbc-mysql'].includes(
+              activatedCatalogDetail?.provider
+            ) && (
+              <Grid item xs={12} data-refer='schema-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={`schema-props-${index}`}
+                            >
+                              <Box>
+                                <TextField
+                                  size='small'
+                                  name='key'
+                                  label='Key'
+                                  value={item.key}
+                                  disabled={item.disabled || (item.key === 
'location' && type === 'update')}
+                                  onChange={event => handleFormChange({ 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 || (item.key === 
'location' && type === 'update')}
+                                  onChange={event => handleFormChange({ index, 
event })}
+                                  data-refer={`props-value-${index}`}
+                                  data-prev-refer={`props-${item.key}`}
+                                />
+                              </Box>
+
+                              {!(item.disabled || (item.key === 'location' && 
type === 'update')) ? (
+                                <Box sx={{ minWidth: 40 }}>
+                                  <IconButton onClick={() => 
removeFields(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>
+            )}
+
+            {!['jdbc-postgresql', 'lakehouse-paimon', 'kafka', 
'jdbc-mysql'].includes(
+              activatedCatalogDetail?.provider
+            ) && (
+              <Grid item xs={12}>
+                <Button
+                  size='small'
+                  onClick={addFields}
+                  variant='outlined'
+                  startIcon={<Icon icon='mdi:plus-circle-outline' />}
+                  data-refer='add-schema-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-schema'>
+            {type === 'create' ? 'Create' : 'Update'}
+          </Button>
+          <Button variant='outlined' onClick={handleClose}>
+            Cancel
+          </Button>
+        </DialogActions>
+      </form>
+    </Dialog>
+  )
+}
+
+export default CreateSchemaDialog
diff --git a/web/web/src/app/metalakes/metalake/rightContent/RightContent.js 
b/web/web/src/app/metalakes/metalake/rightContent/RightContent.js
index 050f84ad5..1706399dd 100644
--- a/web/web/src/app/metalakes/metalake/rightContent/RightContent.js
+++ b/web/web/src/app/metalakes/metalake/rightContent/RightContent.js
@@ -25,23 +25,38 @@ import { Box, Button, IconButton } from '@mui/material'
 import Icon from '@/components/Icon'
 import MetalakePath from './MetalakePath'
 import CreateCatalogDialog from './CreateCatalogDialog'
+import CreateSchemaDialog from './CreateSchemaDialog'
 import TabsContent from './tabsContent/TabsContent'
 import { useSearchParams } from 'next/navigation'
+import { useAppSelector } from '@/lib/hooks/useStore'
 
 const RightContent = () => {
   const [open, setOpen] = useState(false)
+  const [openSchema, setOpenSchema] = useState(false)
   const searchParams = useSearchParams()
-  const [isShowBtn, setBtnVisiable] = useState(true)
+  const [isShowBtn, setBtnVisible] = useState(true)
+  const [isShowSchemaBtn, setSchemaBtnVisible] = useState(false)
+  const store = useAppSelector(state => state.metalakes)
 
   const handleCreateCatalog = () => {
     setOpen(true)
   }
 
+  const handleCreateSchema = () => {
+    setOpenSchema(true)
+  }
+
   useEffect(() => {
     const paramsSize = [...searchParams.keys()].length
-    const isMetalakePage = paramsSize == 1 && searchParams.get('metalake')
-    setBtnVisiable(isMetalakePage)
-  }, [searchParams])
+    const isCatalogList = paramsSize == 1 && searchParams.get('metalake')
+    setBtnVisible(isCatalogList)
+
+    if (store.catalogs.length) {
+      const currentCatalog = store.catalogs.filter(ca => ca.name === 
searchParams.get('catalog'))[0]
+      const isHideSchemaAction = ['lakehouse-hudi', 
'kafka'].includes(currentCatalog?.provider) && paramsSize == 3
+      setSchemaBtnVisible(!isHideSchemaAction && !isCatalogList)
+    }
+  }, [searchParams, store.catalogs, store.catalogs.length])
 
   return (
     <Box className={`twc-w-0 twc-grow twc-h-full twc-bg-customs-white 
twc-overflow-hidden`}>
@@ -76,6 +91,20 @@ const RightContent = () => {
             <CreateCatalogDialog open={open} setOpen={setOpen} />
           </Box>
         )}
+        {isShowSchemaBtn && (
+          <Box className={`twc-flex twc-items-center`}>
+            <Button
+              variant='contained'
+              startIcon={<Icon icon='mdi:plus-box' />}
+              onClick={handleCreateSchema}
+              sx={{ width: 200 }}
+              data-refer='create-schema-btn'
+            >
+              Create Schema
+            </Button>
+            <CreateSchemaDialog open={openSchema} setOpen={setOpenSchema} />
+          </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 08a4e6349..cdc94c776 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
@@ -40,12 +40,14 @@ import ColumnTypeChip from '@/components/ColumnTypeChip'
 import DetailsDrawer from '@/components/DetailsDrawer'
 import ConfirmDeleteDialog from '@/components/ConfirmDeleteDialog'
 import CreateCatalogDialog from '../../CreateCatalogDialog'
+import CreateSchemaDialog from '../../CreateSchemaDialog'
 
 import { useAppSelector, useAppDispatch } from '@/lib/hooks/useStore'
-import { updateCatalog, deleteCatalog } from '@/lib/store/metalakes'
+import { deleteCatalog, deleteSchema } from '@/lib/store/metalakes'
 
 import { to } from '@/lib/utils'
 import { getCatalogDetailsApi } from '@/lib/api/catalogs'
+import { getSchemaDetailsApi } from '@/lib/api/schemas'
 import { useSearchParams } from 'next/navigation'
 
 const fonts = Inconsolata({ subsets: ['latin'] })
@@ -72,6 +74,8 @@ const TableView = () => {
   const searchParams = useSearchParams()
   const paramsSize = [...searchParams.keys()].length
   const metalake = searchParams.get('metalake') || ''
+  const catalog = searchParams.get('catalog') || ''
+  const type = searchParams.get('type') || ''
 
   const defaultPaginationConfig = { pageSize: 10, page: 0 }
   const pageSizeOptions = [10, 25, 50]
@@ -86,8 +90,18 @@ const TableView = () => {
   const [confirmCacheData, setConfirmCacheData] = useState(null)
   const [openConfirmDelete, setOpenConfirmDelete] = useState(false)
   const [openDialog, setOpenDialog] = useState(false)
+  const [openSchemaDialog, setOpenSchemaDialog] = useState(false)
   const [dialogData, setDialogData] = useState({})
   const [dialogType, setDialogType] = useState('create')
+  const [isHideSchemaEdit, setIsHideSchemaEdit] = useState(true)
+
+  useEffect(() => {
+    if (store.catalogs.length) {
+      const currentCatalog = store.catalogs.filter(ca => ca.name === 
catalog)[0]
+      const isHideSchemaAction = ['lakehouse-hudi', 
'kafka'].includes(currentCatalog?.provider) && paramsSize == 3
+      setIsHideSchemaEdit(isHideSchemaAction)
+    }
+  }, [store.catalogs, store.catalogs.length, paramsSize, catalog])
 
   const handleClickUrl = path => {
     if (!path) {
@@ -200,7 +214,7 @@ const TableView = () => {
     }
   ]
 
-  const catalogsColumns = [
+  const actionsColumns = [
     {
       flex: 0.1,
       minWidth: 60,
@@ -251,31 +265,35 @@ const TableView = () => {
             title='Details'
             size='small'
             sx={{ color: theme => theme.palette.text.secondary }}
-            onClick={() => handleShowDetails({ row, type: 'catalog' })}
-            data-refer={`view-catalog-${row.name}`}
+            onClick={() => handleShowDetails({ row, type: row.node })}
+            data-refer={`view-entity-${row.name}`}
           >
             <ViewIcon viewBox='0 0 24 22' />
           </IconButton>
 
-          <IconButton
-            title='Edit'
-            size='small'
-            sx={{ color: theme => theme.palette.text.secondary }}
-            onClick={() => handleShowEditDialog({ row, type: 'catalog' })}
-            data-refer={`edit-catalog-${row.name}`}
-          >
-            <EditIcon />
-          </IconButton>
-
-          <IconButton
-            title='Delete'
-            size='small'
-            sx={{ color: theme => theme.palette.error.light }}
-            onClick={() => handleDelete({ name: row.name, type: 'catalog', 
catalogType: row.type })}
-            data-refer={`delete-catalog-${row.name}`}
-          >
-            <DeleteIcon />
-          </IconButton>
+          {!isHideSchemaEdit && (
+            <IconButton
+              title='Edit'
+              size='small'
+              sx={{ color: theme => theme.palette.text.secondary }}
+              onClick={() => handleShowEditDialog({ row, type: row.node })}
+              data-refer={`edit-entity-${row.name}`}
+            >
+              <EditIcon />
+            </IconButton>
+          )}
+
+          {!isHideSchemaEdit && (
+            <IconButton
+              title='Delete'
+              size='small'
+              sx={{ color: theme => theme.palette.error.light }}
+              onClick={() => handleDelete({ name: row.name, type: row.node, 
catalogType: row.type })}
+              data-refer={`delete-entity-${row.name}`}
+            >
+              <DeleteIcon />
+            </IconButton>
+          )}
         </>
       )
     }
@@ -422,34 +440,66 @@ const TableView = () => {
   ]
 
   const handleShowDetails = async ({ row, type }) => {
-    if (type === 'catalog') {
-      const [err, res] = await to(getCatalogDetailsApi({ metalake, catalog: 
row.name }))
+    switch (type) {
+      case 'catalog': {
+        const [err, res] = await to(getCatalogDetailsApi({ metalake, catalog: 
row.name }))
 
-      if (err || !res) {
-        throw new Error(err)
+        if (err || !res) {
+          throw new Error(err)
+        }
+
+        setDrawerData(res.catalog)
+        setOpenDrawer(true)
+        break
       }
+      case 'schema': {
+        const [err, res] = await to(getSchemaDetailsApi({ metalake, catalog, 
schema: row.name }))
+
+        if (err || !res) {
+          throw new Error(err)
+        }
 
-      setDrawerData(res.catalog)
-      setOpenDrawer(true)
+        setDrawerData(res.schema)
+        setOpenDrawer(true)
+        break
+      }
+      default:
+        return
     }
   }
 
   const handleShowEditDialog = async data => {
-    const metalake = data.row.namespace[0] || null
-    const catalog = data.row.name || null
+    switch (data.type) {
+      case 'catalog': {
+        const [err, res] = await to(getCatalogDetailsApi({ metalake, catalog: 
data.row?.name }))
 
-    if (metalake && catalog) {
-      const [err, res] = await to(getCatalogDetailsApi({ metalake, catalog }))
+        if (err || !res) {
+          throw new Error(err)
+        }
 
-      if (err || !res) {
-        throw new Error(err)
+        const { catalog: resCatalog } = res
+
+        setDialogType('update')
+        setDialogData(resCatalog)
+        setOpenDialog(true)
+        break
       }
+      case 'schema': {
+        if (metalake && catalog) {
+          const [err, res] = await to(getSchemaDetailsApi({ metalake, catalog, 
schema: data.row?.name }))
 
-      const { catalog: resCatalog } = res
+          if (err || !res) {
+            throw new Error(err)
+          }
 
-      setDialogType('update')
-      setDialogData(resCatalog)
-      setOpenDialog(true)
+          setDialogType('update')
+          setDialogData(res.schema)
+          setOpenSchemaDialog(true)
+        }
+        break
+      }
+      default:
+        return
     }
   }
 
@@ -465,8 +515,15 @@ const TableView = () => {
 
   const handleConfirmDeleteSubmit = () => {
     if (confirmCacheData) {
-      if (confirmCacheData.type === 'catalog') {
-        dispatch(deleteCatalog({ metalake, catalog: confirmCacheData.name, 
type: confirmCacheData.catalogType }))
+      switch (confirmCacheData.type) {
+        case 'catalog':
+          dispatch(deleteCatalog({ metalake, catalog: confirmCacheData.name, 
type: confirmCacheData.catalogType }))
+          break
+        case 'schema':
+          dispatch(deleteSchema({ metalake, catalog, type, schema: 
confirmCacheData.name }))
+          break
+        default:
+          break
       }
 
       setOpenConfirmDelete(false)
@@ -474,8 +531,11 @@ const TableView = () => {
   }
 
   const checkColumns = () => {
-    if (paramsSize == 1 && searchParams.has('metalake')) {
-      return catalogsColumns
+    if (
+      (paramsSize == 1 && searchParams.has('metalake')) ||
+      (paramsSize == 3 && searchParams.has('metalake') && 
searchParams.has('catalog') && searchParams.has('type'))
+    ) {
+      return actionsColumns
     } else if (paramsSize == 5 && searchParams.has('table')) {
       return tableColumns
     } else {
@@ -508,12 +568,7 @@ const TableView = () => {
         paginationModel={paginationModel}
         onPaginationModelChange={setPaginationModel}
       />
-      <DetailsDrawer
-        openDrawer={openDrawer}
-        setOpenDrawer={setOpenDrawer}
-        drawerData={drawerData}
-        isMetalakePage={paramsSize == 1 && 
searchParams.hasOwnProperty('metalake')}
-      />
+      <DetailsDrawer openDrawer={openDrawer} setOpenDrawer={setOpenDrawer} 
drawerData={drawerData} />
       <ConfirmDeleteDialog
         open={openConfirmDelete}
         setOpen={setOpenConfirmDelete}
@@ -522,13 +577,9 @@ const TableView = () => {
         handleConfirmDeleteSubmit={handleConfirmDeleteSubmit}
       />
 
-      <CreateCatalogDialog
-        open={openDialog}
-        setOpen={setOpenDialog}
-        updateCatalog={updateCatalog}
-        data={dialogData}
-        type={dialogType}
-      />
+      <CreateCatalogDialog open={openDialog} setOpen={setOpenDialog} 
data={dialogData} type={dialogType} />
+
+      <CreateSchemaDialog open={openSchemaDialog} 
setOpen={setOpenSchemaDialog} data={dialogData} type={dialogType} />
     </Box>
   )
 }
diff --git a/web/web/src/components/DetailsDrawer.js 
b/web/web/src/components/DetailsDrawer.js
index a310e5fb1..c470af43c 100644
--- a/web/web/src/components/DetailsDrawer.js
+++ b/web/web/src/components/DetailsDrawer.js
@@ -41,7 +41,7 @@ import EmptyText from '@/components/EmptyText'
 import { formatToDateTime, isValidDate } from '@/lib/utils/date'
 
 const DetailsDrawer = props => {
-  const { openDrawer, setOpenDrawer, drawerData = {}, isMetalakePage } = props
+  const { openDrawer, setOpenDrawer, drawerData = {} } = props
 
   const { audit = {} } = drawerData
 
@@ -125,22 +125,23 @@ const DetailsDrawer = props => {
           </Typography>
         </Grid>
 
-        {isMetalakePage ? (
-          <>
-            <Grid item xs={12} md={6} sx={{ mb: [0, 5] }}>
-              <Typography variant='body2' sx={{ mb: 2 }}>
-                Type
-              </Typography>
-              {renderFieldText({ value: drawerData.type })}
-            </Grid>
-            <Grid item xs={12} md={6} sx={{ mb: [0, 5] }}>
-              <Typography variant='body2' sx={{ mb: 2 }}>
-                Provider
-              </Typography>
-              {renderFieldText({ value: drawerData.provider })}
-            </Grid>
-          </>
-        ) : null}
+        {drawerData.type && (
+          <Grid item xs={12} md={6} sx={{ mb: [0, 5] }}>
+            <Typography variant='body2' sx={{ mb: 2 }}>
+              Type
+            </Typography>
+            {renderFieldText({ value: drawerData.type })}
+          </Grid>
+        )}
+
+        {drawerData.provider && (
+          <Grid item xs={12} md={6} sx={{ mb: [0, 5] }}>
+            <Typography variant='body2' sx={{ mb: 2 }}>
+              Provider
+            </Typography>
+            {renderFieldText({ value: drawerData.provider })}
+          </Grid>
+        )}
 
         <Grid item xs={12} sx={{ mb: [0, 5] }}>
           <Typography variant='body2' sx={{ mb: 2 }}>
@@ -156,26 +157,32 @@ const DetailsDrawer = props => {
           {renderFieldText({ value: audit.creator })}
         </Grid>
 
-        <Grid item xs={12} sx={{ mb: [0, 5] }}>
-          <Typography variant='body2' sx={{ mb: 2 }}>
-            Created at
-          </Typography>
-          {renderFieldText({ value: audit.createTime, isDate: true })}
-        </Grid>
-
-        <Grid item xs={12} sx={{ mb: [0, 5] }}>
-          <Typography variant='body2' sx={{ mb: 2 }}>
-            Last modified by
-          </Typography>
-          {renderFieldText({ value: audit.lastModifier })}
-        </Grid>
-
-        <Grid item xs={12} sx={{ mb: [0, 5] }}>
-          <Typography variant='body2' sx={{ mb: 2 }}>
-            Last modified at
-          </Typography>
-          {renderFieldText({ value: audit.lastModifiedTime, isDate: true })}
-        </Grid>
+        {audit.createTime && (
+          <Grid item xs={12} sx={{ mb: [0, 5] }}>
+            <Typography variant='body2' sx={{ mb: 2 }}>
+              Created at
+            </Typography>
+            {renderFieldText({ value: audit.createTime, isDate: true })}
+          </Grid>
+        )}
+
+        {audit.lastModifier && (
+          <Grid item xs={12} sx={{ mb: [0, 5] }}>
+            <Typography variant='body2' sx={{ mb: 2 }}>
+              Last modified by
+            </Typography>
+            {renderFieldText({ value: audit.lastModifier })}
+          </Grid>
+        )}
+
+        {audit.lastModifiedTime && (
+          <Grid item xs={12} sx={{ mb: [0, 5] }}>
+            <Typography variant='body2' sx={{ mb: 2 }}>
+              Last modified at
+            </Typography>
+            {renderFieldText({ value: audit.lastModifiedTime, isDate: true })}
+          </Grid>
+        )}
 
         <Grid item xs={12} sx={{ mb: [0, 5] }}>
           <Typography variant='body2' sx={{ mb: 2 }}>
diff --git a/web/web/src/lib/api/schemas/index.js 
b/web/web/src/lib/api/schemas/index.js
index 654ed4017..0848c1e54 100644
--- a/web/web/src/lib/api/schemas/index.js
+++ b/web/web/src/lib/api/schemas/index.js
@@ -25,7 +25,13 @@ const Apis = {
   GET_DETAIL: ({ metalake, catalog, schema }) =>
     
`/api/metalakes/${encodeURIComponent(metalake)}/catalogs/${encodeURIComponent(
       catalog
-    )}/schemas/${encodeURIComponent(schema)}`
+    )}/schemas/${encodeURIComponent(schema)}`,
+  CREATE: ({ metalake, catalog }) =>
+    
`/api/metalakes/${encodeURIComponent(metalake)}/catalogs/${encodeURIComponent(catalog)}/schemas`,
+  UPDATE: ({ metalake, catalog, schema }) =>
+    
`/api/metalakes/${encodeURIComponent(metalake)}/catalogs/${encodeURIComponent(catalog)}/schemas/${encodeURIComponent(schema)}`,
+  DELETE: ({ metalake, catalog, schema }) =>
+    
`/api/metalakes/${encodeURIComponent(metalake)}/catalogs/${encodeURIComponent(catalog)}/schemas/${encodeURIComponent(schema)}`
 }
 
 export const getSchemasApi = params => {
@@ -39,3 +45,15 @@ export const getSchemaDetailsApi = ({ metalake, catalog, 
schema }) => {
     url: `${Apis.GET_DETAIL({ metalake, catalog, schema })}`
   })
 }
+
+export const createSchemaApi = ({ metalake, catalog, data }) => {
+  return defHttp.post({ url: `${Apis.CREATE({ metalake, catalog })}`, data })
+}
+
+export const updateSchemaApi = ({ metalake, catalog, schema, data }) => {
+  return defHttp.put({ url: `${Apis.UPDATE({ metalake, catalog, schema })}`, 
data })
+}
+
+export const deleteSchemaApi = ({ metalake, catalog, schema }) => {
+  return defHttp.delete({ url: `${Apis.DELETE({ metalake, catalog, schema })}` 
})
+}
diff --git a/web/web/src/lib/icons/iconify-icons.css 
b/web/web/src/lib/icons/iconify-icons.css
index f5803fdf9..465c98809 100644
--- a/web/web/src/lib/icons/iconify-icons.css
+++ b/web/web/src/lib/icons/iconify-icons.css
@@ -38,7 +38,7 @@
 }
 
 .custom-icons-hudi {
-  background-image: url("data:image/svg+xml,%3Csvg 
xmlns='http://www.w3.org/2000/svg' viewBox='0 0 2000 2000' width='2000' 
height='2000'%3E%3Cpath fill='%23FFF' d='M0 1000V0h2000v2000H0zm1529 
553c0-5-102-212-227-461l-228-453-22 46c-20 42-25 45-60 
45h-38l-54-110c-30-61-57-110-60-110-4 0-470 924-470 933 0 1 59 1 
130-1l130-4v41c0 23-5 51-10 62-11 19-2 19 450 19 253 0 460-3 459-7m290-154 
103-84-47-14-47-15 6-111c8-127-5-191-56-288-82-154-302-287-433-263-32 6-33 7-23 
45l10 38 82 5c102 7 177 4 [...]
+  background-image: url("data:image/svg+xml,%3Csvg 
xmlns='http://www.w3.org/2000/svg' viewBox='0 0 2000 2000' width='2000' 
height='2000'%3E%3Cpath fill='transparent' d='M0 1000V0h2000v2000H0zm1529 
553c0-5-102-212-227-461l-228-453-22 46c-20 42-25 45-60 
45h-38l-54-110c-30-61-57-110-60-110-4 0-470 924-470 933 0 1 59 1 
130-1l130-4v41c0 23-5 51-10 62-11 19-2 19 450 19 253 0 460-3 459-7m290-154 
103-84-47-14-47-15 6-111c8-127-5-191-56-288-82-154-302-287-433-263-32 6-33 7-23 
45l10 38 82 5c102 7  [...]
 }
 
 .custom-icons-paimon {
diff --git a/web/web/src/lib/icons/svg/hudi.svg 
b/web/web/src/lib/icons/svg/hudi.svg
index 8dfc3cb27..19a86a1c9 100644
--- a/web/web/src/lib/icons/svg/hudi.svg
+++ b/web/web/src/lib/icons/svg/hudi.svg
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <svg xmlns="http://www.w3.org/2000/svg"; width="1000" height="1000" viewBox="0 
0 2000 2000">
-  <g id="l65Z6PrNyXPHoWyqfb2ioux" fill="rgb(255,255,255)" style="transform: 
none;">
+  <g id="l65Z6PrNyXPHoWyqfb2ioux" fill="transparent" style="transform: none;">
     <g style="transform: none;">
       <path id="pg3BNCSpi" d="M0 1000 l0 -1000 1000 0 1000 0 0 1000 0 1000 
-1000 0 -1000 0 0 -1000z m1529 553 c0 -5 -102 -212 -227 -461 l-228 -453 -22 46 
c-20 42 -25 45 -60 45 l-38 0 -54 -110 c-30 -61 -57 -110 -60 -110 -4 0 -470 924 
-470 933 0 1 59 1 130 -1 l130 -4 0 41 c0 23 -5 51 -10 62 -11 19 -2 19 450 19 
253 0 460 -3 459 -7z m290 -154 l103 -84 -47 -14 -47 -15 6 -111 c8 -127 -5 -191 
-56 -288 -82 -154 -302 -287 -433 -263 -32 6 -33 7 -23 45 l10 38 82 5 c102 7 177 
41 242 110 68 73 96 142 [...]
     </g>
diff --git a/web/web/src/lib/store/metalakes/index.js 
b/web/web/src/lib/store/metalakes/index.js
index 5dd555010..7c58e80e4 100644
--- a/web/web/src/lib/store/metalakes/index.js
+++ b/web/web/src/lib/store/metalakes/index.js
@@ -39,7 +39,13 @@ import {
   updateCatalogApi,
   deleteCatalogApi
 } from '@/lib/api/catalogs'
-import { getSchemasApi, getSchemaDetailsApi } from '@/lib/api/schemas'
+import {
+  getSchemasApi,
+  getSchemaDetailsApi,
+  createSchemaApi,
+  updateSchemaApi,
+  deleteSchemaApi
+} from '@/lib/api/schemas'
 import { getTablesApi, getTableDetailsApi } from '@/lib/api/tables'
 import { getFilesetsApi, getFilesetDetailsApi } from '@/lib/api/filesets'
 import { getTopicsApi, getTopicDetailsApi } from '@/lib/api/topics'
@@ -549,6 +555,67 @@ export const getSchemaDetails = createAsyncThunk(
   }
 )
 
+export const createSchema = createAsyncThunk(
+  'appMetalakes/createSchema',
+  async ({ data, metalake, catalog, type }, { dispatch }) => {
+    dispatch(setTableLoading(true))
+    const [err, res] = await to(createSchemaApi({ data, metalake, catalog }))
+    dispatch(setTableLoading(false))
+
+    if (err || !res) {
+      return { err: true }
+    }
+
+    const { schema: schemaItem } = res
+
+    const schemaData = {
+      ...schemaItem,
+      node: 'schema',
+      id: `{{${metalake}}}{{${catalog}}}{{${type}}}{{${schemaItem.name}}}`,
+      key: `{{${metalake}}}{{${catalog}}}{{${type}}}{{${schemaItem.name}}}`,
+      path: `?${new URLSearchParams({ metalake, catalog, type, schema: 
schemaItem.name }).toString()}`,
+      name: schemaItem.name,
+      title: schemaItem.name,
+      tables: [],
+      children: []
+    }
+
+    dispatch(fetchSchemas({ metalake, catalog, type, init: true }))
+
+    return schemaData
+  }
+)
+
+export const updateSchema = createAsyncThunk(
+  'appMetalakes/updateSchema',
+  async ({ metalake, catalog, type, schema, data }, { dispatch }) => {
+    const [err, res] = await to(updateSchemaApi({ metalake, catalog, schema, 
data }))
+    if (err || !res) {
+      return { err: true }
+    }
+    dispatch(fetchSchemas({ metalake, catalog, type, init: true }))
+
+    return res.catalog
+  }
+)
+
+export const deleteSchema = createAsyncThunk(
+  'appMetalakes/deleteSchema',
+  async ({ metalake, catalog, type, schema }, { dispatch }) => {
+    dispatch(setTableLoading(true))
+    const [err, res] = await to(deleteSchemaApi({ metalake, catalog, schema }))
+    dispatch(setTableLoading(false))
+
+    if (err || !res) {
+      throw new Error(err)
+    }
+
+    dispatch(fetchSchemas({ metalake, catalog, type, page: 'catalogs', init: 
true }))
+
+    return res
+  }
+)
+
 export const fetchTables = createAsyncThunk(
   'appMetalakes/fetchTables',
   async ({ init, page, metalake, catalog, schema }, { getState, dispatch }) => 
{
@@ -1087,6 +1154,21 @@ export const appMetalakesSlice = createSlice({
         toast.error(action.error.message)
       }
     })
+    builder.addCase(createSchema.rejected, (state, action) => {
+      if (!action.error.message.includes('CanceledError')) {
+        toast.error(action.error.message)
+      }
+    })
+    builder.addCase(updateSchema.rejected, (state, action) => {
+      if (!action.error.message.includes('CanceledError')) {
+        toast.error(action.error.message)
+      }
+    })
+    builder.addCase(deleteSchema.rejected, (state, action) => {
+      if (!action.error.message.includes('CanceledError')) {
+        toast.error(action.error.message)
+      }
+    })
     builder.addCase(fetchTables.fulfilled, (state, action) => {
       state.tables = action.payload.tables
       if (action.payload.init) {


Reply via email to