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

mchades 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 daea9c8ac2 [#6947] subtask(web): Web UI supports fileset multiple 
locations (#7033)
daea9c8ac2 is described below

commit daea9c8ac2a99cfbb0c4b8b1561cc106a5c90805
Author: Qian Xia <[email protected]>
AuthorDate: Mon Apr 28 15:25:01 2025 +0800

    [#6947] subtask(web): Web UI supports fileset multiple locations (#7033)
    
    ### What changes were proposed in this pull request?
    Web UI supports fileset multiple locations
    <img width="647" alt="image"
    
src="https://github.com/user-attachments/assets/bfb877fd-312c-4de0-835c-68c5258deefd";
    />
    <img width="658" alt="image"
    
src="https://github.com/user-attachments/assets/249ce1b8-ddca-4e6f-8188-9ec62a9f308c";
    />
    
    ### Why are the changes needed?
    N/A
    
    Fix: #6947
    
    ### Does this PR introduce _any_ user-facing change?
    N/A
    
    ### How was this patch tested?
    manually
---
 .../integration/test/web/ui/CatalogsPageTest.java  |   3 +-
 .../test/web/ui/pages/CatalogsPage.java            |  19 +-
 .../metalake/rightContent/CreateFilesetDialog.js   | 253 +++++++++++++++++----
 .../tabsContent/detailsView/DetailsView.js         |  84 ++++++-
 web/web/src/components/DetailsDrawer.js            |  58 +++++
 5 files changed, 356 insertions(+), 61 deletions(-)

diff --git 
a/web/integration-test/src/test/java/org/apache/gravitino/integration/test/web/ui/CatalogsPageTest.java
 
b/web/integration-test/src/test/java/org/apache/gravitino/integration/test/web/ui/CatalogsPageTest.java
index 01c48c6a0a..bf9a996c49 100644
--- 
a/web/integration-test/src/test/java/org/apache/gravitino/integration/test/web/ui/CatalogsPageTest.java
+++ 
b/web/integration-test/src/test/java/org/apache/gravitino/integration/test/web/ui/CatalogsPageTest.java
@@ -89,6 +89,7 @@ public class CatalogsPageTest extends BaseWebIT {
   private static final String FILESET_CATALOG_NAME = "catalog_fileset";
   private static final String SCHEMA_NAME = "default";
   private static final String SCHEMA_NAME_FILESET = "schema_fileset";
+  private static final String FILESET_DEFAULT_LOCATION = "fileset_location";
   private static final String FILESET_NAME = "fileset1";
   private static final String TABLE_NAME = "table1";
   private static final String TABLE_NAME_2 = "table2";
@@ -638,7 +639,7 @@ public class CatalogsPageTest extends BaseWebIT {
     clickAndWait(catalogsPage.createFilesetBtn);
     catalogsPage.setFilesetNameField(FILESET_NAME);
     String storageLocation = storageLocation(SCHEMA_NAME_FILESET, 
FILESET_NAME);
-    catalogsPage.setFilesetStorageLocationField(storageLocation);
+    catalogsPage.setFilesetStorageLocationField(0, FILESET_DEFAULT_LOCATION, 
storageLocation);
     catalogsPage.setFilesetCommentField("fileset comment");
     catalogsPage.addFilesetPropsBtn.click();
     catalogsPage.setPropsAt(0, PROPERTIES_KEY1, PROPERTIES_VALUE1);
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 85e090bcbb..0c004f4a6a 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
@@ -318,14 +318,17 @@ public class CatalogsPage extends BaseWebIT {
     }
   }
 
-  public void setFilesetStorageLocationField(String storageLocation) {
-    try {
-      WebElement filesetStorageLocationFieldInput =
-          filesetStorageLocationField.findElement(By.tagName("input"));
-      filesetStorageLocationFieldInput.sendKeys(
-          Keys.chord(Keys.HOME, Keys.chord(Keys.SHIFT, Keys.END), 
Keys.DELETE));
-      filesetStorageLocationFieldInput.clear();
-      filesetStorageLocationFieldInput.sendKeys(storageLocation);
+  public void setFilesetStorageLocationField(
+      int index, String locationName, String storageLocation) {
+    try {
+      // Set the indexed storageLocations name
+      String namePath = "//div[@data-refer='storageLocations-name-" + index + 
"']//input";
+      WebElement nameInput = driver.findElement(By.xpath(namePath));
+      nameInput.sendKeys(locationName);
+      // Set the indexed storageLocations location
+      String locationPath = "//div[@data-refer='storageLocations-location-" + 
index + "']//input";
+      WebElement locationInput = driver.findElement(By.xpath(locationPath));
+      locationInput.sendKeys(storageLocation);
     } catch (Exception e) {
       LOG.error(e.getMessage(), e);
     }
diff --git 
a/web/web/src/app/metalakes/metalake/rightContent/CreateFilesetDialog.js 
b/web/web/src/app/metalakes/metalake/rightContent/CreateFilesetDialog.js
index 873876e6e9..17a4fe52eb 100644
--- a/web/web/src/app/metalakes/metalake/rightContent/CreateFilesetDialog.js
+++ b/web/web/src/app/metalakes/metalake/rightContent/CreateFilesetDialog.js
@@ -36,7 +36,9 @@ import {
   MenuItem,
   InputLabel,
   FormControl,
-  FormHelperText
+  FormHelperText,
+  Tooltip,
+  Switch
 } from '@mui/material'
 
 import Icon from '@/components/Icon'
@@ -45,7 +47,7 @@ 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 { useForm, Controller, useFieldArray } from 'react-hook-form'
 import { yupResolver } from '@hookform/resolvers/yup'
 
 import { groupBy } from 'lodash-es'
@@ -56,7 +58,7 @@ import { useSearchParams } from 'next/navigation'
 const defaultValues = {
   name: '',
   type: 'managed',
-  storageLocation: '',
+  storageLocations: [{ name: '', location: '', defaultLocation: true }],
   comment: '',
   propItems: []
 }
@@ -64,11 +66,37 @@ const defaultValues = {
 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
-  }),
+  storageLocations: yup
+    .array()
+    .of(
+      yup.object().shape({
+        name: yup.string().when('type', {
+          is: 'external',
+          then: schema => schema.required(),
+          otherwise: schema => schema
+        }),
+        location: yup.string().when('name', {
+          is: name => !!name,
+          then: schema => schema.required(),
+          otherwise: schema => schema
+        })
+      })
+    )
+    .test('unique', 'Location name must be unique', (storageLocations, ctx) => 
{
+      const values = storageLocations?.filter(l => !!l.name).map(l => l.name)
+      const duplicates = values.filter((value, index, self) => 
self.indexOf(value) !== index)
+
+      if (duplicates.length > 0) {
+        const duplicateIndex = values.lastIndexOf(duplicates[0])
+
+        return ctx.createError({
+          path: `storageLocations.${duplicateIndex}.name`,
+          message: 'This storage location name is duplicated'
+        })
+      }
+
+      return true
+    }),
   propItems: yup.array().of(
     yup.object().shape({
       required: yup.boolean(),
@@ -111,6 +139,9 @@ const CreateFilesetDialog = props => {
     resolver: yupResolver(schema)
   })
 
+  const defaultLocationProps = watch('propItems').filter(item => item.key === 
'default-location-name')[0]
+  const storageLocationsItems = watch('storageLocations')
+
   const handleFormChange = ({ index, event }) => {
     let data = [...innerProps]
     data[index][event.target.name] = event.target.value
@@ -154,6 +185,20 @@ const CreateFilesetDialog = props => {
     setValue('propItems', data)
   }
 
+  const { fields, append, remove } = useFieldArray({
+    control,
+    name: 'storageLocations'
+  })
+
+  const onChangeDefaultLocation = ({ index, event }) => {
+    fields.forEach((item, i) => {
+      if (i !== index) {
+        setValue(`storageLocations.${i}.defaultLocation`, false)
+      }
+    })
+    setValue(`storageLocations.${index}.defaultLocation`, event.target.checked)
+  }
+
   const handleClose = () => {
     reset()
     setInnerProps([])
@@ -188,7 +233,16 @@ const CreateFilesetDialog = props => {
         const filesetData = {
           name: data.name,
           type: data.type,
-          storageLocation: data.storageLocation,
+          storageLocations: data.storageLocations.reduce((acc, item) => {
+            if (item.name && item.location) {
+              acc[item.name] = item.location
+              if (item.defaultLocation && 
!properties['default-location-name']) {
+                properties['default-location-name'] = item.name
+              }
+            }
+
+            return acc
+          }, {}),
           comment: data.comment,
           properties
         }
@@ -238,9 +292,16 @@ const CreateFilesetDialog = props => {
       setCacheData(data)
       setValue('name', data.name)
       setValue('type', data.type)
-      setValue('storageLocation', data.storageLocation)
       setValue('comment', data.comment)
 
+      const storageLocations = 
Object.entries(data.storageLocations).map(([key, value]) => {
+        return {
+          name: key,
+          location: value,
+          defaultLocation: properties['default-location-name'] === key
+        }
+      })
+
       const propsItems = Object.entries(properties).map(([key, value]) => {
         return {
           key,
@@ -248,6 +309,7 @@ const CreateFilesetDialog = props => {
         }
       })
 
+      setValue('storageLocations', storageLocations)
       setInnerProps(propsItems)
       setValue('propItems', propsItems)
     }
@@ -329,38 +391,130 @@ const CreateFilesetDialog = props => {
             </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>
+              <Typography sx={{ mb: 2 }} variant='body2'>
+                Storage Locations
+              </Typography>
+              {fields.map((field, index) => {
+                return (
+                  <Grid key={index} item xs={12} sx={{ '& + &': { mt: 2 } }}>
+                    <FormControl fullWidth>
+                      <Box
+                        key={field.id}
+                        sx={{ display: 'flex', alignItems: 'center', 
justifyContent: 'space-between', gap: 1 }}
+                        data-refer={`storageLocations-${index}`}
+                      >
+                        <Box>
+                          <Controller
+                            name={`storageLocations.${index}.name`}
+                            control={control}
+                            render={({ field: { value, onChange } }) => (
+                              <TextField
+                                {...field}
+                                value={value}
+                                onChange={onChange}
+                                disabled={type === 'update'}
+                                label={`Name ${index + 1}`}
+                                data-refer={`storageLocations-name-${index}`}
+                                
error={!!errors.storageLocations?.[index]?.name || 
!!errors.storageLocations?.message}
+                                helperText={
+                                  
errors.storageLocations?.[index]?.name?.message || 
errors.storageLocations?.message
+                                }
+                                fullWidth
+                              />
+                            )}
+                          />
+                        </Box>
+                        <Box>
+                          <Controller
+                            name={`storageLocations.${index}.location`}
+                            control={control}
+                            render={({ field: { value, onChange } }) => (
+                              <TextField
+                                {...field}
+                                value={value}
+                                onChange={onChange}
+                                disabled={type === 'update'}
+                                label={`Location ${index + 1}`}
+                                
data-refer={`storageLocations-location-${index}`}
+                                error={
+                                  !!errors.storageLocations?.[index]?.location 
|| !!errors.storageLocations?.message
+                                }
+                                helperText={
+                                  
errors.storageLocations?.[index]?.location?.message ||
+                                  errors.storageLocations?.message
+                                }
+                                fullWidth
+                              />
+                            )}
+                          />
+                        </Box>
+                        {!defaultLocationProps &&
+                          storageLocationsItems.length > 1 &&
+                          storageLocationsItems[0].name &&
+                          storageLocationsItems[0].location && (
+                            <Box>
+                              <Controller
+                                
name={`storageLocations.${index}.defaultLocation`}
+                                control={control}
+                                render={({ field: { value, onChange } }) => (
+                                  <Tooltip title='Default Location' 
placement='top'>
+                                    <Switch
+                                      checked={value}
+                                      onChange={event => 
onChangeDefaultLocation({ index, event })}
+                                      disabled={type === 'update'}
+                                      size='small'
+                                    />
+                                  </Tooltip>
+                                )}
+                              />
+                            </Box>
+                          )}
+                        <Box>
+                          {index === 0 ? (
+                            <Box sx={{ minWidth: 40 }}>
+                              <IconButton
+                                sx={{ cursor: type === 'update' ? 
'not-allowed' : 'pointer' }}
+                                onClick={() => {
+                                  if (type === 'update') return
+                                  append({ name: '', location: '', 
defaultLocation: false })
+                                }}
+                              >
+                                <Icon icon='mdi:plus-circle-outline' />
+                              </IconButton>
+                            </Box>
+                          ) : (
+                            <Box sx={{ minWidth: 40 }}>
+                              <IconButton
+                                sx={{ cursor: type === 'update' ? 
'not-allowed' : 'pointer' }}
+                                onClick={() => {
+                                  if (type === 'update') return
+                                  remove(index)
+                                }}
+                              >
+                                <Icon icon='mdi:minus-circle-outline' />
+                              </IconButton>
+                            </Box>
+                          )}
+                        </Box>
+                      </Box>
+                    </FormControl>
+                  </Grid>
+                )
+              })}
+              {errors.storageLocations ? (
+                <FormHelperText sx={{ color: 'error.main' 
}}>{errors.storageLocations.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>
+                </>
+              )}
             </Grid>
 
             <Grid item xs={12}>
@@ -405,7 +559,10 @@ const CreateFilesetDialog = props => {
                                 name='key'
                                 label='Key'
                                 value={item.key}
-                                disabled={item.disabled || (item.key === 
'location' && type === 'update')}
+                                disabled={
+                                  item.disabled ||
+                                  (['location', 
'default-location-name'].includes(item.key) && type === 'update')
+                                }
                                 onChange={event => handleFormChange({ index, 
event })}
                                 error={item.hasDuplicateKey || item.invalid || 
!item.key.trim()}
                                 data-refer={`props-key-${index}`}
@@ -418,14 +575,20 @@ const CreateFilesetDialog = props => {
                                 label='Value'
                                 error={item.required && item.value === ''}
                                 value={item.value}
-                                disabled={item.disabled || (item.key === 
'location' && type === 'update')}
+                                disabled={
+                                  item.disabled ||
+                                  (['location', 
'default-location-name'].includes(item.key) && 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')) ? (
+                            {!(
+                              item.disabled ||
+                              (['location', 
'default-location-name'].includes(item.key) && type === 'update')
+                            ) ? (
                               <Box sx={{ minWidth: 40 }}>
                                 <IconButton onClick={() => 
removeFields(index)}>
                                   <Icon icon='mdi:minus-circle-outline' />
diff --git 
a/web/web/src/app/metalakes/metalake/rightContent/tabsContent/detailsView/DetailsView.js
 
b/web/web/src/app/metalakes/metalake/rightContent/tabsContent/detailsView/DetailsView.js
index 580c497171..30a0456dc3 100644
--- 
a/web/web/src/app/metalakes/metalake/rightContent/tabsContent/detailsView/DetailsView.js
+++ 
b/web/web/src/app/metalakes/metalake/rightContent/tabsContent/detailsView/DetailsView.js
@@ -19,7 +19,18 @@
 
 'use client'
 
-import { Box, Grid, Typography, Table, TableHead, TableBody, TableRow, 
TableCell, TableContainer } from '@mui/material'
+import {
+  Box,
+  Grid,
+  Typography,
+  Table,
+  TableHead,
+  TableBody,
+  TableRow,
+  TableCell,
+  TableContainer,
+  Tooltip
+} from '@mui/material'
 
 import EmptyText from '@/components/EmptyText'
 
@@ -96,12 +107,71 @@ const DetailsView = () => {
               </Typography>
               {renderFieldText({ value: activatedItem?.type })}
             </Grid>
-            <Grid item xs={12} sx={{ mb: [0, 5] }}>
-              <Typography variant='body2' sx={{ mb: 2 }}>
-                Storage location
-              </Typography>
-              {renderFieldText({ value: activatedItem?.storageLocation })}
-            </Grid>
+            {activatedItem?.storageLocation && (
+              <Grid item xs={12} sx={{ mb: [0, 5] }}>
+                <Typography variant='body2' sx={{ mb: 2 }}>
+                  Storage location
+                </Typography>
+                {renderFieldText({ value: activatedItem?.storageLocation })}
+              </Grid>
+            )}
+            {activatedItem?.storageLocations && (
+              <Grid item xs={12} sx={{ mb: [0, 5] }}>
+                <Typography variant='body2' sx={{ mb: 2 }}>
+                  Storage Location(s)
+                </Typography>
+
+                <TableContainer>
+                  <Table>
+                    <TableHead
+                      sx={{
+                        backgroundColor: theme => theme.palette.action.hover
+                      }}
+                    >
+                      <TableRow>
+                        <TableCell sx={{ py: 2 }}>Name</TableCell>
+                        <TableCell sx={{ py: 2 }}>Location</TableCell>
+                      </TableRow>
+                    </TableHead>
+                    <TableBody data-refer='details-props-table'>
+                      {Object.keys(activatedItem?.storageLocations).map((name, 
index) => {
+                        return (
+                          <TableRow key={index} 
data-refer={`details-props-index-${index}`}>
+                            <TableCell
+                              className={'twc-py-[0.7rem] twc-truncate 
twc-max-w-[134px]'}
+                              data-refer={`storageLocations-name-${name}`}
+                            >
+                              <Tooltip
+                                title={<span 
data-refer={`tip-storageLocations-name-${name}`}>{name}</span>}
+                                placement='bottom'
+                              >
+                                {name}
+                              </Tooltip>
+                            </TableCell>
+                            <TableCell
+                              className={'twc-py-[0.7rem] twc-truncate 
twc-max-w-[134px]'}
+                              
data-refer={`storageLocations-location-${activatedItem?.storageLocations[name]}`}
+                              data-prev-refer={`storageLocations-name-${name}`}
+                            >
+                              <Tooltip
+                                title={
+                                  <span 
data-prev-refer={`storageLocations-name-${name}`}>
+                                    {activatedItem?.storageLocations[name]}
+                                  </span>
+                                }
+                                placement='bottom'
+                              >
+                                {activatedItem?.storageLocations[name]}
+                              </Tooltip>
+                            </TableCell>
+                          </TableRow>
+                        )
+                      })}
+                    </TableBody>
+                  </Table>
+                </TableContainer>
+              </Grid>
+            )}
           </>
         ) : null}
 
diff --git a/web/web/src/components/DetailsDrawer.js 
b/web/web/src/components/DetailsDrawer.js
index 021f248250..3228261794 100644
--- a/web/web/src/components/DetailsDrawer.js
+++ b/web/web/src/components/DetailsDrawer.js
@@ -170,6 +170,64 @@ const DetailsDrawer = props => {
           </Grid>
         )}
 
+        {drawerData.storageLocations && (
+          <Grid item xs={12} sx={{ mb: [0, 5] }}>
+            <Typography variant='body2' sx={{ mb: 2 }}>
+              Storage Location(s)
+            </Typography>
+
+            <TableContainer>
+              <Table>
+                <TableHead
+                  sx={{
+                    backgroundColor: theme => theme.palette.action.hover
+                  }}
+                >
+                  <TableRow>
+                    <TableCell sx={{ py: 2 }}>Name</TableCell>
+                    <TableCell sx={{ py: 2 }}>Location</TableCell>
+                  </TableRow>
+                </TableHead>
+                <TableBody data-refer='details-props-table'>
+                  {Object.keys(drawerData.storageLocations).map((name, index) 
=> {
+                    return (
+                      <TableRow key={index} 
data-refer={`details-props-index-${index}`}>
+                        <TableCell
+                          className={'twc-py-[0.7rem] twc-truncate 
twc-max-w-[134px]'}
+                          data-refer={`storageLocations-name-${name}`}
+                        >
+                          <Tooltip
+                            title={<span 
data-refer={`tip-storageLocations-name-${name}`}>{name}</span>}
+                            placement='bottom'
+                          >
+                            {name}
+                          </Tooltip>
+                        </TableCell>
+                        <TableCell
+                          className={'twc-py-[0.7rem] twc-truncate 
twc-max-w-[134px]'}
+                          
data-refer={`storageLocations-location-${drawerData.storageLocations[name]}`}
+                          data-prev-refer={`storageLocations-name-${name}`}
+                        >
+                          <Tooltip
+                            title={
+                              <span 
data-prev-refer={`storageLocations-name-${name}`}>
+                                {drawerData.storageLocations[name]}
+                              </span>
+                            }
+                            placement='bottom'
+                          >
+                            {drawerData.storageLocations[name]}
+                          </Tooltip>
+                        </TableCell>
+                      </TableRow>
+                    )
+                  })}
+                </TableBody>
+              </Table>
+            </TableContainer>
+          </Grid>
+        )}
+
         <Grid item xs={12} sx={{ mb: [0, 5] }}>
           <Typography variant='body2' sx={{ mb: 2 }}>
             Comment

Reply via email to