From b55d5be3bc997f4f360d57fdf7afa477407582ea Mon Sep 17 00:00:00 2001 From: Karan Hotchandani <33024356+karanh37@users.noreply.github.com> Date: Sat, 6 Jul 2024 17:52:42 +0530 Subject: [PATCH] Show Data Assets in hierarchy on Explore (#16938) * initial commit * push changes * update explore page * fix explore page * update labels * add tests * fix review comments * fix review comments * fix review comments * fix sizing --- .../playwright/e2e/Pages/ExploreTree.spec.ts | 87 +++++++++ .../resources/ui/src/assets/svg/ic-search.svg | 2 +- .../AssetSelectionModal.tsx | 4 +- .../CustomControls.component.tsx | 2 +- .../Explore/ExplorePage.interface.ts | 2 +- .../ExploreTree/ExploreTree.interface.ts | 45 +++++ .../Explore/ExploreTree/ExploreTree.test.tsx | 32 +++ .../Explore/ExploreTree/ExploreTree.tsx | 184 ++++++++++++++++++ .../components/Explore/useExplore.store.ts | 24 +++ .../ExploreV1/ExploreV1.component.tsx | 94 +++++++-- .../src/components/ExploreV1/exploreV1.less | 30 ++- .../tabs/AssetsTabs.component.tsx | 2 +- .../src/constants/AdvancedSearch.constants.ts | 31 +++ .../ExplorePage/ExplorePage.interface.ts | 5 + .../ExplorePage/ExplorePageV1.component.tsx | 111 ++++++----- .../src/main/resources/ui/src/styles/app.less | 3 + ...ore.utils.test.ts => ExploreUtils.test.ts} | 101 +++++++++- .../{Explore.utils.ts => ExploreUtils.ts} | 59 ++++++ .../resources/ui/src/utils/SearchClassBase.ts | 79 ++++++++ .../ui/src/utils/ServiceUtilClassBase.ts | 12 +- 20 files changed, 828 insertions(+), 81 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/ExploreTree.spec.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreTree/ExploreTree.interface.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreTree/ExploreTree.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreTree/ExploreTree.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/Explore/useExplore.store.ts rename openmetadata-ui/src/main/resources/ui/src/utils/{Explore.utils.test.ts => ExploreUtils.test.ts} (60%) rename openmetadata-ui/src/main/resources/ui/src/utils/{Explore.utils.ts => ExploreUtils.ts} (78%) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/ExploreTree.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/ExploreTree.spec.ts new file mode 100644 index 00000000000..d3bd900a46b --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/ExploreTree.spec.ts @@ -0,0 +1,87 @@ +/* + * Copyright 2024 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 test, { expect } from '@playwright/test'; +import { SidebarItem } from '../../constant/sidebar'; +import { redirectToHomePage } from '../../utils/common'; +import { sidebarClick } from '../../utils/sidebar'; + +// use the admin user to login +test.use({ storageState: 'playwright/.auth/admin.json' }); + +test.beforeEach(async ({ page }) => { + await redirectToHomePage(page); + await sidebarClick(page, SidebarItem.EXPLORE); +}); + +test('Explore Tree', async ({ page }) => { + await page.getByTestId('explore-tree-tab').getByText('Tree').click(); + + await test.step('Check the explore tree', async () => { + await expect(page.getByRole('tree')).toContainText('Databases'); + await expect(page.getByRole('tree')).toContainText('Dashboards'); + await expect(page.getByRole('tree')).toContainText('Pipelines'); + await expect(page.getByRole('tree')).toContainText('Topics'); + await expect(page.getByRole('tree')).toContainText('ML Models'); + await expect(page.getByRole('tree')).toContainText('Containers'); + await expect(page.getByRole('tree')).toContainText('Search Indexes'); + await expect(page.getByRole('tree')).toContainText('Governance'); + + await page + .locator('div') + .filter({ hasText: /^Governance$/ }) + .locator('svg') + .first() + .click(); + + await expect(page.getByRole('tree')).toContainText('Glossaries'); + await expect(page.getByRole('tree')).toContainText('Tags'); + }); + + await test.step('Check the quick filters', async () => { + await expect( + page.getByTestId('search-dropdown-Domain').locator('span') + ).toContainText('Domain'); + await expect(page.getByTestId('search-dropdown-Owner')).toContainText( + 'Owner' + ); + await expect( + page.getByTestId('search-dropdown-Tag').locator('span') + ).toContainText('Tag'); + + await page.getByRole('button', { name: 'Tier' }).click(); + + await expect( + page.getByTestId('search-dropdown-Tier').locator('span') + ).toContainText('Tier'); + await expect( + page.getByTestId('search-dropdown-Service').locator('span') + ).toContainText('Service'); + await expect( + page.getByTestId('search-dropdown-Service Type').locator('span') + ).toContainText('Service Type'); + }); + + await test.step('Click on tree item and check quick filter', async () => { + await page.getByTestId('explore-tree-title-Glossaries').click(); + + await expect(page.getByTestId('search-dropdown-Data Assets')).toContainText( + 'Data Assets: glossaryTerm' + ); + + await page.getByTestId('explore-tree-title-Tags').click(); + + await expect(page.getByTestId('search-dropdown-Data Assets')).toContainText( + 'Data Assets: tag' + ); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-search.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-search.svg index 6e7771027c4..206c4dcc02c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-search.svg +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-search.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionModal.tsx index 75e2b5b611d..8fd9b0ecf39 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionModal.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionModal.tsx @@ -65,11 +65,11 @@ import { import { searchQuery } from '../../../rest/searchAPI'; import { getAssetsPageQuickFilters } from '../../../utils/AdvancedSearchUtils'; import { getEntityReferenceFromEntity } from '../../../utils/EntityUtils'; +import { getCombinedQueryFilterObject } from '../../../utils/ExplorePage/ExplorePageUtils'; import { getAggregations, getQuickFilterQuery, -} from '../../../utils/Explore.utils'; -import { getCombinedQueryFilterObject } from '../../../utils/ExplorePage/ExplorePageUtils'; +} from '../../../utils/ExploreUtils'; import { showErrorToast } from '../../../utils/ToastUtils'; import ErrorPlaceHolder from '../../common/ErrorWithPlaceholder/ErrorPlaceHolder'; import Loader from '../../common/Loader/Loader'; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/CustomControls.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/CustomControls.component.tsx index 3abb0b532cf..60c6a410ab1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/CustomControls.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/CustomControls.component.tsx @@ -37,7 +37,7 @@ import { SearchIndex } from '../../../enums/search.enum'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { getAssetsPageQuickFilters } from '../../../utils/AdvancedSearchUtils'; import { getLoadingStatusValue } from '../../../utils/EntityLineageUtils'; -import { getQuickFilterQuery } from '../../../utils/Explore.utils'; +import { getQuickFilterQuery } from '../../../utils/ExploreUtils'; import { ExploreQuickFilterField } from '../../Explore/ExplorePage.interface'; import ExploreQuickFilters from '../../Explore/ExploreQuickFilters'; import { AssetsOfEntity } from '../../Glossary/GlossaryTerms/tabs/AssetsTabs.interface'; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExplorePage.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExplorePage.interface.ts index b625b7578b6..e13ee8aa1fc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExplorePage.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExplorePage.interface.ts @@ -75,7 +75,7 @@ export interface ExploreProps { queryFilter: QueryFilterInterface | undefined ) => void; - searchIndex: ExploreSearchIndex; + searchIndex: SearchIndex.DATA_ASSET | ExploreSearchIndex; onChangeSearchIndex: (searchIndex: ExploreSearchIndex) => void; sortValue: string; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreTree/ExploreTree.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreTree/ExploreTree.interface.ts new file mode 100644 index 00000000000..80db602f537 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreTree/ExploreTree.interface.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2024 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 { ReactNode } from 'react'; +import { EntityFields } from '../../../enums/AdvancedSearch.enum'; +import { EntityType } from '../../../enums/entity.enum'; +import { ExploreQuickFilterField } from '../ExplorePage.interface'; + +export type ExploreTreeNode = { + title: ReactNode; + key: string; + children?: ExploreTreeNode[]; + isLeaf?: boolean; + icon?: JSX.Element | SvgComponent; + data?: TreeNodeData; +}; + +export type ExploreTreeProps = { + onFieldValueSelect: (field: ExploreQuickFilterField[]) => void; +}; + +export type TreeNodeData = { + isRoot?: boolean; + currentBucketKey?: string; + currentBucketValue?: string; + filterField?: ExploreQuickFilterField[]; + parentSearchIndex?: string; + rootIndex?: string; + entityType?: EntityType; +}; + +export type DatabaseFields = + | EntityFields.SERVICE_TYPE + | EntityFields.SERVICE + | EntityFields.DATABASE + | EntityFields.DATABASE_SCHEMA; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreTree/ExploreTree.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreTree/ExploreTree.test.tsx new file mode 100644 index 00000000000..ed0bc4b0371 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreTree/ExploreTree.test.tsx @@ -0,0 +1,32 @@ +/* + * Copyright 2024 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 { render } from '@testing-library/react'; +import React from 'react'; +import ExploreTree from './ExploreTree'; + +describe('ExploreTree', () => { + it('renders the correct tree nodes', () => { + const { getByText } = render( + + ); + + expect(getByText('label.database-plural')).toBeInTheDocument(); + expect(getByText('label.dashboard-plural')).toBeInTheDocument(); + expect(getByText('label.topic-plural')).toBeInTheDocument(); + expect(getByText('label.container-plural')).toBeInTheDocument(); + expect(getByText('label.pipeline-plural')).toBeInTheDocument(); + expect(getByText('label.search-index-plural')).toBeInTheDocument(); + expect(getByText('label.ml-model-plural')).toBeInTheDocument(); + expect(getByText('label.governance')).toBeInTheDocument(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreTree/ExploreTree.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreTree/ExploreTree.tsx new file mode 100644 index 00000000000..4bc1392ba67 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreTree/ExploreTree.tsx @@ -0,0 +1,184 @@ +/* + * Copyright 2024 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 { Tree, Typography } from 'antd'; +import classNames from 'classnames'; +import { uniqueId } from 'lodash'; +import React, { useCallback, useState } from 'react'; +import { EntityFields } from '../../../enums/AdvancedSearch.enum'; +import { SearchIndex } from '../../../enums/search.enum'; +import { getAggregateFieldOptions } from '../../../rest/miscAPI'; +import { getCountBadge } from '../../../utils/CommonUtils'; +import { getEntityNameLabel } from '../../../utils/EntityUtils'; +import { + getAggregations, + getSubLevelHierarchyKey, + updateTreeData, +} from '../../../utils/ExploreUtils'; +import searchClassBase from '../../../utils/SearchClassBase'; +import serviceUtilClassBase from '../../../utils/ServiceUtilClassBase'; +import { getEntityIcon } from '../../../utils/TableUtils'; +import { + ExploreTreeNode, + ExploreTreeProps, + TreeNodeData, +} from './ExploreTree.interface'; + +const ExploreTreeTitle = ({ node }: { node: ExploreTreeNode }) => ( + + {node.title} + +); + +const ExploreTree = ({ onFieldValueSelect }: ExploreTreeProps) => { + const initTreeData = searchClassBase.getExploreTree(); + const [treeData, setTreeData] = useState(initTreeData); + + const onLoadData = useCallback( + async (treeNode: ExploreTreeNode) => { + if (treeNode.children) { + return; + } + + const { + isRoot = false, + currentBucketKey, + currentBucketValue, + filterField = [], + rootIndex, + } = treeNode?.data as TreeNodeData; + + const searchIndex = isRoot + ? treeNode.key + : treeNode?.data?.parentSearchIndex; + + const { bucket: bucketToFind, queryFilter } = getSubLevelHierarchyKey( + rootIndex === SearchIndex.DATABASE, + currentBucketKey as EntityFields, + currentBucketValue + ); + + const res = await getAggregateFieldOptions( + searchIndex as SearchIndex, + bucketToFind, + '', + JSON.stringify(queryFilter) + ); + const aggregations = getAggregations(res.data.aggregations); + const buckets = aggregations[bucketToFind].buckets; + const isServiceType = bucketToFind === EntityFields.SERVICE_TYPE; + const isEntityType = bucketToFind === EntityFields.ENTITY_TYPE; + + const sortedBuckets = buckets.sort((a, b) => + a.key.localeCompare(b.key, undefined, { sensitivity: 'base' }) + ); + + const children = sortedBuckets.map((bucket) => { + let logo = <>; + const title = ( +
+ + {isEntityType ? getEntityNameLabel(bucket.key) : bucket.key} + + {isEntityType && {getCountBadge(bucket.doc_count)}} +
+ ); + if (isEntityType) { + logo = getEntityIcon(bucket.key, 'service-icon w-4 h-4'); + } else if (isServiceType) { + const serviceIcon = serviceUtilClassBase.getServiceLogo(bucket.key); + logo = ( + logo + ); + } + + return { + title: title, + key: uniqueId(), + icon: logo, + isLeaf: bucketToFind === EntityFields.ENTITY_TYPE, + data: { + currentBucketKey: bucketToFind, + parentSearchIndex: isRoot ? treeNode.key : SearchIndex.DATA_ASSET, + currentBucketValue: bucket.key, + filterField: [ + ...filterField, + { + label: bucketToFind, + key: bucketToFind, + value: [ + { + key: bucket.key, + label: bucket.key, + }, + ], + }, + ], + isRoot: false, + rootIndex: isRoot ? treeNode.key : treeNode.data?.rootIndex, + }, + }; + }); + + setTreeData((origin) => updateTreeData(origin, treeNode.key, children)); + }, + [updateTreeData] + ); + + const onNodeSelect = useCallback( + (_, { node }) => { + const filterField = node.data?.filterField; + if (filterField) { + onFieldValueSelect(filterField); + } else if (node.isLeaf) { + const filterField = [ + { + label: EntityFields.ENTITY_TYPE, + key: EntityFields.ENTITY_TYPE, + value: [ + { + key: node.data?.entityType, + label: node.data?.entityType, + }, + ], + }, + ]; + onFieldValueSelect(filterField); + } + }, + [onFieldValueSelect] + ); + + return ( + } + treeData={treeData} + onSelect={onNodeSelect} + /> + ); +}; + +export default ExploreTree; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/useExplore.store.ts b/openmetadata-ui/src/main/resources/ui/src/components/Explore/useExplore.store.ts new file mode 100644 index 00000000000..f7a8ebf0288 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/useExplore.store.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2024 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 { create } from 'zustand'; +import { ExploreSidebarTab } from '../../pages/ExplorePage/ExplorePage.interface'; + +export const useExploreStore = create<{ + sidebarActiveTab: ExploreSidebarTab; + setSidebarActiveTab: (tab: ExploreSidebarTab) => void; +}>()((set) => ({ + sidebarActiveTab: ExploreSidebarTab.ASSETS, + setSidebarActiveTab: (tab: ExploreSidebarTab) => { + set({ sidebarActiveTab: tab }); + }, +})); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreV1.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreV1.component.tsx index 41c40c29276..6e3e747fd1f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreV1.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreV1.component.tsx @@ -18,6 +18,7 @@ import { } from '@ant-design/icons'; import { Alert, + Badge, Button, Col, Layout, @@ -25,6 +26,7 @@ import { Row, Space, Switch, + Tabs, Typography, } from 'antd'; import { Content } from 'antd/lib/layout/layout'; @@ -42,17 +44,21 @@ import ExploreQuickFilters from '../../components/Explore/ExploreQuickFilters'; import SortingDropDown from '../../components/Explore/SortingDropDown'; import { NULL_OPTION_KEY } from '../../constants/AdvancedSearch.constants'; import { + entitySortingFields, SEARCH_INDEXING_APPLICATION, SUPPORTED_EMPTY_FILTER_FIELDS, TAG_FQN_KEY, } from '../../constants/explore.constants'; import { ERROR_PLACEHOLDER_TYPE, SORT_ORDER } from '../../enums/common.enum'; import { useApplicationStore } from '../../hooks/useApplicationStore'; -import { QueryFieldInterface } from '../../pages/ExplorePage/ExplorePage.interface'; +import { + ExploreSidebarTab, + QueryFieldInterface, +} from '../../pages/ExplorePage/ExplorePage.interface'; import { getDropDownItems } from '../../utils/AdvancedSearchUtils'; import { Transi18next } from '../../utils/CommonUtils'; import { highlightEntityNameAndDescription } from '../../utils/EntityUtils'; -import { getSelectedValuesFromQuickFilter } from '../../utils/Explore.utils'; +import { getSelectedValuesFromQuickFilter } from '../../utils/ExploreUtils'; import { getApplicationDetailsPath } from '../../utils/RouterUtils'; import searchClassBase from '../../utils/SearchClassBase'; import Loader from '../common/Loader/Loader'; @@ -62,6 +68,8 @@ import { ExploreQuickFilterField, ExploreSearchIndex, } from '../Explore/ExplorePage.interface'; +import ExploreTree from '../Explore/ExploreTree/ExploreTree'; +import { useExploreStore } from '../Explore/useExplore.store'; import SearchedData from '../SearchedData/SearchedData'; import { SearchedDataProps } from '../SearchedData/SearchedData.interface'; import './exploreV1.less'; @@ -134,6 +142,11 @@ const ExploreV1: React.FC = ({ const [showSummaryPanel, setShowSummaryPanel] = useState(false); const [entityDetails, setEntityDetails] = useState(); + const { sidebarActiveTab, setSidebarActiveTab } = useExploreStore(); + + const onTabChange = (key: string) => { + setSidebarActiveTab(key as ExploreSidebarTab); + }; const firstEntity = searchResults?.hits ?.hits[0] as SearchedDataProps['data'][number]; @@ -279,7 +292,8 @@ const ExploreV1: React.FC = ({ if ( !isUndefined(searchResults) && searchResults?.hits?.hits[0] && - searchResults?.hits?.hits[0]._index === searchIndex + (sidebarActiveTab === ExploreSidebarTab.TREE || + searchResults?.hits?.hits[0]._index === searchIndex) ) { handleSummaryPanelDisplay( highlightEntityNameAndDescription( @@ -293,6 +307,49 @@ const ExploreV1: React.FC = ({ } }, [searchResults]); + const SIDEBAR_TAB_ITEMS = [ + { + key: ExploreSidebarTab.ASSETS, + label: ( +
+ {t('label.asset-plural')} +
+ ), + children: ( + { + if (info && info.key !== activeTabKey) { + onChangeSearchIndex(info.key as ExploreSearchIndex); + setShowSummaryPanel(false); + } + }} + /> + ), + }, + { + key: ExploreSidebarTab.TREE, + label: ( +
+ {t('label.tree')} + +
+ ), + children: , + }, + ]; + if (tabItems.length === 0 && !searchQueryParam) { return ; } @@ -301,23 +358,15 @@ const ExploreV1: React.FC = ({
{tabItems.length > 0 && ( - - - {t('label.data-asset-plural')} - - { - if (info && info.key !== activeTabKey) { - onChangeSearchIndex(info.key as ExploreSearchIndex); - setShowSummaryPanel(false); - } - }} + + @@ -370,7 +419,10 @@ const ExploreV1: React.FC = ({ diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/exploreV1.less b/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/exploreV1.less index 2678fb33643..2d17963f77a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/exploreV1.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/exploreV1.less @@ -31,9 +31,9 @@ } .explore-page-tabs { .ant-tabs-nav { - margin: 0 !important; + margin-bottom: 8px; width: 100%; - height: @page-height; + padding: 0 1rem; } } .summary-panel-container { @@ -53,6 +53,12 @@ a.alert-link { font-size: inherit; } + .ant-tree { + .ant-tree-iconEle { + height: 20px; + width: 20px; + } + } } .explore-page { @@ -76,3 +82,23 @@ color: @primary-color; } } + +.explore-page { + .explore-tree { + height: @explore-page-height; + overflow: auto; + .ant-tree-switcher:hover { + background-color: @grey-2; + } + .ant-tree-title { + width: 100%; + padding: 4px; + } + .ant-tree-node-selected { + background-color: @radio-button-checked-bg !important; + } + .ant-tree-treenode { + padding: 0; + } + } +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/AssetsTabs.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/AssetsTabs.component.tsx index a7aba6f8a18..a91e90efd36 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/AssetsTabs.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/AssetsTabs.component.tsx @@ -81,7 +81,7 @@ import { import { getAggregations, getQuickFilterQuery, -} from '../../../../utils/Explore.utils'; +} from '../../../../utils/ExploreUtils'; import { escapeESReservedCharacters, getEncodedFqn, diff --git a/openmetadata-ui/src/main/resources/ui/src/constants/AdvancedSearch.constants.ts b/openmetadata-ui/src/main/resources/ui/src/constants/AdvancedSearch.constants.ts index 0d4fb4291db..119ef9d8135 100644 --- a/openmetadata-ui/src/main/resources/ui/src/constants/AdvancedSearch.constants.ts +++ b/openmetadata-ui/src/main/resources/ui/src/constants/AdvancedSearch.constants.ts @@ -42,6 +42,37 @@ export const COMMON_DROPDOWN_ITEMS = [ }, ]; +export const DATA_ASSET_DROPDOWN_ITEMS = [ + { + label: t('label.data-asset-plural'), + key: EntityFields.ENTITY_TYPE, + }, + { + label: t('label.domain'), + key: EntityFields.DOMAIN, + }, + { + label: t('label.owner'), + key: EntityFields.OWNER, + }, + { + label: t('label.tag'), + key: EntityFields.TAG, + }, + { + label: t('label.tier'), + key: EntityFields.TIER, + }, + { + label: t('label.service'), + key: EntityFields.SERVICE, + }, + { + label: t('label.service-type'), + key: EntityFields.SERVICE_TYPE, + }, +]; + export const TABLE_DROPDOWN_ITEMS = [ { label: t('label.database'), diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ExplorePage/ExplorePage.interface.ts b/openmetadata-ui/src/main/resources/ui/src/pages/ExplorePage/ExplorePage.interface.ts index 26cecc39551..529eccb07c2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ExplorePage/ExplorePage.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ExplorePage/ExplorePage.interface.ts @@ -55,3 +55,8 @@ export interface QueryFieldInterface { export interface QueryFilterInterface { query: QueryFieldInterface; } + +export enum ExploreSidebarTab { + ASSETS = 'assets', + TREE = 'tree', +} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ExplorePage/ExplorePageV1.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ExplorePage/ExplorePageV1.component.tsx index a440eea9d86..71412f5eea8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ExplorePage/ExplorePageV1.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ExplorePage/ExplorePageV1.component.tsx @@ -30,6 +30,7 @@ import { SearchHitCounts, UrlParams, } from '../../components/Explore/ExplorePage.interface'; +import { useExploreStore } from '../../components/Explore/useExplore.store'; import ExploreV1 from '../../components/ExploreV1/ExploreV1.component'; import { getExplorePath, PAGE_SIZE } from '../../constants/constants'; import { @@ -50,15 +51,16 @@ import { useApplicationStore } from '../../hooks/useApplicationStore'; import { Aggregations, SearchResponse } from '../../interface/search.interface'; import { searchQuery } from '../../rest/searchAPI'; import { getCountBadge } from '../../utils/CommonUtils'; +import { getCombinedQueryFilterObject } from '../../utils/ExplorePage/ExplorePageUtils'; import { extractTermKeys, findActiveSearchIndex, -} from '../../utils/Explore.utils'; -import { getCombinedQueryFilterObject } from '../../utils/ExplorePage/ExplorePageUtils'; +} from '../../utils/ExploreUtils'; import searchClassBase from '../../utils/SearchClassBase'; import { escapeESReservedCharacters } from '../../utils/StringsUtils'; import { showErrorToast } from '../../utils/ToastUtils'; import { + ExploreSidebarTab, QueryFieldInterface, QueryFilterInterface, } from './ExplorePage.interface'; @@ -79,6 +81,8 @@ const ExplorePageV1: FunctionComponent = () => { const [searchResults, setSearchResults] = useState>(); + const { sidebarActiveTab, setSidebarActiveTab } = useExploreStore(); + const [showIndexNotFoundAlert, setShowIndexNotFoundAlert] = useState(false); @@ -233,6 +237,10 @@ const ExplorePageV1: FunctionComponent = () => { }; const searchIndex = useMemo(() => { + if (sidebarActiveTab === ExploreSidebarTab.TREE) { + return SearchIndex.DATA_ASSET; + } + const tabInfo = Object.entries(tabsInfo).find( ([, tabInfo]) => tabInfo.path === tab ); @@ -245,7 +253,7 @@ const ExplorePageV1: FunctionComponent = () => { return !isNil(tabInfo) ? (tabInfo[0] as ExploreSearchIndex) : SearchIndex.TABLE; - }, [tab, searchHitCounts]); + }, [tab, searchHitCounts, sidebarActiveTab]); const tabItems = useMemo(() => { const items = Object.entries(tabsInfo).map( @@ -352,51 +360,57 @@ const ExplorePageV1: FunctionComponent = () => { ); setIsLoading(true); - Promise.all([ - searchQuery({ - query: !isEmpty(searchQueryParam) - ? escapeESReservedCharacters(searchQueryParam) - : '', - searchIndex, - queryFilter: combinedQueryFilter, - sortField: sortValue, - sortOrder: sortOrder, - pageNumber: page, - pageSize: size, - includeDeleted: showDeleted, - }) - .then((res) => res) - .then((res) => { - setSearchResults(res); - setUpdatedAggregations(res.aggregations); - }), - searchQuery({ - query: escapeESReservedCharacters(searchQueryParam), - pageNumber: 0, - pageSize: 0, - queryFilter: combinedQueryFilter, - searchIndex: SearchIndex.ALL, - includeDeleted: showDeleted, - trackTotalHits: true, - fetchSource: false, - filters: '', - }).then((res) => { - const buckets = res.aggregations['entityType'].buckets; - const counts: Record = {}; - buckets.forEach((item) => { - const searchIndexKey = - item && EntityTypeSearchIndexMapping[item.key as EntityType]; + const searchAPICall = searchQuery({ + query: !isEmpty(searchQueryParam) + ? escapeESReservedCharacters(searchQueryParam) + : '', + searchIndex, + queryFilter: combinedQueryFilter, + sortField: sortValue, + sortOrder: sortOrder, + pageNumber: page, + pageSize: size, + includeDeleted: showDeleted, + }).then((res) => { + setSearchResults(res as SearchResponse); + setUpdatedAggregations(res.aggregations); + }); - if ( - TABS_SEARCH_INDEXES.includes(searchIndexKey as ExploreSearchIndex) - ) { - counts[searchIndexKey ?? ''] = item.doc_count; - } - }); - setSearchHitCounts(counts as SearchHitCounts); - }), - ]) + const countAPICall = searchQuery({ + query: escapeESReservedCharacters(searchQueryParam), + pageNumber: 0, + pageSize: 0, + queryFilter: combinedQueryFilter, + searchIndex: SearchIndex.ALL, + includeDeleted: showDeleted, + trackTotalHits: true, + fetchSource: false, + filters: '', + }).then((res) => { + const buckets = res.aggregations['entityType'].buckets; + const counts: Record = {}; + + buckets.forEach((item) => { + const searchIndexKey = + item && EntityTypeSearchIndexMapping[item.key as EntityType]; + + if ( + TABS_SEARCH_INDEXES.includes(searchIndexKey as ExploreSearchIndex) + ) { + counts[searchIndexKey ?? ''] = item.doc_count; + } + }); + setSearchHitCounts(counts as SearchHitCounts); + }); + + const apiCalls = [searchAPICall]; + + if (sidebarActiveTab !== ExploreSidebarTab.TREE) { + apiCalls.push(countAPICall); + } + + Promise.all(apiCalls) .catch((error) => { if ( error.response?.data.message.includes(FAILED_TO_FIND_INDEX_ERROR) || @@ -407,10 +421,13 @@ const ExplorePageV1: FunctionComponent = () => { showErrorToast(error); } }) - .finally(() => setIsLoading(false)); }; + useEffect(() => { + setSidebarActiveTab(ExploreSidebarTab.ASSETS); + }, []); + useEffect(() => { if (isTourOpen) { setSearchHitCounts(MOCK_EXPLORE_PAGE_COUNT); diff --git a/openmetadata-ui/src/main/resources/ui/src/styles/app.less b/openmetadata-ui/src/main/resources/ui/src/styles/app.less index 503006ed8b3..ef87700ba8d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/styles/app.less +++ b/openmetadata-ui/src/main/resources/ui/src/styles/app.less @@ -536,6 +536,9 @@ a[href].link-text-grey, padding: 0 4px; margin-left: 2px; font-size: 10px; + background: @primary-1; + border: 1px solid @primary-color; + color: @primary-color; } } diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/Explore.utils.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/ExploreUtils.test.ts similarity index 60% rename from openmetadata-ui/src/main/resources/ui/src/utils/Explore.utils.test.ts rename to openmetadata-ui/src/main/resources/ui/src/utils/ExploreUtils.test.ts index 4a9986135ad..3e84fd34d51 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/Explore.utils.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ExploreUtils.test.ts @@ -11,12 +11,15 @@ * limitations under the License. */ import { ExploreQuickFilterField } from '../components/Explore/ExplorePage.interface'; +import { EntityFields } from '../enums/AdvancedSearch.enum'; import { QueryFieldInterface } from '../pages/ExplorePage/ExplorePage.interface'; import { extractTermKeys, getQuickFilterQuery, getSelectedValuesFromQuickFilter, -} from './Explore.utils'; + getSubLevelHierarchyKey, + updateTreeData, +} from './ExploreUtils'; describe('Explore Utils', () => { it('should return undefined if data is empty', () => { @@ -170,4 +173,100 @@ describe('Explore Utils', () => { selectedFilters ); }); + + describe('getSubLevelHierarchyKey', () => { + it('returns the correct bucket and queryFilter when isDatabaseHierarchy is true', () => { + const result = getSubLevelHierarchyKey( + true, + EntityFields.SERVICE, + 'testValue' + ); + + expect(result).toEqual({ + bucket: EntityFields.DATABASE, + queryFilter: { + query: { + bool: { + must: { + term: { + [EntityFields.SERVICE]: 'testValue', + }, + }, + }, + }, + }, + }); + }); + + it('returns the correct bucket and queryFilter when isDatabaseHierarchy is false', () => { + const result = getSubLevelHierarchyKey( + false, + EntityFields.SERVICE, + 'testValue' + ); + + expect(result).toEqual({ + bucket: EntityFields.ENTITY_TYPE, + queryFilter: { + query: { + bool: { + must: { + term: { + [EntityFields.SERVICE]: 'testValue', + }, + }, + }, + }, + }, + }); + }); + + it('returns the default bucket and an empty queryFilter when key and value are not provided', () => { + const result = getSubLevelHierarchyKey(); + + expect(result).toEqual({ + bucket: EntityFields.SERVICE_TYPE, + queryFilter: { + query: { + bool: {}, + }, + }, + }); + }); + }); + + describe('updateTreeData', () => { + it('updates the correct node in the tree', () => { + const treeData = [ + { title: '1', key: '1', children: [{ title: '1.1', key: '1.1' }] }, + { title: '2', key: '2', children: [{ title: '2.1', key: '2.1' }] }, + ]; + + const newChildren = [{ title: '1.1.1', key: '1.1.1' }]; + + const updatedTreeData = updateTreeData(treeData, '1.1', newChildren); + + expect(updatedTreeData).toEqual([ + { + key: '1', + title: '1', + children: [{ title: '1.1', key: '1.1', children: newChildren }], + }, + { key: '2', title: '2', children: [{ title: '2.1', key: '2.1' }] }, + ]); + }); + + it('does not modify the tree if the key is not found', () => { + const treeData = [ + { title: '1', key: '1', children: [{ title: '1.1', key: '1.1' }] }, + { title: '2', key: '2', children: [{ title: '2.1', key: '2.1' }] }, + ]; + + const newChildren = [{ title: '1.1.1', key: '1.1.1' }]; + + const updatedTreeData = updateTreeData(treeData, '3', newChildren); + + expect(updatedTreeData).toEqual(treeData); + }); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/Explore.utils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/ExploreUtils.ts similarity index 78% rename from openmetadata-ui/src/main/resources/ui/src/utils/Explore.utils.ts rename to openmetadata-ui/src/main/resources/ui/src/utils/ExploreUtils.ts index d087510dc4a..c76fd324947 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/Explore.utils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ExploreUtils.ts @@ -18,10 +18,16 @@ import { ExploreSearchIndex, SearchHitCounts, } from '../components/Explore/ExplorePage.interface'; +import { + DatabaseFields, + ExploreTreeNode, +} from '../components/Explore/ExploreTree/ExploreTree.interface'; import { SearchDropdownOption } from '../components/SearchDropdown/SearchDropdown.interface'; import { NULL_OPTION_KEY } from '../constants/AdvancedSearch.constants'; +import { EntityFields } from '../enums/AdvancedSearch.enum'; import { Aggregations } from '../interface/search.interface'; import { + EsBoolQuery, QueryFieldInterface, QueryFilterInterface, TabsInfoData, @@ -168,3 +174,56 @@ export const extractTermKeys = (objects: QueryFieldInterface[]): string[] => { return termKeys; }; + +export const getSubLevelHierarchyKey = ( + isDatabaseHierarchy = false, + key?: EntityFields, + value?: string +) => { + const queryFilter = { + query: { bool: {} }, + }; + + if (key && value) { + (queryFilter.query.bool as EsBoolQuery).must = { term: { [key]: value } }; + } + + const bucketMapping = isDatabaseHierarchy + ? { + [EntityFields.SERVICE_TYPE]: EntityFields.SERVICE, + [EntityFields.SERVICE]: EntityFields.DATABASE, + [EntityFields.DATABASE]: EntityFields.DATABASE_SCHEMA, + [EntityFields.DATABASE_SCHEMA]: EntityFields.ENTITY_TYPE, + } + : { + [EntityFields.SERVICE_TYPE]: EntityFields.SERVICE, + [EntityFields.SERVICE]: EntityFields.ENTITY_TYPE, + }; + + return { + bucket: bucketMapping[key as DatabaseFields] ?? EntityFields.SERVICE_TYPE, + queryFilter, + }; +}; + +export const updateTreeData = ( + list: ExploreTreeNode[], + key: React.Key, + children: ExploreTreeNode[] +): ExploreTreeNode[] => + list.map((node) => { + if (node.key === key) { + return { + ...node, + children, + }; + } + if (node.children) { + return { + ...node, + children: updateTreeData(node.children, key, children), + }; + } + + return node; + }); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/SearchClassBase.ts b/openmetadata-ui/src/main/resources/ui/src/utils/SearchClassBase.ts index 0c2177353de..54b66056b4e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/SearchClassBase.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/SearchClassBase.ts @@ -12,6 +12,7 @@ */ import { SearchOutlined } from '@ant-design/icons'; import i18next from 'i18next'; +import { ReactComponent as GovernIcon } from '../assets/svg/bank.svg'; import { ReactComponent as ClassificationIcon } from '../assets/svg/classification.svg'; import { ReactComponent as IconDataModel } from '../assets/svg/data-model.svg'; import { ReactComponent as GlossaryIcon } from '../assets/svg/glossary.svg'; @@ -21,18 +22,21 @@ import { ReactComponent as DatabaseIcon } from '../assets/svg/ic-database.svg'; import { ReactComponent as MlModelIcon } from '../assets/svg/ic-ml-model.svg'; import { ReactComponent as PipelineIcon } from '../assets/svg/ic-pipeline.svg'; import { ReactComponent as SchemaIcon } from '../assets/svg/ic-schema.svg'; +import { ReactComponent as SearchIcon } from '../assets/svg/ic-search.svg'; import { ReactComponent as ContainerIcon } from '../assets/svg/ic-storage.svg'; import { ReactComponent as IconStoredProcedure } from '../assets/svg/ic-stored-procedure.svg'; import { ReactComponent as TableIcon } from '../assets/svg/ic-table.svg'; import { ReactComponent as TopicIcon } from '../assets/svg/ic-topic.svg'; import { ReactComponent as IconTable } from '../assets/svg/table-grey.svg'; import { ExploreSearchIndex } from '../components/Explore/ExplorePage.interface'; +import { ExploreTreeNode } from '../components/Explore/ExploreTree/ExploreTree.interface'; import { SourceType } from '../components/SearchedData/SearchedData.interface'; import { COMMON_DROPDOWN_ITEMS, CONTAINER_DROPDOWN_ITEMS, DASHBOARD_DATA_MODEL_TYPE, DASHBOARD_DROPDOWN_ITEMS, + DATA_ASSET_DROPDOWN_ITEMS, DATA_PRODUCT_DROPDOWN_ITEMS, GLOSSARY_DROPDOWN_ITEMS, ML_MODEL_DROPDOWN_ITEMS, @@ -167,6 +171,79 @@ class SearchClassBase { ]; } + public getExploreTree(): ExploreTreeNode[] { + return [ + { + title: i18n.t('label.database-plural'), + key: SearchIndex.DATABASE, + data: { isRoot: true }, + icon: DatabaseIcon, + }, + { + title: i18n.t('label.dashboard-plural'), + key: SearchIndex.DASHBOARD, + data: { isRoot: true }, + icon: DashboardIcon, + }, + { + title: i18n.t('label.pipeline-plural'), + key: SearchIndex.PIPELINE, + data: { isRoot: true }, + icon: PipelineIcon, + }, + { + title: i18n.t('label.topic-plural'), + key: SearchIndex.TOPIC, + data: { isRoot: true }, + icon: TopicIcon, + }, + { + title: i18n.t('label.ml-model-plural'), + key: SearchIndex.MLMODEL, + data: { isRoot: true }, + icon: MlModelIcon, + }, + { + title: i18n.t('label.container-plural'), + key: SearchIndex.CONTAINER, + data: { isRoot: true }, + icon: ContainerIcon, + }, + { + title: i18n.t('label.search-index-plural'), + key: SearchIndex.SEARCH_INDEX, + data: { isRoot: true }, + icon: SearchIcon, + }, + { + title: i18n.t('label.governance'), + key: 'Governance', + data: { isRoot: true }, + icon: GovernIcon, + children: [ + { + title: i18n.t('label.glossary-plural'), + key: '3', + isLeaf: true, + icon: GlossaryIcon, + data: { + entityType: EntityType.GLOSSARY_TERM, + }, + }, + { + title: i18n.t('label.tag-plural'), + key: '4', + isLeaf: true, + icon: ClassificationIcon, + data: { + entityType: EntityType.TAG, + }, + }, + ], + }, + ]; + } + public getTabsInfo(): Record { return { [SearchIndex.TABLE]: { @@ -304,6 +381,8 @@ class SearchClassBase { case SearchIndex.DATABASE: case SearchIndex.DATABASE_SCHEMA: return COMMON_DROPDOWN_ITEMS; + case SearchIndex.DATA_ASSET: + return DATA_ASSET_DROPDOWN_ITEMS; default: return []; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/ServiceUtilClassBase.ts b/openmetadata-ui/src/main/resources/ui/src/utils/ServiceUtilClassBase.ts index 9b2ff9154c2..8852e075801 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/ServiceUtilClassBase.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ServiceUtilClassBase.ts @@ -211,11 +211,8 @@ class ServiceUtilClassBase { }; } - public getServiceTypeLogo( - searchSource: SearchSuggestions[number] | SearchSourceAlias - ) { + public getServiceLogo(type: string) { const serviceTypes = this.getSupportedServiceFromList(); - const type = searchSource?.serviceType ?? ''; switch (toLower(type)) { case this.DatabaseServiceTypeSmallCase.Mysql: return MYSQL; @@ -470,6 +467,13 @@ class ServiceUtilClassBase { } } + public getServiceTypeLogo( + searchSource: SearchSuggestions[number] | SearchSourceAlias + ) { + const type = searchSource?.serviceType ?? ''; + + return this.getServiceLogo(type); + } public getDataAssetsService(serviceType: string): ExplorePageTabs { const database = this.DatabaseServiceTypeSmallCase; const messaging = this.MessagingServiceTypeSmallCase;