From 53ec29bc8259cfe801e5f61842767370a4a2d815 Mon Sep 17 00:00:00 2001 From: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> Date: Wed, 29 Mar 2023 11:29:11 +0530 Subject: [PATCH] feat(ui): support assets selection from glossary page (#10803) * feat: initial commit glossary redesign * chore: add localization * fix: update glossary ui * fix: missing localization * feat: update glossary ui * fix: jest tests * fix: jest tests * fix: update breadcrumbs * fix: update cypress tests * chore: remove logs * fix: update glossary right panel * fix: jest tests * fix: add reviewer functionality * feat(ui): support assets selection from glossary page --------- Co-authored-by: karanh37 Co-authored-by: karanh37 <33024356+karanh37@users.noreply.github.com> --- .../AssetSelectionModal.interface.ts | 45 +++ .../AssetSelectionModal.tsx | 265 +++++++++++++++++ .../components/Explore/explore.interface.ts | 3 +- .../GlossaryHeaderButtons.component.tsx | 14 + .../GlossaryTermReferencesModal.component.tsx | 3 +- .../table-data-card-v2/TableDataCardV2.tsx | 275 ++++++++++-------- .../resources/ui/src/utils/GlossaryUtils.ts | 24 ++ 7 files changed, 499 insertions(+), 130 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/Assets/AssetsSelectionModal/AssetSelectionModal.interface.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/Assets/AssetsSelectionModal/AssetSelectionModal.tsx diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Assets/AssetsSelectionModal/AssetSelectionModal.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Assets/AssetsSelectionModal/AssetSelectionModal.interface.ts new file mode 100644 index 00000000000..903a93e8ae5 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Assets/AssetsSelectionModal/AssetSelectionModal.interface.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2023 Collate. + * Licensed 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. + */ +import { EntityType } from 'enums/entity.enum'; +import { Container } from 'generated/entity/data/container'; +import { Dashboard } from 'generated/entity/data/dashboard'; +import { Mlmodel } from 'generated/entity/data/mlmodel'; +import { Pipeline } from 'generated/entity/data/pipeline'; +import { Table } from 'generated/entity/data/table'; +import { Topic } from 'generated/entity/data/topic'; + +export interface AssetSelectionModalProps { + glossaryFQN: string; + open: boolean; + onCancel: () => void; + onSave?: () => void; +} + +export type AssetsUnion = + | EntityType.TABLE + | EntityType.PIPELINE + | EntityType.DASHBOARD + | EntityType.MLMODEL + | EntityType.TOPIC + | EntityType.CONTAINER; + +export type AssetFilterKeys = AssetsUnion | 'all'; + +export type MapPatchAPIResponse = { + [EntityType.TABLE]: Table; + [EntityType.DASHBOARD]: Dashboard; + [EntityType.MLMODEL]: Mlmodel; + [EntityType.PIPELINE]: Pipeline; + [EntityType.CONTAINER]: Container; + [EntityType.TOPIC]: Topic; +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Assets/AssetsSelectionModal/AssetSelectionModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Assets/AssetsSelectionModal/AssetSelectionModal.tsx new file mode 100644 index 00000000000..afb5465f460 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Assets/AssetsSelectionModal/AssetSelectionModal.tsx @@ -0,0 +1,265 @@ +/* + * Copyright 2023 Collate. + * Licensed 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. + */ +import { Button, List, Modal, Radio, Space } from 'antd'; +import Searchbar from 'components/common/searchbar/Searchbar'; +import TableDataCardV2 from 'components/common/table-data-card-v2/TableDataCardV2'; +import { EntityUnion } from 'components/Explore/explore.interface'; +import Loader from 'components/Loader/Loader'; +import { PAGE_SIZE_MEDIUM } from 'constants/constants'; +import { EntityType } from 'enums/entity.enum'; +import { SearchIndex } from 'enums/search.enum'; +import { compare, Operation } from 'fast-json-patch'; +import { cloneDeep, groupBy, map, startCase } from 'lodash'; +import { EntityDetailUnion } from 'Models'; +import VirtualList from 'rc-virtual-list'; +import { + default as React, + UIEventHandler, + useEffect, + useMemo, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { patchDashboardDetails } from 'rest/dashboardAPI'; +import { patchMlModelDetails } from 'rest/mlModelAPI'; +import { patchContainerDetails } from 'rest/objectStoreAPI'; +import { patchPipelineDetails } from 'rest/pipelineAPI'; +import { searchQuery } from 'rest/searchAPI'; +import { patchTableDetails } from 'rest/tableAPI'; +import { patchTopicDetails } from 'rest/topicsAPI'; +import { getCountBadge } from 'utils/CommonUtils'; +import { getQueryFilterToExcludeTerm } from 'utils/GlossaryUtils'; +import { + AssetFilterKeys, + AssetSelectionModalProps, + AssetsUnion, + MapPatchAPIResponse, +} from './AssetSelectionModal.interface'; + +export const AssetSelectionModal = ({ + glossaryFQN, + onCancel, + open, +}: AssetSelectionModalProps) => { + const { t } = useTranslation(); + const [search, setSearch] = useState(''); + const [items, setItems] = useState([]); + const [itemCount, setItemCount] = useState>(); + const [selectedItems, setSelectedItems] = + useState>(); + const [isLoading, setIsLoading] = useState(false); + const [activeFilter, setActiveFilter] = useState('all'); + const [pageNumber, setPageNumber] = useState(1); + + const fetchEntities = async (searchText = '', page = 1) => { + try { + setIsLoading(true); + const res = await searchQuery({ + pageNumber: page, + pageSize: PAGE_SIZE_MEDIUM, + searchIndex: [ + SearchIndex.TABLE, + SearchIndex.PIPELINE, + SearchIndex.MLMODEL, + SearchIndex.TOPIC, + SearchIndex.DASHBOARD, + SearchIndex.CONTAINER, + ], + query: searchText, + queryFilter: getQueryFilterToExcludeTerm(glossaryFQN), + }); + + const groupedArray = groupBy(res.hits.hits, '_source.entityType'); + + setItemCount({ + all: res.hits.total.value, + [EntityType.TABLE]: groupedArray[EntityType.TABLE]?.length ?? 0, + [EntityType.PIPELINE]: groupedArray[EntityType.PIPELINE]?.length ?? 0, + [EntityType.MLMODEL]: groupedArray[EntityType.MLMODEL]?.length ?? 0, + [EntityType.TOPIC]: groupedArray[EntityType.TOPIC]?.length ?? 0, + [EntityType.DASHBOARD]: groupedArray[EntityType.DASHBOARD]?.length ?? 0, + [EntityType.CONTAINER]: groupedArray[EntityType.CONTAINER]?.length ?? 0, + }); + setActiveFilter('all'); + setItems(res.hits.hits); + setPageNumber(page); + } catch (error) { + console.error(error); + } finally { + setIsLoading(false); + } + }; + + const handleCardClick = (details: EntityUnion) => { + const id = details.id; + if (!id) { + return; + } + if (selectedItems?.has(id ?? '')) { + setSelectedItems((prevItems) => { + const selectedItemMap = new Map(); + + prevItems?.forEach( + (item) => item.id !== id && selectedItemMap.set(item.id, item) + ); + + return selectedItemMap; + }); + } else { + setSelectedItems((prevItems) => { + const selectedItemMap = new Map(); + + prevItems?.forEach((item) => selectedItemMap.set(item.id, item)); + + selectedItemMap.set( + id, + items.find(({ _source }) => _source.id === id)._source + ); + + return selectedItemMap; + }); + } + }; + + const getAPIfromSource = ( + source: AssetsUnion + ): (( + id: string, + jsonPatch: Operation[] + ) => Promise) => { + switch (source) { + case EntityType.TABLE: + return patchTableDetails; + case EntityType.DASHBOARD: + return patchDashboardDetails; + case EntityType.MLMODEL: + return patchMlModelDetails; + case EntityType.PIPELINE: + return patchPipelineDetails; + case EntityType.TOPIC: + return patchTopicDetails; + case EntityType.CONTAINER: + return patchContainerDetails; + } + }; + + const handleSave = async () => { + setIsLoading(true); + const promises = [...(selectedItems?.values() ?? [])].map((item) => { + const jsonPatch = compare( + { tags: item.tags }, + { + tags: [ + ...item.tags, + { tagFQN: glossaryFQN, source: 'Glossary', labelType: 'Manual' }, + ], + } + ); + + const api = getAPIfromSource(item.entityType); + + return api(item.id, jsonPatch); + }); + + try { + await Promise.all(promises); + onCancel(); + } catch (error) { + console.error(error); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchEntities(); + }, []); + + const onScroll: UIEventHandler = (e) => { + if (e.currentTarget.scrollHeight - e.currentTarget.scrollTop === 500) { + fetchEntities(search, pageNumber + 1); + } + }; + + const filteredData = useMemo(() => { + return activeFilter === 'all' + ? cloneDeep(items) + : items.filter((i) => i._source.entityType === activeFilter); + }, [items, activeFilter]); + + return ( + + + + + } + open={open} + style={{ top: 40 }} + title={t('label.add-entity', { entity: t('label.asset-plural') })} + width={750}> + + { + setSearch(s); + fetchEntities(s); + }} + /> + setActiveFilter(e.target.value)}> + {map( + itemCount, + (value, key) => + value > 0 && ( + + {startCase(key)} {getCountBadge(value)} + + ) + )} + + }}> + + {({ _index: index, _source: item }) => ( + + )} + + + + + ); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/explore.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Explore/explore.interface.ts index 206e4ab00c7..d10ff74480b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Explore/explore.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/explore.interface.ts @@ -49,7 +49,8 @@ export type ExploreSearchIndexKey = | 'PIPELINE' | 'DASHBOARD' | 'MLMODEL' - | 'TOPIC'; + | 'TOPIC' + | 'CONTAINER'; export type SearchHitCounts = Record; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryHeaderButtons/GlossaryHeaderButtons.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryHeaderButtons/GlossaryHeaderButtons.component.tsx index 3d6fafc0908..26b587769a9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryHeaderButtons/GlossaryHeaderButtons.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryHeaderButtons/GlossaryHeaderButtons.component.tsx @@ -16,6 +16,7 @@ import { ReactComponent as ExportIcon } from 'assets/svg/ic-export.svg'; import { ReactComponent as ImportIcon } from 'assets/svg/ic-import.svg'; import { ReactComponent as IconDropdown } from 'assets/svg/menu.svg'; import { AxiosError } from 'axios'; +import { AssetSelectionModal } from 'components/Assets/AssetsSelectionModal/AssetSelectionModal'; import EntityDeleteModal from 'components/Modals/EntityDeleteModal/EntityDeleteModal'; import { OperationPermission } from 'components/PermissionProvider/PermissionProvider.interface'; import VersionButton from 'components/VersionButton/VersionButton.component'; @@ -66,6 +67,7 @@ const GlossaryHeaderButtons = ({ const [showActions, setShowActions] = useState(false); const [isDelete, setIsDelete] = useState(false); const [, setVersionList] = useState({} as EntityHistory); + const [showAddAssets, setShowAddAssets] = useState(false); const isExportAction = useMemo( () => action === GlossaryAction.EXPORT, @@ -115,6 +117,10 @@ const GlossaryHeaderButtons = ({ setIsDelete(false); }; + const handleAddAssetsClick = () => { + setShowAddAssets(true); + }; + const addButtonContent = [ { label: t('label.glossary-term'), @@ -124,6 +130,7 @@ const GlossaryHeaderButtons = ({ { label: t('label.asset-plural'), key: '2', + onClick: () => handleAddAssetsClick(), }, ]; @@ -317,6 +324,13 @@ const GlossaryHeaderButtons = ({ onOk={handleCancelGlossaryExport} /> )} + {selectedData.fullyQualifiedName && !isGlossary && ( + setShowAddAssets(false)} + /> + )} ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/GlossaryTerms/GlossaryTermReferencesModal.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/GlossaryTerms/GlossaryTermReferencesModal.component.tsx index 2cc8577bd8d..d30c9cd07e4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/GlossaryTerms/GlossaryTermReferencesModal.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/GlossaryTerms/GlossaryTermReferencesModal.component.tsx @@ -48,6 +48,7 @@ const GlossaryTermReferencesModal = ({ return ( {t('label.cancel')} @@ -60,8 +61,8 @@ const GlossaryTermReferencesModal = ({ {t('label.save')} , ]} + open={isVisible} title={t('label.reference-plural')} - visible={isVisible} onCancel={onClose}>
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/table-data-card-v2/TableDataCardV2.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/table-data-card-v2/TableDataCardV2.tsx index 707709c40bb..fb165b449b9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/table-data-card-v2/TableDataCardV2.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/table-data-card-v2/TableDataCardV2.tsx @@ -12,11 +12,12 @@ */ import { ExclamationCircleOutlined } from '@ant-design/icons'; +import { Checkbox } from 'antd'; import classNames from 'classnames'; import { EntityUnion } from 'components/Explore/explore.interface'; import { isString, startCase, uniqueId } from 'lodash'; import { ExtraInfo } from 'Models'; -import React, { useMemo } from 'react'; +import React, { forwardRef, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useLocation, useParams } from 'react-router-dom'; import { getEntityId, getEntityName } from 'utils/EntityUtils'; @@ -56,146 +57,164 @@ export interface TableDataCardPropsV2 { details: EntityUnion, entityType: string ) => void; + checked?: boolean; + showCheckboxes?: boolean; } -const TableDataCardV2: React.FC = ({ - id, - className, - source, - matches, - searchIndex, - handleSummaryPanelDisplay, -}) => { - const { t } = useTranslation(); - const location = useLocation(); - const { tab } = useParams<{ tab: string }>(); +const TableDataCardV2: React.FC = forwardRef< + HTMLDivElement, + TableDataCardPropsV2 +>( + ( + { + id, + className, + source, + matches, + searchIndex, + handleSummaryPanelDisplay, + showCheckboxes, + checked, + }, + ref + ) => { + const { t } = useTranslation(); + const location = useLocation(); + const { tab } = useParams<{ tab: string }>(); - const otherDetails = useMemo(() => { - const _otherDetails: ExtraInfo[] = [ - { - key: 'Owner', - value: getOwnerValue(source.owner as EntityReference), - placeholderText: getEntityPlaceHolder( - getEntityName(source.owner as EntityReference), - source.owner?.deleted - ), - id: getEntityId(source.owner as EntityReference), - isEntityDetails: true, - isLink: true, - openInNewTab: false, - profileName: - source.owner?.type === OwnerType.USER - ? source.owner?.name - : undefined, - }, - ]; + const otherDetails = useMemo(() => { + const _otherDetails: ExtraInfo[] = [ + { + key: 'Owner', + value: getOwnerValue(source.owner as EntityReference), + placeholderText: getEntityPlaceHolder( + getEntityName(source.owner as EntityReference), + source.owner?.deleted + ), + id: getEntityId(source.owner as EntityReference), + isEntityDetails: true, + isLink: true, + openInNewTab: false, + profileName: + source.owner?.type === OwnerType.USER + ? source.owner?.name + : undefined, + }, + ]; - if ( - source.entityType !== EntityType.GLOSSARY_TERM && - source.entityType !== EntityType.TAG - ) { - _otherDetails.push({ - key: 'Tier', - value: source.tier - ? isString(source.tier) - ? source.tier - : source.tier?.tagFQN.split(FQN_SEPARATOR_CHAR)[1] - : '', - }); - } + if ( + source.entityType !== EntityType.GLOSSARY_TERM && + source.entityType !== EntityType.TAG + ) { + _otherDetails.push({ + key: 'Tier', + value: source.tier + ? isString(source.tier) + ? source.tier + : source.tier?.tagFQN.split(FQN_SEPARATOR_CHAR)[1] + : '', + }); + } - if ('usageSummary' in source) { - _otherDetails.push({ - value: getUsagePercentile( - source.usageSummary?.weeklyStats?.percentileRank || 0, - true - ), - }); - } + if ('usageSummary' in source) { + _otherDetails.push({ + value: getUsagePercentile( + source.usageSummary?.weeklyStats?.percentileRank || 0, + true + ), + }); + } - if ('tableType' in source) { - _otherDetails.push({ - key: 'Type', - value: source.tableType, - showLabel: true, - }); - } + if ('tableType' in source) { + _otherDetails.push({ + key: 'Type', + value: source.tableType, + showLabel: true, + }); + } - return _otherDetails; - }, [source]); + return _otherDetails; + }, [source]); - const handleLinkClick = (e: React.MouseEvent) => { - e.stopPropagation(); - if (location.pathname.includes(ROUTES.TOUR)) { - AppState.currentTourPage = CurrentTourPageType.DATASET_PAGE; - } - }; + const handleLinkClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if (location.pathname.includes(ROUTES.TOUR)) { + AppState.currentTourPage = CurrentTourPageType.DATASET_PAGE; + } + }; - const headerLabel = useMemo(() => { - return getEntityHeaderLabel(source); - }, [source]); + const headerLabel = useMemo(() => { + return getEntityHeaderLabel(source); + }, [source]); - const serviceIcon = useMemo(() => { - return getServiceIcon(source); - }, [source]); + const serviceIcon = useMemo(() => { + return getServiceIcon(source); + }, [source]); - return ( -
{ - handleSummaryPanelDisplay && - handleSummaryPanelDisplay(source as EntityUnion, tab); - }}> -
- {headerLabel} -
- {serviceIcon} - { + handleSummaryPanelDisplay && + handleSummaryPanelDisplay(source as EntityUnion, tab); + }}> +
+ {headerLabel} +
+ {serviceIcon} + + + {source.deleted && ( + <> +
+ + {t('label.deleted')} +
+ + )} + {showCheckboxes && ( + + )} +
+
+
+ - - {source.deleted && ( - <> -
- - {t('label.deleted')} -
- - )}
+ {matches && matches.length > 0 ? ( +
+ {`${t( + 'label.matches' + )}:`} + {matches.map((data, i) => ( + + {`${data.value} in ${startCase(data.key)}${ + i !== matches.length - 1 ? ',' : '' + }`} + + ))} +
+ ) : null}
-
- -
- {matches && matches.length > 0 ? ( -
- {`${t('label.matches')}:`} - {matches.map((data, i) => ( - - {`${data.value} in ${startCase(data.key)}${ - i !== matches.length - 1 ? ',' : '' - }`} - - ))} -
- ) : null} -
- ); -}; + ); + } +); export default TableDataCardV2; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.ts index 0c1a1ec7082..28a0efde9b1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.ts @@ -160,3 +160,27 @@ export const getSearchedDataFromGlossaryTree = ( return acc; }, [] as ModifiedGlossaryTerm[]); }; + +export const getQueryFilterToExcludeTerm = (fqn: string) => ({ + query: { + bool: { + must: [ + { + bool: { + must: [ + { + bool: { + must_not: { + term: { + 'tags.tagFQN': fqn, + }, + }, + }, + }, + ], + }, + }, + ], + }, + }, +});