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 92d9fc473 [#5168][#5169][#5170] Feature(web): Add ui support for 
creating, editing, viewing, and deleting fileset (#5223)
92d9fc473 is described below

commit 92d9fc473fc9c1bd26085014e395f771f164b4f0
Author: Qian Xia <[email protected]>
AuthorDate: Thu Oct 24 17:09:07 2024 +0800

    [#5168][#5169][#5170] Feature(web): Add ui support for creating, editing, 
viewing, and deleting fileset (#5223)
    
    ### What changes were proposed in this pull request?
    Add ui support for creating, editing, viewing, and deleting fileset
    <img width="1296" alt="image"
    
src="https://github.com/user-attachments/assets/57b76a6f-2655-40da-b779-b2e6f32ef7ba";>
    
    
    ### Why are the changes needed?
    N/A
    
    Fix: #5168
    Fix: #5169
    Fix: #5170
    
    ### Does this PR introduce _any_ user-facing change?
    N/A
    
    ### How was this patch tested?
    manually
---
 .../metalake/rightContent/CreateFilesetDialog.js   | 510 +++++++++++++++++++++
 .../metalake/rightContent/RightContent.js          |  39 +-
 .../tabsContent/tableView/TableView.js             |  50 +-
 web/web/src/lib/api/filesets/index.js              |  20 +-
 web/web/src/lib/store/metalakes/index.js           |  69 ++-
 5 files changed, 682 insertions(+), 6 deletions(-)

diff --git 
a/web/web/src/app/metalakes/metalake/rightContent/CreateFilesetDialog.js 
b/web/web/src/app/metalakes/metalake/rightContent/CreateFilesetDialog.js
new file mode 100644
index 000000000..cb1901545
--- /dev/null
+++ b/web/web/src/app/metalakes/metalake/rightContent/CreateFilesetDialog.js
@@ -0,0 +1,510 @@
+/*
+ * 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 { createFileset, updateFileset } 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'
+
+const defaultValues = {
+  name: '',
+  type: 'managed',
+  storageLocation: '',
+  comment: '',
+  propItems: []
+}
+
+const schema = yup.object().shape({
+  name: yup.string().required().matches(nameRegex, nameRegexDesc),
+  type: yup.mixed().oneOf(['managed', 'external']).required(),
+  storageLocation: yup.string().when('type', {
+    is: 'external',
+    then: schema => schema.required(),
+    otherwise: schema => schema
+  }),
+  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 CreateFilesetDialog = 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 schemaName = searchParams.get('schema')
+  const [innerProps, setInnerProps] = useState([])
+  const dispatch = useAppDispatch()
+  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 filesetData = {
+          name: data.name,
+          type: data.type,
+          storageLocation: data.storageLocation,
+          comment: data.comment,
+          properties
+        }
+
+        if (type === 'create') {
+          dispatch(createFileset({ data: filesetData, metalake, catalog, type: 
catalogType, schema: schemaName })).then(
+            res => {
+              if (!res.payload?.err) {
+                handleClose()
+              }
+            }
+          )
+        } else {
+          const reqData = { updates: genUpdates(cacheData, filesetData) }
+
+          if (reqData.updates.length !== 0) {
+            dispatch(
+              updateFileset({
+                metalake,
+                catalog,
+                type: catalogType,
+                schema: schemaName,
+                fileset: 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('type', data.type)
+      setValue('storageLocation', data.storageLocation)
+      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'} Fileset
+            </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='fileset-name-field'
+                    />
+                  )}
+                />
+                {errors.name && <FormHelperText sx={{ color: 'error.main' 
}}>{errors.name.message}</FormHelperText>}
+              </FormControl>
+            </Grid>
+
+            <Grid item xs={12}>
+              <FormControl fullWidth>
+                <InputLabel id='select-fileset-type' 
error={Boolean(errors.type)}>
+                  Type
+                </InputLabel>
+                <Controller
+                  name='type'
+                  control={control}
+                  rules={{ required: true }}
+                  render={({ field: { value, onChange } }) => (
+                    <Select
+                      value={value}
+                      label='Type'
+                      defaultValue='managed'
+                      onChange={onChange}
+                      disabled={type === 'update'}
+                      error={Boolean(errors.type)}
+                      labelId='select-fileset-type'
+                      data-refer='fileset-type-selector'
+                    >
+                      <MenuItem value={'managed'}>Managed</MenuItem>
+                      <MenuItem value={'external'}>External</MenuItem>
+                    </Select>
+                  )}
+                />
+                {errors.type && <FormHelperText sx={{ color: 'error.main' 
}}>{errors.type.message}</FormHelperText>}
+              </FormControl>
+            </Grid>
+
+            <Grid item xs={12}>
+              <FormControl fullWidth>
+                <Controller
+                  name='storageLocation'
+                  control={control}
+                  rules={{ required: true }}
+                  render={({ field: { value, onChange } }) => (
+                    <TextField
+                      value={value}
+                      label='Storage Location'
+                      onChange={onChange}
+                      disabled={type === 'update'}
+                      placeholder=''
+                      error={Boolean(errors.storageLocation)}
+                      data-refer='fileset-storageLocation-field'
+                    />
+                  )}
+                />
+                {errors.storageLocation ? (
+                  <FormHelperText sx={{ color: 'error.main' 
}}>{errors.storageLocation.message}</FormHelperText>
+                ) : (
+                  <>
+                    <FormHelperText sx={{ color: 'text.main' }}>
+                      It is optional if the fileset is 'Managed' type and a 
storage location is already specified at the
+                      parent catalog or schema level.
+                    </FormHelperText>
+                    <FormHelperText sx={{ color: 'text.main' }}>
+                      It becomes mandatory if the fileset type is 'External' 
or no storage location is defined at the
+                      parent level.
+                    </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='fileset-comment-field'
+                    />
+                  )}
+                />
+              </FormControl>
+            </Grid>
+
+            <Grid item xs={12} data-refer='fileset-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={`fileset-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>
+
+            <Grid item xs={12}>
+              <Button
+                size='small'
+                onClick={addFields}
+                variant='outlined'
+                startIcon={<Icon icon='mdi:plus-circle-outline' />}
+                data-refer='add-fileset-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-fileset'>
+            {type === 'create' ? 'Create' : 'Update'}
+          </Button>
+          <Button variant='outlined' onClick={handleClose}>
+            Cancel
+          </Button>
+        </DialogActions>
+      </form>
+    </Dialog>
+  )
+}
+
+export default CreateFilesetDialog
diff --git a/web/web/src/app/metalakes/metalake/rightContent/RightContent.js 
b/web/web/src/app/metalakes/metalake/rightContent/RightContent.js
index 1706399dd..4dfd091a4 100644
--- a/web/web/src/app/metalakes/metalake/rightContent/RightContent.js
+++ b/web/web/src/app/metalakes/metalake/rightContent/RightContent.js
@@ -26,6 +26,7 @@ import Icon from '@/components/Icon'
 import MetalakePath from './MetalakePath'
 import CreateCatalogDialog from './CreateCatalogDialog'
 import CreateSchemaDialog from './CreateSchemaDialog'
+import CreateFilesetDialog from './CreateFilesetDialog'
 import TabsContent from './tabsContent/TabsContent'
 import { useSearchParams } from 'next/navigation'
 import { useAppSelector } from '@/lib/hooks/useStore'
@@ -33,9 +34,11 @@ import { useAppSelector } from '@/lib/hooks/useStore'
 const RightContent = () => {
   const [open, setOpen] = useState(false)
   const [openSchema, setOpenSchema] = useState(false)
+  const [openFileset, setOpenFileset] = useState(false)
   const searchParams = useSearchParams()
   const [isShowBtn, setBtnVisible] = useState(true)
   const [isShowSchemaBtn, setSchemaBtnVisible] = useState(false)
+  const [isShowFilesetBtn, setFilesetBtnVisible] = useState(false)
   const store = useAppSelector(state => state.metalakes)
 
   const handleCreateCatalog = () => {
@@ -46,15 +49,33 @@ const RightContent = () => {
     setOpenSchema(true)
   }
 
+  const handleCreateFileset = () => {
+    setOpenFileset(true)
+  }
+
   useEffect(() => {
     const paramsSize = [...searchParams.keys()].length
     const isCatalogList = paramsSize == 1 && searchParams.get('metalake')
     setBtnVisible(isCatalogList)
 
+    const isFilesetList =
+      paramsSize == 4 &&
+      searchParams.has('metalake') &&
+      searchParams.has('catalog') &&
+      searchParams.get('type') === 'fileset'
+    searchParams.has('schema')
+    setFilesetBtnVisible(isFilesetList)
+
     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)
+
+      const isSchemaList =
+        paramsSize == 3 &&
+        searchParams.has('metalake') &&
+        searchParams.has('catalog') &&
+        searchParams.has('type') &&
+        !['lakehouse-hudi', 'kafka'].includes(currentCatalog?.provider)
+      setSchemaBtnVisible(isSchemaList)
     }
   }, [searchParams, store.catalogs, store.catalogs.length])
 
@@ -105,6 +126,20 @@ const RightContent = () => {
             <CreateSchemaDialog open={openSchema} setOpen={setOpenSchema} />
           </Box>
         )}
+        {isShowFilesetBtn && (
+          <Box className={`twc-flex twc-items-center`}>
+            <Button
+              variant='contained'
+              startIcon={<Icon icon='mdi:plus-box' />}
+              onClick={handleCreateFileset}
+              sx={{ width: 200 }}
+              data-refer='create-schema-btn'
+            >
+              Create Fileset
+            </Button>
+            <CreateFilesetDialog open={openFileset} setOpen={setOpenFileset} />
+          </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 cdc94c776..cf8cc3baf 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
@@ -41,14 +41,16 @@ import DetailsDrawer from '@/components/DetailsDrawer'
 import ConfirmDeleteDialog from '@/components/ConfirmDeleteDialog'
 import CreateCatalogDialog from '../../CreateCatalogDialog'
 import CreateSchemaDialog from '../../CreateSchemaDialog'
+import CreateFilesetDialog from '../../CreateFilesetDialog'
 
 import { useAppSelector, useAppDispatch } from '@/lib/hooks/useStore'
-import { deleteCatalog, deleteSchema } from '@/lib/store/metalakes'
+import { deleteCatalog, deleteFileset, 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'
+import { getFilesetDetailsApi } from '@/lib/api/filesets'
 
 const fonts = Inconsolata({ subsets: ['latin'] })
 
@@ -76,6 +78,13 @@ const TableView = () => {
   const metalake = searchParams.get('metalake') || ''
   const catalog = searchParams.get('catalog') || ''
   const type = searchParams.get('type') || ''
+  const schema = searchParams.get('schema') || ''
+
+  const isKafkaSchema =
+    paramsSize == 3 &&
+    searchParams.has('metalake') &&
+    searchParams.has('catalog') &&
+    searchParams.get('type') === 'messaging'
 
   const defaultPaginationConfig = { pageSize: 10, page: 0 }
   const pageSizeOptions = [10, 25, 50]
@@ -91,6 +100,7 @@ const TableView = () => {
   const [openConfirmDelete, setOpenConfirmDelete] = useState(false)
   const [openDialog, setOpenDialog] = useState(false)
   const [openSchemaDialog, setOpenSchemaDialog] = useState(false)
+  const [openFilesetDialog, setOpenFilesetDialog] = useState(false)
   const [dialogData, setDialogData] = useState({})
   const [dialogType, setDialogType] = useState('create')
   const [isHideSchemaEdit, setIsHideSchemaEdit] = useState(true)
@@ -463,6 +473,15 @@ const TableView = () => {
         setOpenDrawer(true)
         break
       }
+      case 'fileset': {
+        const [err, res] = await to(getFilesetDetailsApi({ metalake, catalog, 
schema, fileset: row.name }))
+        if (err || !res) {
+          throw new Error(err)
+        }
+
+        setDrawerData(res.fileset)
+        setOpenDrawer(true)
+      }
       default:
         return
     }
@@ -498,6 +517,18 @@ const TableView = () => {
         }
         break
       }
+      case 'fileset': {
+        if (metalake && catalog && schema) {
+          const [err, res] = await to(getFilesetDetailsApi({ metalake, 
catalog, schema, fileset: data.row?.name }))
+          if (err || !res) {
+            throw new Error(err)
+          }
+
+          setDialogType('update')
+          setDialogData(res.fileset)
+          setOpenFilesetDialog(true)
+        }
+      }
       default:
         return
     }
@@ -522,6 +553,9 @@ const TableView = () => {
         case 'schema':
           dispatch(deleteSchema({ metalake, catalog, type, schema: 
confirmCacheData.name }))
           break
+        case 'fileset':
+          dispatch(deleteFileset({ metalake, catalog, type, schema, fileset: 
confirmCacheData.name }))
+          break
         default:
           break
       }
@@ -533,7 +567,12 @@ const TableView = () => {
   const checkColumns = () => {
     if (
       (paramsSize == 1 && searchParams.has('metalake')) ||
-      (paramsSize == 3 && searchParams.has('metalake') && 
searchParams.has('catalog') && searchParams.has('type'))
+      (paramsSize == 3 && searchParams.has('metalake') && 
searchParams.has('catalog') && searchParams.has('type')) ||
+      (paramsSize == 4 &&
+        searchParams.has('metalake') &&
+        searchParams.has('catalog') &&
+        searchParams.get('type') === 'fileset' &&
+        searchParams.has('schema'))
     ) {
       return actionsColumns
     } else if (paramsSize == 5 && searchParams.has('table')) {
@@ -580,6 +619,13 @@ const TableView = () => {
       <CreateCatalogDialog open={openDialog} setOpen={setOpenDialog} 
data={dialogData} type={dialogType} />
 
       <CreateSchemaDialog open={openSchemaDialog} 
setOpen={setOpenSchemaDialog} data={dialogData} type={dialogType} />
+
+      <CreateFilesetDialog
+        open={openFilesetDialog}
+        setOpen={setOpenFilesetDialog}
+        data={dialogData}
+        type={dialogType}
+      />
     </Box>
   )
 }
diff --git a/web/web/src/lib/api/filesets/index.js 
b/web/web/src/lib/api/filesets/index.js
index 81f05488f..bae492a11 100644
--- a/web/web/src/lib/api/filesets/index.js
+++ b/web/web/src/lib/api/filesets/index.js
@@ -27,7 +27,13 @@ const Apis = {
   GET_DETAIL: ({ metalake, catalog, schema, fileset }) =>
     
`/api/metalakes/${encodeURIComponent(metalake)}/catalogs/${encodeURIComponent(
       catalog
-    
)}/schemas/${encodeURIComponent(schema)}/filesets/${encodeURIComponent(fileset)}`
+    
)}/schemas/${encodeURIComponent(schema)}/filesets/${encodeURIComponent(fileset)}`,
+  CREATE: ({ metalake, catalog, schema }) =>
+    
`/api/metalakes/${encodeURIComponent(metalake)}/catalogs/${encodeURIComponent(catalog)}/schemas/${encodeURIComponent(schema)}/filesets`,
+  UPDATE: ({ metalake, catalog, schema, fileset }) =>
+    
`/api/metalakes/${encodeURIComponent(metalake)}/catalogs/${encodeURIComponent(catalog)}/schemas/${encodeURIComponent(schema)}/filesets/${encodeURIComponent(fileset)}`,
+  DELETE: ({ metalake, catalog, schema, fileset }) =>
+    
`/api/metalakes/${encodeURIComponent(metalake)}/catalogs/${encodeURIComponent(catalog)}/schemas/${encodeURIComponent(schema)}/filesets/${encodeURIComponent(fileset)}`
 }
 
 export const getFilesetsApi = params => {
@@ -41,3 +47,15 @@ export const getFilesetDetailsApi = ({ metalake, catalog, 
schema, fileset }) =>
     url: `${Apis.GET_DETAIL({ metalake, catalog, schema, fileset })}`
   })
 }
+
+export const createFilesetApi = ({ metalake, catalog, schema, data }) => {
+  return defHttp.post({ url: `${Apis.CREATE({ metalake, catalog, schema })}`, 
data })
+}
+
+export const updateFilesetApi = ({ metalake, catalog, schema, fileset, data }) 
=> {
+  return defHttp.put({ url: `${Apis.UPDATE({ metalake, catalog, schema, 
fileset })}`, data })
+}
+
+export const deleteFilesetApi = ({ metalake, catalog, schema, fileset }) => {
+  return defHttp.delete({ url: `${Apis.DELETE({ metalake, catalog, schema, 
fileset })}` })
+}
diff --git a/web/web/src/lib/store/metalakes/index.js 
b/web/web/src/lib/store/metalakes/index.js
index 7c58e80e4..445d2838d 100644
--- a/web/web/src/lib/store/metalakes/index.js
+++ b/web/web/src/lib/store/metalakes/index.js
@@ -47,7 +47,13 @@ import {
   deleteSchemaApi
 } from '@/lib/api/schemas'
 import { getTablesApi, getTableDetailsApi } from '@/lib/api/tables'
-import { getFilesetsApi, getFilesetDetailsApi } from '@/lib/api/filesets'
+import {
+  getFilesetsApi,
+  getFilesetDetailsApi,
+  createFilesetApi,
+  updateFilesetApi,
+  deleteFilesetApi
+} from '@/lib/api/filesets'
 import { getTopicsApi, getTopicDetailsApi } from '@/lib/api/topics'
 
 export const fetchMetalakes = createAsyncThunk('appMetalakes/fetchMetalakes', 
async (params, { getState }) => {
@@ -885,6 +891,67 @@ export const getFilesetDetails = createAsyncThunk(
   }
 )
 
+export const createFileset = createAsyncThunk(
+  'appMetalakes/createFileset',
+  async ({ data, metalake, catalog, type, schema }, { dispatch }) => {
+    dispatch(setTableLoading(true))
+    const [err, res] = await to(createFilesetApi({ data, metalake, catalog, 
schema }))
+    dispatch(setTableLoading(false))
+
+    if (err || !res) {
+      return { err: true }
+    }
+
+    const { fileset: filesetItem } = res
+
+    const filesetData = {
+      ...filesetItem,
+      node: 'fileset',
+      id: 
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${filesetItem.name}}}`,
+      key: 
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${filesetItem.name}}}`,
+      path: `?${new URLSearchParams({ metalake, catalog, type, schema, 
fileset: filesetItem.name }).toString()}`,
+      name: filesetItem.name,
+      title: filesetItem.name,
+      tables: [],
+      children: []
+    }
+
+    dispatch(fetchFilesets({ metalake, catalog, schema, type, init: true }))
+
+    return filesetData
+  }
+)
+
+export const updateFileset = createAsyncThunk(
+  'appMetalakes/updateFileset',
+  async ({ metalake, catalog, type, schema, fileset, data }, { dispatch }) => {
+    const [err, res] = await to(updateFilesetApi({ metalake, catalog, schema, 
fileset, data }))
+    if (err || !res) {
+      return { err: true }
+    }
+    dispatch(fetchFilesets({ metalake, catalog, type, schema, init: true }))
+
+    return res.catalog
+  }
+)
+
+export const deleteFileset = createAsyncThunk(
+  'appMetalakes/deleteFileset',
+  async ({ metalake, catalog, type, schema, fileset }, { dispatch }) => {
+    dispatch(setTableLoading(true))
+    const [err, res] = await to(deleteFilesetApi({ metalake, catalog, schema, 
fileset }))
+    dispatch(setTableLoading(false))
+
+    if (err || !res) {
+      throw new Error(err)
+    }
+
+    dispatch(fetchFilesets({ metalake, catalog, type, schema, page: 'schemas', 
init: true }))
+
+    return res
+  }
+)
+
 export const fetchTopics = createAsyncThunk(
   'appMetalakes/fetchTopics',
   async ({ init, page, metalake, catalog, schema }, { getState, dispatch }) => 
{

Reply via email to