diff --git a/api/src/main/java/marquez/db/DatasetFieldDao.java b/api/src/main/java/marquez/db/DatasetFieldDao.java index 7b66b55adf..86abf8de60 100644 --- a/api/src/main/java/marquez/db/DatasetFieldDao.java +++ b/api/src/main/java/marquez/db/DatasetFieldDao.java @@ -129,7 +129,7 @@ void deleteDatasetVersionFieldTag( @SqlUpdate( "INSERT INTO dataset_fields_tag_mapping (dataset_field_uuid, tag_uuid, tagged_at) " - + "VALUES (:rowUuid, :tagUuid, :taggedAt)") + + "VALUES (:rowUuid, :tagUuid, :taggedAt) ON CONFLICT DO NOTHING") void updateTags(UUID rowUuid, UUID tagUuid, Instant taggedAt); @SqlBatch( diff --git a/api/src/test/java/marquez/api/TagResourceIntegrationTest.java b/api/src/test/java/marquez/api/TagResourceIntegrationTest.java index 4304b55d33..60b905c33a 100644 --- a/api/src/test/java/marquez/api/TagResourceIntegrationTest.java +++ b/api/src/test/java/marquez/api/TagResourceIntegrationTest.java @@ -100,4 +100,38 @@ public void testApp_testDatasetTagFieldDelete() { // assert that only SENSITIVE remains assertThat(taggedDatasetFieldDelete.getFields().get(0).getTags()).containsExactly("SENSITIVE"); } + + @Test + public void testApp_testDatasetTagFieldConflict() { + // Create Namespace + createNamespace(NAMESPACE_NAME); + // create a source + createSource(DB_TABLE_SOURCE_NAME); + // Create Dataset + MARQUEZ_CLIENT.createDataset(NAMESPACE_NAME, DB_TABLE_NAME, DB_TABLE_META); + + // tag dataset field + Dataset taggedDatasetField = + MARQUEZ_CLIENT.tagFieldWith( + NAMESPACE_NAME, + DB_TABLE_NAME, + DB_TABLE_META.getFields().get(0).getName(), + "TESTFIELDTAG"); + // assert the tag TESTFIELDTAG has been added to field at position 0 + assertThat(taggedDatasetField.getFields().get(0).getTags()).contains("TESTFIELDTAG"); + // assert a total of two tags exist on the field + assertThat(taggedDatasetField.getFields().get(0).getTags()).hasSize(2); + + // tag dataset field again to test ON CONFLICT DO NOTHING + Dataset taggedDatasetField2 = + MARQUEZ_CLIENT.tagFieldWith( + NAMESPACE_NAME, + DB_TABLE_NAME, + DB_TABLE_META.getFields().get(0).getName(), + "TESTFIELDTAG"); + // assert the tag TESTFIELDTAG has been added to field at position 0 + assertThat(taggedDatasetField2.getFields().get(0).getTags()).contains("TESTFIELDTAG"); + // assert a total of two tags exist on the field + assertThat(taggedDatasetField2.getFields().get(0).getTags()).hasSize(2); + } } diff --git a/web/src/components/datasets/DatasetDetailPage.tsx b/web/src/components/datasets/DatasetDetailPage.tsx index b739d71026..58d63ecd3d 100644 --- a/web/src/components/datasets/DatasetDetailPage.tsx +++ b/web/src/components/datasets/DatasetDetailPage.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import * as Redux from 'redux' -import { Box, Button, Tab, Tabs, createTheme } from '@mui/material' +import { Box, Button, Switch, Tab, Tabs, createTheme } from '@mui/material' import { CircularProgress } from '@mui/material' import { DatasetVersion } from '../../types/api' import { IState } from '../../store/reducers' @@ -31,7 +31,7 @@ import IconButton from '@mui/material/IconButton' import Io from '../io/Io' import MqStatus from '../core/status/MqStatus' import MqText from '../core/text/MqText' -import React, { ChangeEvent, FunctionComponent, useEffect } from 'react' +import React, { ChangeEvent, FunctionComponent, useEffect, useState } from 'react' interface StateProps { lineageDataset: LineageDataset @@ -79,6 +79,7 @@ const DatasetDetailPage: FunctionComponent = (props) => { const i18next = require('i18next') const theme = createTheme(useTheme()) const [_, setSearchParams] = useSearchParams() + const [showTags, setShowTags] = useState(false) // unmounting useEffect( @@ -91,7 +92,13 @@ const DatasetDetailPage: FunctionComponent = (props) => { useEffect(() => { fetchDatasetVersions(lineageDataset.namespace, lineageDataset.name) - }, [lineageDataset.name, datasets.refreshTags]) + }, [lineageDataset.name]) + + useEffect(() => { + if (showTags === true) { + fetchDatasetVersions(lineageDataset.namespace, lineageDataset.name) + } + }, [showTags]) // if the dataset is deleted then redirect to datasets end point useEffect(() => { @@ -194,7 +201,7 @@ const DatasetDetailPage: FunctionComponent = (props) => { - + {facetsStatus && ( @@ -203,6 +210,14 @@ const DatasetDetailPage: FunctionComponent = (props) => { {name} + + {i18next.t('datasets.show_field_tags')} + setShowTags(!showTags)} + inputProps={{ 'aria-label': 'toggle show tags' }} + /> + {description} @@ -213,6 +228,7 @@ const DatasetDetailPage: FunctionComponent = (props) => { datasetFields={firstVersion.fields} facets={firstVersion.facets} run={firstVersion.createdByRun} + showTags={showTags} /> )} {tabIndex === 1 && } diff --git a/web/src/components/datasets/DatasetInfo.tsx b/web/src/components/datasets/DatasetInfo.tsx index a218aa96ac..b938066cc1 100644 --- a/web/src/components/datasets/DatasetInfo.tsx +++ b/web/src/components/datasets/DatasetInfo.tsx @@ -4,16 +4,13 @@ import * as Redux from 'redux' import { Box, Table, TableBody, TableCell, TableHead, TableRow } from '@mui/material' import { Field, Run } from '../../types/api' import { IState } from '../../store/reducers' - import { connect, useSelector } from 'react-redux' import { fetchJobFacets, resetFacets } from '../../store/actionCreators' -import Collapse from '@mui/material/Collapse' import DatasetTags from './DatasetTags' -import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown' import MqEmpty from '../core/empty/MqEmpty' import MqJsonView from '../core/json-view/MqJsonView' import MqText from '../core/text/MqText' -import React, { FunctionComponent, useEffect, useState } from 'react' +import React, { FunctionComponent, useEffect } from 'react' export interface DispatchProps { fetchJobFacets: typeof fetchJobFacets @@ -36,22 +33,18 @@ type DatasetInfoProps = { datasetFields: Field[] facets?: object run?: Run + showTags?: boolean } & JobFacetsProps & DispatchProps const DatasetInfo: FunctionComponent = (props) => { - const { datasetFields, facets, run, fetchJobFacets, resetFacets } = props + const { datasetFields, facets, run, fetchJobFacets, resetFacets, showTags } = props const i18next = require('i18next') const dsNamespace = useSelector( (state: IState) => state.datasetVersions.result.versions[0].namespace ) const dsName = useSelector((state: IState) => state.datasetVersions.result.versions[0].name) - const loadCollapsedState = () => { - const storedState = localStorage.getItem(`dsi_${dsNamespace}_${dsName}`) - return storedState ? JSON.parse(storedState) : [] - } - useEffect(() => { run && fetchJobFacets(run.id) }, [run]) @@ -62,27 +55,6 @@ const DatasetInfo: FunctionComponent = (props) => { }, [] ) - const [expandedRows, setExpandedRows] = useState(loadCollapsedState) - - const toggleRow = (index: number) => { - setExpandedRows((prevExpandedRows) => { - const newExpandedRows = prevExpandedRows.includes(index) - ? prevExpandedRows.filter((rowIndex) => rowIndex !== index) - : [...prevExpandedRows, index] - - localStorage.setItem(`dsi_${dsNamespace}_${dsName}`, JSON.stringify(newExpandedRows)) - - return newExpandedRows - }) - } - - useEffect(() => { - for (const key in localStorage) { - if (key !== `dsi_${dsNamespace}_${dsName}`) { - localStorage.removeItem(key) - } - } - }, [dsNamespace, dsName]) return ( @@ -102,53 +74,49 @@ const DatasetInfo: FunctionComponent = (props) => { {i18next.t('dataset_info_columns.name')} - - - {i18next.t('dataset_info_columns.type')} - - - - - {i18next.t('dataset_info_columns.description')} - - - + {!showTags && ( + + + {i18next.t('dataset_info_columns.type')} + + + )} + {!showTags && ( + + + {i18next.t('dataset_info_columns.description')} + + + )} + {showTags && ( + + + {i18next.t('dataset_tags.tags')} + + + )} - {datasetFields.map((field, index) => { + {datasetFields.map((field) => { return ( - toggleRow(index)} - className='expandable-row' - > + {field.name} - {field.type} - {field.description || 'no description'} - - - - - - - - - - - - + {!showTags && {field.type}} + {!showTags && ( + {field.description || 'no description'} + )} + {showTags && ( + + + + )} ) diff --git a/web/src/components/datasets/DatasetTags.tsx b/web/src/components/datasets/DatasetTags.tsx index 245214fa3c..0863451136 100644 --- a/web/src/components/datasets/DatasetTags.tsx +++ b/web/src/components/datasets/DatasetTags.tsx @@ -1,7 +1,13 @@ // Copyright 2018-2024 contributors to the Marquez project // SPDX-License-Identifier: Apache-2.0 import * as Redux from 'redux' -import { Autocomplete, TextField } from '@mui/material' +import { + Autocomplete, + AutocompleteChangeDetails, + AutocompleteChangeReason, + Checkbox, + TextField, +} from '@mui/material' import { Box, createTheme } from '@mui/material' import { IState } from '../../store/reducers' import { Tag } from '../../types/api' @@ -15,27 +21,18 @@ import { import { bindActionCreators } from 'redux' import { connect, useSelector } from 'react-redux' import { useTheme } from '@emotion/react' -import AddIcon from '@mui/icons-material/Add' -import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown' import Button from '@mui/material/Button' -import ButtonGroup from '@mui/material/ButtonGroup' +import CheckBoxIcon from '@mui/icons-material/CheckBox' +import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank' import Chip from '@mui/material/Chip' -import ClickAwayListener from '@mui/material/ClickAwayListener' import Dialog from '@mui/material/Dialog' import DialogActions from '@mui/material/DialogActions' import DialogContent from '@mui/material/DialogContent' import DialogTitle from '@mui/material/DialogTitle' -import EditNoteIcon from '@mui/icons-material/EditNote' -import FormControl from '@mui/material/FormControl' -import Grow from '@mui/material/Grow' +import LocalOfferIcon from '@mui/icons-material/LocalOffer' import MQText from '../core/text/MqText' import MQTooltip from '../core/tooltip/MQTooltip' -import MenuItem from '@mui/material/MenuItem' -import MenuList from '@mui/material/MenuList' -import Paper from '@mui/material/Paper' -import Popper from '@mui/material/Popper' -import React, { useRef, useState } from 'react' -import Select from '@mui/material/Select' +import React, { useState } from 'react' import Snackbar from '@mui/material/Snackbar' interface DatasetTagsProps { @@ -68,47 +65,24 @@ const DatasetTags: React.FC = (props) => { addTags, } = props - const [isDialogOpen, setDialogOpen] = useState(false) const [listTag, setListTag] = useState('') - const closeDialog = () => setDialogOpen(false) - const i18next = require('i18next') - const options = ['Add a Tag', 'Edit a Tag Description'] - const [openDropDown, setOpenDropDown] = useState(false) const [openTagDesc, setOpenTagDesc] = useState(false) - const anchorRef = useRef(null) - const [selectedIndex, setSelectedIndex] = useState(0) const [tagDescription, setTagDescription] = useState('No Description') + const [selectedTags, setSelectedTags] = useState(datasetTags) + const handleButtonClick = () => { - options[selectedIndex] === 'Add a Tag' ? setDialogOpen(true) : setOpenTagDesc(true) + setOpenTagDesc(true) } + const [snackbarOpen, setSnackbarOpen] = useState(false) const theme = createTheme(useTheme()) - const handleMenuItemClick = ( - _event: React.MouseEvent, - index: number - ) => { - setSelectedIndex(index) - setOpenDropDown(false) - } - - const handleDropDownToggle = () => { - setOpenDropDown((prevprevOpenDropDown) => !prevprevOpenDropDown) - } - const handleTagDescClose = () => { setOpenTagDesc(false) setListTag('') setTagDescription('No Description') } - const handleDropDownClose = (event: Event) => { - if (anchorRef.current && anchorRef.current.contains(event.target as HTMLElement)) { - return - } - setOpenDropDown(false) - } - const handleTagDescChange = (_event: any, value: string) => { const selectedTagData = tagData.find((tag) => tag.name === value) setListTag(value) @@ -119,19 +93,33 @@ const DatasetTags: React.FC = (props) => { setTagDescription(event.target.value) } - const tagData = useSelector((state: IState) => state.tags.tags) + const tagData = useSelector((state: IState) => + state.tags.tags.sort((a, b) => a.name.localeCompare(b.name)) + ) - const handleTagListChange = (event: any) => { - setListTag(event.target.value) - } + const handleTagChange = ( + _event: React.SyntheticEvent, + _value: string[], + _reason: AutocompleteChangeReason, + details?: AutocompleteChangeDetails | undefined + ) => { + + if (details) { + const newTag = details.option + const newSelectedTags = [...selectedTags, newTag] + setSelectedTags(newSelectedTags) - const handleTagChange = () => { - datasetField - ? addDatasetFieldTag(namespace, datasetName, listTag, datasetField) - : addDatasetTag(namespace, datasetName, listTag) + datasetField + ? addDatasetFieldTag(namespace, datasetName, newTag, datasetField) + : addDatasetTag(namespace, datasetName, newTag) + } } const handleDelete = (deletedTag: string) => { + const newSelectedTags = selectedTags.filter((tag) => deletedTag !== tag) + + setSelectedTags(newSelectedTags) + datasetField ? deleteDatasetFieldTag(namespace, datasetName, deletedTag, datasetField) : deleteDatasetTag(namespace, datasetName, deletedTag) @@ -146,19 +134,20 @@ const DatasetTags: React.FC = (props) => { } const formatTags = (tags: string[], tag_desc: Tag[]) => { - return tags.map((tag) => { + return tags.map((tag, index) => { const tagDescription = tag_desc.find((tagItem) => tagItem.name === tag) const tooltipTitle = tagDescription?.description || 'No Tag Description' return ( handleDelete(tag)} style={{ display: 'row', - marginLeft: theme.spacing(1), + marginLeft: index === 0 ? theme.spacing(0) : theme.spacing(1), }} /> @@ -176,121 +165,85 @@ const DatasetTags: React.FC = (props) => { message={'Tag updated.'} anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} /> - - {i18next.t('dataset_tags.tags')} - {formatTags(datasetTags, tagData)} - - + + {!datasetField && ( + - - + )} + option.name)} + value={selectedTags} + onChange={handleTagChange} + renderTags={(value: string[]) => formatTags(value, tagData)} + renderOption={(props, option, { selected }) => ( +
  • + } + checkedIcon={} + style={{ marginRight: 4 }} + checked={selected} + /> +
    + {option} + + {tagData.find((tagItem) => tagItem.name === option)?.description || + 'No Tag Description'} + +
    +
  • + )} + renderInput={(params) => ( + 0 ? '' : 'Add some Tags.'} + InputProps={{ + ...params.InputProps, + ...(datasetField ? { disableUnderline: true } : {}), + }} + InputLabelProps={{ + shrink: true, + }} + size='small' + /> + )} + />
    - { + if (event.key === 'Escape') { + handleTagDescClose() + } }} - open={openDropDown} - anchorEl={anchorRef.current} - role={undefined} - transition - disablePortal > - {({ TransitionProps, placement }) => ( - - - - - {options.map((option, index) => ( - handleMenuItemClick(event, index)} - > - {option} - - ))} - - - - - )} - - - {i18next.t('dataset_tags.dialogtitle')} - - - - - - - - - - - Select a Tag to change Tag option.name)} - freeSolo autoSelect + freeSolo onChange={handleTagDescChange} renderInput={(params) => ( ({ +export const deleteDatasetTagSuccess = (namespace: string, datasetName: string, tag: string) => ({ type: actionTypes.DELETE_DATASET_TAG_SUCCESS, payload: { datasetName, + namespace, + tag, }, }) @@ -140,10 +142,18 @@ export const deleteDatasetFieldTag = ( }, }) -export const deleteDatasetFieldTagSuccess = (datasetName: string) => ({ +export const deleteDatasetFieldTagSuccess = ( + namespace: string, + datasetName: string, + field: string, + tag: string +) => ({ type: actionTypes.DELETE_DATASET_FIELD_TAG_SUCCESS, payload: { datasetName, + namespace, + tag, + field, }, }) @@ -156,10 +166,12 @@ export const addDatasetTag = (namespace: string, datasetName: string, tag: strin }, }) -export const addDatasetTagSuccess = (datasetName: string) => ({ +export const addDatasetTagSuccess = (namespace: string, datasetName: string, tag: string) => ({ type: actionTypes.ADD_DATASET_TAG_SUCCESS, payload: { datasetName, + namespace, + tag, }, }) @@ -178,10 +190,18 @@ export const addDatasetFieldTag = ( }, }) -export const addDatasetFieldTagSuccess = (datasetName: string) => ({ +export const addDatasetFieldTagSuccess = ( + namespace: string, + datasetName: string, + field: string, + tag: string +) => ({ type: actionTypes.ADD_DATASET_FIELD_TAG_SUCCESS, payload: { datasetName, + namespace, + field, + tag, }, }) diff --git a/web/src/store/reducers/datasets.ts b/web/src/store/reducers/datasets.ts index 1952674430..ed75282131 100644 --- a/web/src/store/reducers/datasets.ts +++ b/web/src/store/reducers/datasets.ts @@ -72,21 +72,28 @@ export default (state: IDatasetsState = initialState, action: IDatasetsAction): case DELETE_DATASET_SUCCESS: return { ...state, deletedDatasetName: payload.datasetName } case DELETE_DATASET_TAG: - return { ...state, refreshTags: false } + return { ...state } case DELETE_DATASET_TAG_SUCCESS: - return { ...state, refreshTags: true } + return { + ...state, + } case DELETE_DATASET_FIELD_TAG: - return { ...state, refreshTags: false } + return { ...state } case DELETE_DATASET_FIELD_TAG_SUCCESS: - return { ...state, refreshTags: true } + return { + ...state, + } case ADD_DATASET_TAG: - return { ...state, refreshTags: false } + return { ...state } case ADD_DATASET_TAG_SUCCESS: return { ...state, refreshTags: true } case ADD_DATASET_FIELD_TAG: - return { ...state, refreshTags: false } + return { ...state } case ADD_DATASET_FIELD_TAG_SUCCESS: - return { ...state, refreshTags: true } + return { + ...state, + refreshTags: true, + } default: return state } diff --git a/web/src/store/sagas/index.ts b/web/src/store/sagas/index.ts index 0e36113c73..b233da757b 100644 --- a/web/src/store/sagas/index.ts +++ b/web/src/store/sagas/index.ts @@ -253,13 +253,8 @@ export function* deleteDatasetTagSaga() { while (true) { try { const { payload } = yield take(DELETE_DATASET_TAG) - const dataset: Dataset = yield call( - deleteDatasetTag, - payload.namespace, - payload.datasetName, - payload.tag - ) - yield put(deleteDatasetTagSuccess(dataset.name)) + yield call(deleteDatasetTag, payload.namespace, payload.datasetName, payload.tag) + yield put(deleteDatasetTagSuccess(payload.namespace, payload.datasetName, payload.tag)) } catch (e) { yield put(applicationError('Something went wrong while removing tag from dataset')) } @@ -270,14 +265,21 @@ export function* deleteDatasetFieldTagSaga() { while (true) { try { const { payload } = yield take(DELETE_DATASET_FIELD_TAG) - const dataset: Dataset = yield call( + yield call( deleteDatasetFieldTag, payload.namespace, payload.datasetName, payload.tag, payload.field ) - yield put(deleteDatasetFieldTagSuccess(dataset.name)) + yield put( + deleteDatasetFieldTagSuccess( + payload.namespace, + payload.datasetName, + payload.field, + payload.tag + ) + ) } catch (e) { yield put(applicationError('Something went wrong while removing tag from dataset field')) } @@ -288,13 +290,8 @@ export function* addDatasetTagSaga() { while (true) { try { const { payload } = yield take(ADD_DATASET_TAG) - const dataset: Dataset = yield call( - addDatasetTag, - payload.namespace, - payload.datasetName, - payload.tag - ) - yield put(addDatasetTagSuccess(dataset.name)) + yield call(addDatasetTag, payload.namespace, payload.datasetName, payload.tag) + yield put(addDatasetTagSuccess(payload.namespace, payload.datasetName, payload.tag)) } catch (e) { yield put(applicationError('Something went wrong while adding tag to dataset')) } @@ -305,14 +302,21 @@ export function* addDatasetFieldTagSaga() { while (true) { try { const { payload } = yield take(ADD_DATASET_FIELD_TAG) - const dataset: Dataset = yield call( + yield call( addDatasetFieldTag, payload.namespace, payload.datasetName, payload.tag, payload.field ) - yield put(addDatasetFieldTagSuccess(dataset.name)) + yield put( + addDatasetFieldTagSuccess( + payload.namespace, + payload.datasetName, + payload.field, + payload.tag + ) + ) } catch (e) { yield put(applicationError('Something went wrong while adding tag to dataset field.')) }