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) {