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