From 5b63c36ef103ca61ae33e4721d4a22e3599fd9fb Mon Sep 17 00:00:00 2001 From: sonika-shah <58761340+sonika-shah@users.noreply.github.com> Date: Mon, 27 Jan 2025 15:43:42 +0530 Subject: [PATCH] Fix #19116 : Support for domain hierarchy listing (#19191) * Backend support for domain hierarchy listing * use linkedHashmap to maintain order of results in hierarchy * Revert changes to generated files to match main * show all domains for non admin user * change select domain layout * fix multiple save * fix playwright * cleanup * fix domain tests * fix domain tests * fix icon styling * show icons on navbar domain list * update tests --------- Co-authored-by: karanh37 Co-authored-by: Karan Hotchandani <33024356+karanh37@users.noreply.github.com> --- .../service/jdbi3/DomainRepository.java | 49 ++++ .../resources/domains/DomainResource.java | 30 +++ .../elasticsearch/ElasticSearchClient.java | 168 +++++++++---- .../search/opensearch/OpenSearchClient.java | 165 +++++++++---- .../service/util/EntityHierarchyList.java | 13 + .../resources/domains/DomainResourceTest.java | 215 +++++++++++++++++ .../ui/playwright/e2e/Pages/Domains.spec.ts | 10 +- .../ui/playwright/e2e/Pages/Entity.spec.ts | 2 +- .../support/entity/DatabaseClass.ts | 2 +- .../playwright/support/entity/EntityClass.ts | 2 +- .../resources/ui/playwright/utils/common.ts | 33 ++- .../ui/src/assets/svg/ic-subdomain.svg | 13 + .../DomainDetailsPage.component.tsx | 57 +++-- .../GlossaryTermTab.component.tsx | 7 +- .../AsyncSelectList/TreeAsyncSelectList.tsx | 4 +- .../DomainSelectableList.component.tsx | 101 +------- .../domain-select-dropdown.less | 6 +- .../DomainSelectableTree.interface.ts | 24 ++ .../DomainSelectableTree.tsx | 224 ++++++++++++++++++ .../domain-selectable.less | 26 ++ .../resources/ui/src/hooks/useDomainStore.ts | 14 +- .../main/resources/ui/src/rest/domainAPI.ts | 30 +++ .../resources/ui/src/utils/DomainUtils.tsx | 93 ++++++-- .../resources/ui/src/utils/GlossaryUtils.tsx | 18 +- 24 files changed, 1041 insertions(+), 265 deletions(-) create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/util/EntityHierarchyList.java create mode 100644 openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-subdomain.svg create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableTree/DomainSelectableTree.interface.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableTree/DomainSelectableTree.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableTree/domain-selectable.less diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DomainRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DomainRepository.java index 40943f8bc18..5a6f6dcca48 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DomainRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DomainRepository.java @@ -18,10 +18,15 @@ import static org.openmetadata.schema.type.Include.ALL; import static org.openmetadata.service.Entity.DOMAIN; import static org.openmetadata.service.Entity.FIELD_ASSETS; +import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.jdbi.v3.sqlobject.transaction.Transaction; import org.openmetadata.schema.EntityInterface; +import org.openmetadata.schema.entity.data.EntityHierarchy; import org.openmetadata.schema.entity.domains.Domain; import org.openmetadata.schema.type.EntityReference; import org.openmetadata.schema.type.Include; @@ -30,8 +35,11 @@ import org.openmetadata.schema.type.api.BulkAssets; import org.openmetadata.schema.type.api.BulkOperationResult; import org.openmetadata.service.Entity; import org.openmetadata.service.resources.domains.DomainResource; +import org.openmetadata.service.util.EntityUtil; import org.openmetadata.service.util.EntityUtil.Fields; import org.openmetadata.service.util.FullyQualifiedName; +import org.openmetadata.service.util.JsonUtils; +import org.openmetadata.service.util.ResultList; @Slf4j public class DomainRepository extends EntityRepository { @@ -140,6 +148,47 @@ public class DomainRepository extends EntityRepository { : null; } + public List buildHierarchy(String fieldsParam, int limit) { + fieldsParam = EntityUtil.addField(fieldsParam, Entity.FIELD_PARENT); + Fields fields = getFields(fieldsParam); + ResultList resultList = listAfter(null, fields, new ListFilter(null), limit, null); + List domains = resultList.getData(); + + /* + Maintaining hierarchy in terms of EntityHierarchy to get all other fields of Domain like style, + which would have been restricted if built using hierarchy of Domain, as Domain.getChildren() returns List + and EntityReference does not support additional properties + */ + List rootDomains = new ArrayList<>(); + + Map entityHierarchyMap = + domains.stream() + .collect( + Collectors.toMap( + Domain::getId, + domain -> { + EntityHierarchy entityHierarchy = + JsonUtils.readValue(JsonUtils.pojoToJson(domain), EntityHierarchy.class); + entityHierarchy.setChildren(new ArrayList<>()); + return entityHierarchy; + })); + + for (Domain domain : domains) { + EntityHierarchy entityHierarchy = entityHierarchyMap.get(domain.getId()); + + if (domain.getParent() != null) { + EntityHierarchy parentHierarchy = entityHierarchyMap.get(domain.getParent().getId()); + if (parentHierarchy != null) { + parentHierarchy.getChildren().add(entityHierarchy); + } + } else { + rootDomains.add(entityHierarchy); + } + } + + return rootDomains; + } + public class DomainUpdater extends EntityUpdater { public DomainUpdater(Domain original, Domain updated, Operation operation) { super(original, updated, operation); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/domains/DomainResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/domains/DomainResource.java index f3b446ce4c9..c335f6833de 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/domains/DomainResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/domains/DomainResource.java @@ -45,6 +45,7 @@ import javax.ws.rs.core.SecurityContext; import javax.ws.rs.core.UriInfo; import lombok.extern.slf4j.Slf4j; import org.openmetadata.schema.api.domains.CreateDomain; +import org.openmetadata.schema.entity.data.EntityHierarchy; import org.openmetadata.schema.entity.domains.Domain; import org.openmetadata.schema.type.ChangeEvent; import org.openmetadata.schema.type.EntityHistory; @@ -57,6 +58,7 @@ import org.openmetadata.service.limits.Limits; import org.openmetadata.service.resources.Collection; import org.openmetadata.service.resources.EntityResource; import org.openmetadata.service.security.Authorizer; +import org.openmetadata.service.util.EntityHierarchyList; import org.openmetadata.service.util.ResultList; @Slf4j @@ -433,4 +435,32 @@ public class DomainResource extends EntityResource { String name) { return deleteByName(uriInfo, securityContext, name, true, true); } + + @GET + @Path("/hierarchy") + @Operation( + operationId = "listDomainsHierarchy", + summary = "List domains in hierarchical order", + description = "Get a list of Domains in hierarchical order.", + responses = { + @ApiResponse( + responseCode = "200", + description = "List of Domains in hierarchical order", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = EntityHierarchyList.class))) + }) + public ResultList listHierarchy( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter( + description = "Fields requested in the returned resource", + schema = @Schema(type = "string", example = FIELDS)) + @QueryParam("fields") + String fieldsParam, + @DefaultValue("10") @Min(0) @Max(1000000) @QueryParam("limit") int limitParam) { + + return new EntityHierarchyList(repository.buildHierarchy(fieldsParam, limitParam)); + } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java index 82fa864163a..cf9e7557d80 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java @@ -129,6 +129,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; import java.util.stream.Stream; import javax.json.JsonObject; import javax.net.ssl.SSLContext; @@ -470,50 +471,10 @@ public class ElasticSearchClient implements SearchClient { .getIndexMapping(GLOSSARY_TERM) .getIndexName(clusterAlias))) { searchSourceBuilder.query(QueryBuilders.boolQuery().must(searchSourceBuilder.query())); - - if (request.isGetHierarchy()) { - QueryBuilder baseQuery = - QueryBuilders.boolQuery() - .should(searchSourceBuilder.query()) - .should(QueryBuilders.matchPhraseQuery("fullyQualifiedName", request.getQuery())) - .should(QueryBuilders.matchPhraseQuery("name", request.getQuery())) - .should(QueryBuilders.matchPhraseQuery("displayName", request.getQuery())) - .should( - QueryBuilders.matchPhraseQuery( - "glossary.fullyQualifiedName", request.getQuery())) - .should(QueryBuilders.matchPhraseQuery("glossary.displayName", request.getQuery())) - .must(QueryBuilders.matchQuery("status", "Approved")) - .minimumShouldMatch(1); - searchSourceBuilder.query(baseQuery); - - SearchResponse searchResponse = - client.search( - new es.org.elasticsearch.action.search.SearchRequest(request.getIndex()) - .source(searchSourceBuilder), - RequestOptions.DEFAULT); - - // Extract parent terms from aggregation - BoolQueryBuilder parentTermQueryBuilder = QueryBuilders.boolQuery(); - Terms parentTerms = searchResponse.getAggregations().get("fqnParts_agg"); - - // Build es query to get parent terms for the user input query , to build correct hierarchy - if (!parentTerms.getBuckets().isEmpty() && !request.getQuery().equals("*")) { - parentTerms.getBuckets().stream() - .map(Terms.Bucket::getKeyAsString) - .forEach( - parentTerm -> - parentTermQueryBuilder.should( - QueryBuilders.matchQuery("fullyQualifiedName", parentTerm))); - - searchSourceBuilder.query( - parentTermQueryBuilder - .minimumShouldMatch(1) - .must(QueryBuilders.matchQuery("status", "Approved"))); - } - searchSourceBuilder.sort(SortBuilders.fieldSort("fullyQualifiedName").order(SortOrder.ASC)); - } } + buildHierarchyQuery(request, searchSourceBuilder, client); + /* for performance reasons ElasticSearch doesn't provide accurate hits if we enable trackTotalHits parameter it will try to match every result, count and return hits however in most cases for search results an approximate value is good enough. @@ -581,15 +542,89 @@ public class ElasticSearchClient implements SearchClient { return getResponse(NOT_FOUND, "Document not found."); } + private void buildHierarchyQuery( + SearchRequest request, SearchSourceBuilder searchSourceBuilder, RestHighLevelClient client) + throws IOException { + + if (!request.isGetHierarchy()) { + return; + } + + String indexName = request.getIndex(); + String glossaryTermIndex = + Entity.getSearchRepository().getIndexMapping(GLOSSARY_TERM).getIndexName(clusterAlias); + String domainIndex = + Entity.getSearchRepository().getIndexMapping(DOMAIN).getIndexName(clusterAlias); + + BoolQueryBuilder baseQuery = + QueryBuilders.boolQuery() + .should(searchSourceBuilder.query()) + .should(QueryBuilders.matchPhraseQuery("fullyQualifiedName", request.getQuery())) + .should(QueryBuilders.matchPhraseQuery("name", request.getQuery())) + .should(QueryBuilders.matchPhraseQuery("displayName", request.getQuery())); + + if (indexName.equalsIgnoreCase(glossaryTermIndex)) { + baseQuery + .should(QueryBuilders.matchPhraseQuery("glossary.fullyQualifiedName", request.getQuery())) + .should(QueryBuilders.matchPhraseQuery("glossary.displayName", request.getQuery())) + .must(QueryBuilders.matchQuery("status", "Approved")); + } else if (indexName.equalsIgnoreCase(domainIndex)) { + baseQuery + .should(QueryBuilders.matchPhraseQuery("parent.fullyQualifiedName", request.getQuery())) + .should(QueryBuilders.matchPhraseQuery("parent.displayName", request.getQuery())); + } + + baseQuery.minimumShouldMatch(1); + searchSourceBuilder.query(baseQuery); + + SearchResponse searchResponse = + client.search( + new es.org.elasticsearch.action.search.SearchRequest(request.getIndex()) + .source(searchSourceBuilder), + RequestOptions.DEFAULT); + + Terms parentTerms = searchResponse.getAggregations().get("fqnParts_agg"); + + // Build es query to get parent terms for the user input query , to build correct hierarchy + // In case of default search , no need to get parent terms they are already present in the + // response + if (parentTerms != null + && !parentTerms.getBuckets().isEmpty() + && !request.getQuery().equals("*")) { + BoolQueryBuilder parentTermQueryBuilder = QueryBuilders.boolQuery(); + + parentTerms.getBuckets().stream() + .map(Terms.Bucket::getKeyAsString) + .forEach( + parentTerm -> + parentTermQueryBuilder.should( + QueryBuilders.matchQuery("fullyQualifiedName", parentTerm))); + if (indexName.equalsIgnoreCase(glossaryTermIndex)) { + parentTermQueryBuilder + .minimumShouldMatch(1) + .must(QueryBuilders.matchQuery("status", "Approved")); + } else { + parentTermQueryBuilder.minimumShouldMatch(1); + } + searchSourceBuilder.query(parentTermQueryBuilder); + } + + searchSourceBuilder.sort(SortBuilders.fieldSort("fullyQualifiedName").order(SortOrder.ASC)); + } + public List buildSearchHierarchy(SearchRequest request, SearchResponse searchResponse) { List response = new ArrayList<>(); - if (request - .getIndex() - .equalsIgnoreCase( - Entity.getSearchRepository() - .getIndexMapping(GLOSSARY_TERM) - .getIndexName(clusterAlias))) { + + String indexName = request.getIndex(); + String glossaryTermIndex = + Entity.getSearchRepository().getIndexMapping(GLOSSARY_TERM).getIndexName(clusterAlias); + String domainIndex = + Entity.getSearchRepository().getIndexMapping(DOMAIN).getIndexName(clusterAlias); + + if (indexName.equalsIgnoreCase(glossaryTermIndex)) { response = buildGlossaryTermSearchHierarchy(searchResponse); + } else if (indexName.equalsIgnoreCase(domainIndex)) { + response = buildDomainSearchHierarchy(searchResponse); } return response; } @@ -645,6 +680,38 @@ public class ElasticSearchClient implements SearchClient { return new ArrayList<>(rootTerms.values()); } + public List buildDomainSearchHierarchy(SearchResponse searchResponse) { + Map entityHierarchyMap = + Arrays.stream(searchResponse.getHits().getHits()) + .map(hit -> JsonUtils.readValue(hit.getSourceAsString(), EntityHierarchy.class)) + .collect( + Collectors.toMap( + EntityHierarchy::getFullyQualifiedName, + entity -> { + entity.setChildren(new ArrayList<>()); + return entity; + }, + (existing, replacement) -> existing, + LinkedHashMap::new)); + + List rootDomains = new ArrayList<>(); + + entityHierarchyMap + .values() + .forEach( + entity -> { + String parentFqn = getParentFQN(entity.getFullyQualifiedName()); + EntityHierarchy parentEntity = entityHierarchyMap.get(parentFqn); + if (parentEntity != null) { + parentEntity.getChildren().add(entity); + } else { + rootDomains.add(entity); + } + }); + + return rootDomains; + } + @Override public SearchResultListMapper listWithOffset( String filter, @@ -1819,7 +1886,10 @@ public class ElasticSearchClient implements SearchClient { buildSearchQueryBuilder(query, DomainIndex.getFields()); FunctionScoreQueryBuilder queryBuilder = boostScore(queryStringBuilder); HighlightBuilder hb = buildHighlights(new ArrayList<>()); - return searchBuilder(queryBuilder, hb, from, size); + SearchSourceBuilder searchSourceBuilder = searchBuilder(queryBuilder, hb, from, size); + searchSourceBuilder.aggregation( + AggregationBuilders.terms("fqnParts_agg").field("fqnParts").size(1000)); + return addAggregation(searchSourceBuilder); } private static SearchSourceBuilder buildCostAnalysisReportDataSearch( diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java index 994f82260e1..64308639f05 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java @@ -47,6 +47,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; import java.util.stream.Stream; import javax.json.JsonObject; import javax.net.ssl.SSLContext; @@ -463,53 +464,10 @@ public class OpenSearchClient implements SearchClient { .getIndexMapping(GLOSSARY_TERM) .getIndexName(clusterAlias))) { searchSourceBuilder.query(QueryBuilders.boolQuery().must(searchSourceBuilder.query())); - - if (request.isGetHierarchy()) { - /* - Search for user input terms in name, fullyQualifiedName, displayName and glossary.fullyQualifiedName, glossary.displayName - */ - QueryBuilder baseQuery = - QueryBuilders.boolQuery() - .should(searchSourceBuilder.query()) - .should(QueryBuilders.matchPhraseQuery("fullyQualifiedName", request.getQuery())) - .should(QueryBuilders.matchPhraseQuery("name", request.getQuery())) - .should(QueryBuilders.matchPhraseQuery("displayName", request.getQuery())) - .should( - QueryBuilders.matchPhraseQuery( - "glossary.fullyQualifiedName", request.getQuery())) - .should(QueryBuilders.matchPhraseQuery("glossary.displayName", request.getQuery())) - .must(QueryBuilders.matchQuery("status", "Approved")) - .minimumShouldMatch(1); - searchSourceBuilder.query(baseQuery); - - SearchResponse searchResponse = - client.search( - new os.org.opensearch.action.search.SearchRequest(request.getIndex()) - .source(searchSourceBuilder), - RequestOptions.DEFAULT); - - // Extract parent terms from aggregation - BoolQueryBuilder parentTermQueryBuilder = QueryBuilders.boolQuery(); - Terms parentTerms = searchResponse.getAggregations().get("fqnParts_agg"); - - // Build es query to get parent terms for the user input query , to build correct hierarchy - if (!parentTerms.getBuckets().isEmpty() && !request.getQuery().equals("*")) { - parentTerms.getBuckets().stream() - .map(Terms.Bucket::getKeyAsString) - .forEach( - parentTerm -> - parentTermQueryBuilder.should( - QueryBuilders.matchQuery("fullyQualifiedName", parentTerm))); - - searchSourceBuilder.query( - parentTermQueryBuilder - .minimumShouldMatch(1) - .must(QueryBuilders.matchQuery("status", "Approved"))); - } - searchSourceBuilder.sort(SortBuilders.fieldSort("fullyQualifiedName").order(SortOrder.ASC)); - } } + buildHierarchyQuery(request, searchSourceBuilder, client); + /* for performance reasons OpenSearch doesn't provide accurate hits if we enable trackTotalHits parameter it will try to match every result, count and return hits however in most cases for search results an approximate value is good enough. @@ -572,15 +530,88 @@ public class OpenSearchClient implements SearchClient { return getResponse(NOT_FOUND, "Document not found."); } + private void buildHierarchyQuery( + SearchRequest request, SearchSourceBuilder searchSourceBuilder, RestHighLevelClient client) + throws IOException { + + if (!request.isGetHierarchy()) { + return; + } + + String indexName = request.getIndex(); + String glossaryTermIndex = + Entity.getSearchRepository().getIndexMapping(GLOSSARY_TERM).getIndexName(clusterAlias); + String domainIndex = + Entity.getSearchRepository().getIndexMapping(DOMAIN).getIndexName(clusterAlias); + + BoolQueryBuilder baseQuery = + QueryBuilders.boolQuery() + .should(searchSourceBuilder.query()) + .should(QueryBuilders.matchPhraseQuery("fullyQualifiedName", request.getQuery())) + .should(QueryBuilders.matchPhraseQuery("name", request.getQuery())) + .should(QueryBuilders.matchPhraseQuery("displayName", request.getQuery())); + + if (indexName.equalsIgnoreCase(glossaryTermIndex)) { + baseQuery + .should(QueryBuilders.matchPhraseQuery("glossary.fullyQualifiedName", request.getQuery())) + .should(QueryBuilders.matchPhraseQuery("glossary.displayName", request.getQuery())) + .must(QueryBuilders.matchQuery("status", "Approved")); + } else if (indexName.equalsIgnoreCase(domainIndex)) { + baseQuery + .should(QueryBuilders.matchPhraseQuery("parent.fullyQualifiedName", request.getQuery())) + .should(QueryBuilders.matchPhraseQuery("parent.displayName", request.getQuery())); + } + + baseQuery.minimumShouldMatch(1); + searchSourceBuilder.query(baseQuery); + + SearchResponse searchResponse = + client.search( + new os.org.opensearch.action.search.SearchRequest(request.getIndex()) + .source(searchSourceBuilder), + RequestOptions.DEFAULT); + + Terms parentTerms = searchResponse.getAggregations().get("fqnParts_agg"); + + // Build es query to get parent terms for the user input query , to build correct hierarchy + // In case of default search , no need to get parent terms they are already present in the + // response + if (parentTerms != null + && !parentTerms.getBuckets().isEmpty() + && !request.getQuery().equals("*")) { + BoolQueryBuilder parentTermQueryBuilder = QueryBuilders.boolQuery(); + + parentTerms.getBuckets().stream() + .map(Terms.Bucket::getKeyAsString) + .forEach( + parentTerm -> + parentTermQueryBuilder.should( + QueryBuilders.matchQuery("fullyQualifiedName", parentTerm))); + if (indexName.equalsIgnoreCase(glossaryTermIndex)) { + parentTermQueryBuilder + .minimumShouldMatch(1) + .must(QueryBuilders.matchQuery("status", "Approved")); + } else { + parentTermQueryBuilder.minimumShouldMatch(1); + } + searchSourceBuilder.query(parentTermQueryBuilder); + } + + searchSourceBuilder.sort(SortBuilders.fieldSort("fullyQualifiedName").order(SortOrder.ASC)); + } + public List buildSearchHierarchy(SearchRequest request, SearchResponse searchResponse) { List response = new ArrayList<>(); - if (request - .getIndex() - .equalsIgnoreCase( - Entity.getSearchRepository() - .getIndexMapping(GLOSSARY_TERM) - .getIndexName(clusterAlias))) { + String indexName = request.getIndex(); + String glossaryTermIndex = + Entity.getSearchRepository().getIndexMapping(GLOSSARY_TERM).getIndexName(clusterAlias); + String domainIndex = + Entity.getSearchRepository().getIndexMapping(DOMAIN).getIndexName(clusterAlias); + + if (indexName.equalsIgnoreCase(glossaryTermIndex)) { response = buildGlossaryTermSearchHierarchy(searchResponse); + } else if (indexName.equalsIgnoreCase(domainIndex)) { + response = buildDomainSearchHierarchy(searchResponse); } return response; } @@ -636,6 +667,38 @@ public class OpenSearchClient implements SearchClient { return new ArrayList<>(rootTerms.values()); } + public List buildDomainSearchHierarchy(SearchResponse searchResponse) { + Map entityHierarchyMap = + Arrays.stream(searchResponse.getHits().getHits()) + .map(hit -> JsonUtils.readValue(hit.getSourceAsString(), EntityHierarchy.class)) + .collect( + Collectors.toMap( + EntityHierarchy::getFullyQualifiedName, + entity -> { + entity.setChildren(new ArrayList<>()); + return entity; + }, + (existing, replacement) -> existing, + LinkedHashMap::new)); + + List rootDomains = new ArrayList<>(); + + entityHierarchyMap + .values() + .forEach( + entity -> { + String parentFqn = getParentFQN(entity.getFullyQualifiedName()); + EntityHierarchy parentEntity = entityHierarchyMap.get(parentFqn); + if (parentEntity != null) { + parentEntity.getChildren().add(entity); + } else { + rootDomains.add(entity); + } + }); + + return rootDomains; + } + @Override public SearchResultListMapper listWithOffset( String filter, diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/util/EntityHierarchyList.java b/openmetadata-service/src/main/java/org/openmetadata/service/util/EntityHierarchyList.java new file mode 100644 index 00000000000..fe58dc390de --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/util/EntityHierarchyList.java @@ -0,0 +1,13 @@ +package org.openmetadata.service.util; + +import java.util.List; +import org.openmetadata.schema.entity.data.EntityHierarchy; + +public class EntityHierarchyList extends ResultList { + @SuppressWarnings("unused") + public EntityHierarchyList() {} + + public EntityHierarchyList(List data) { + super(data, null, null, data.size()); + } +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/domains/DomainResourceTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/domains/DomainResourceTest.java index f2d1e854030..178ca066eb9 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/resources/domains/DomainResourceTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/domains/DomainResourceTest.java @@ -3,6 +3,8 @@ package org.openmetadata.service.resources.domains; import static javax.ws.rs.core.Response.Status.NOT_FOUND; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.openmetadata.common.utils.CommonUtil.listOf; import static org.openmetadata.service.Entity.TABLE; import static org.openmetadata.service.security.SecurityUtil.authHeaders; @@ -17,15 +19,18 @@ import static org.openmetadata.service.util.TestUtils.assertListNull; import static org.openmetadata.service.util.TestUtils.assertResponse; import java.io.IOException; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.UUID; +import javax.ws.rs.client.WebTarget; import javax.ws.rs.core.Response.Status; import org.apache.http.client.HttpResponseException; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInfo; import org.openmetadata.schema.api.domains.CreateDomain; import org.openmetadata.schema.api.domains.CreateDomain.DomainType; +import org.openmetadata.schema.entity.data.EntityHierarchy; import org.openmetadata.schema.entity.domains.Domain; import org.openmetadata.schema.entity.type.Style; import org.openmetadata.schema.type.ChangeDescription; @@ -35,7 +40,9 @@ import org.openmetadata.service.exception.EntityNotFoundException; import org.openmetadata.service.jdbi3.TableRepository; import org.openmetadata.service.resources.EntityResourceTest; import org.openmetadata.service.resources.domains.DomainResource.DomainList; +import org.openmetadata.service.util.EntityHierarchyList; import org.openmetadata.service.util.JsonUtils; +import org.openmetadata.service.util.TestUtils; public class DomainResourceTest extends EntityResourceTest { public DomainResourceTest() { @@ -143,6 +150,214 @@ public class DomainResourceTest extends EntityResourceTest String.format("user instance for %s not found", expertReference.getId())); } + @Test + void test_buildDomainNestedHierarchyFromSearch() throws HttpResponseException { + CreateDomain createRootDomain = + createRequest("rootDomain") + .withDisplayName("Global Headquarters") + .withDescription("Root Domain") + .withDomainType(DomainType.AGGREGATE) + .withStyle(null) + .withExperts(null); + Domain rootDomain = createEntity(createRootDomain, ADMIN_AUTH_HEADERS); + + CreateDomain createSecondLevelDomain = + createRequest("secondLevelDomain") + .withDisplayName("Operations Hub") + .withDescription("Second Level Domain") + .withDomainType(DomainType.AGGREGATE) + .withStyle(null) + .withExperts(null) + .withParent(rootDomain.getFullyQualifiedName()); + Domain secondLevelDomain = createEntity(createSecondLevelDomain, ADMIN_AUTH_HEADERS); + + CreateDomain createThirdLevelDomain = + createRequest("thirdLevelDomain") + .withDisplayName("Innovation Center") + .withDescription("Third Level Domain") + .withDomainType(DomainType.AGGREGATE) + .withStyle(null) + .withExperts(null) + .withParent(secondLevelDomain.getFullyQualifiedName()); + Domain thirdLevelDomain = createEntity(createThirdLevelDomain, ADMIN_AUTH_HEADERS); + + // Search for the displayName of third-level child domain and verify the hierarchy + String response = getResponseFormSearchWithHierarchy("domain_search_index", "*innovation*"); + List domains = JsonUtils.readObjects(response, EntityHierarchy.class); + + boolean isChild = + domains.stream() + .filter(domain -> "rootDomain".equals(domain.getName())) + .findFirst() + .map( + root -> + root.getChildren().stream() + .filter(domain -> "secondLevelDomain".equals(domain.getName())) + .flatMap(secondLevel -> secondLevel.getChildren().stream()) + .anyMatch(thirdLevel -> "thirdLevelDomain".equals(thirdLevel.getName()))) + .orElse(false); + + assertTrue( + isChild, + "thirdLevelDomain should be a child of secondLevelDomain, which should be a child of rootDomain"); + + // Search for the fqn of third-level child domain and verify the hierarchy + response = getResponseFormSearchWithHierarchy("domain_search_index", "*third*"); + domains = JsonUtils.readObjects(response, EntityHierarchy.class); + + isChild = + domains.stream() + .filter(domain -> "rootDomain".equals(domain.getName())) + .findFirst() + .map( + root -> + root.getChildren().stream() + .filter(domain -> "secondLevelDomain".equals(domain.getName())) + .flatMap(secondLevel -> secondLevel.getChildren().stream()) + .anyMatch(thirdLevel -> "thirdLevelDomain".equals(thirdLevel.getName()))) + .orElse(false); + + assertTrue( + isChild, + "thirdLevelDomain should be a child of secondLevelDomain, which should be a child of rootDomain"); + } + + @Test + void get_hierarchicalListOfDomain(TestInfo test) throws HttpResponseException { + Domain rootDomain = createEntity(createRequest("A_ROOT_DOMAIN"), ADMIN_AUTH_HEADERS); + Domain subDomain1 = + createEntity( + createRequest("A_subDomain1").withParent(rootDomain.getFullyQualifiedName()), + ADMIN_AUTH_HEADERS); + Domain subDomain2 = + createEntity( + createRequest("A_subDomain2").withParent(rootDomain.getFullyQualifiedName()), + ADMIN_AUTH_HEADERS); + Domain subDomain3 = + createEntity( + createRequest("A_subDomain3").withParent(rootDomain.getFullyQualifiedName()), + ADMIN_AUTH_HEADERS); + + // Ensure parent has all the newly created children + rootDomain = getEntity(rootDomain.getId(), "children,parent", ADMIN_AUTH_HEADERS); + assertEntityReferences( + new ArrayList<>( + List.of( + subDomain1.getEntityReference(), + subDomain2.getEntityReference(), + subDomain3.getEntityReference())), + rootDomain.getChildren()); + + Domain subSubDomain1 = + createEntity( + createRequest("A_subSubDomain11").withParent(subDomain1.getFullyQualifiedName()), + ADMIN_AUTH_HEADERS); + Domain subSubDomain2 = + createEntity( + createRequest("A_subSubDomain12").withParent(subDomain1.getFullyQualifiedName()), + ADMIN_AUTH_HEADERS); + Domain subSubDomain3 = + createEntity( + createRequest("A_subSubDomain13").withParent(subDomain1.getFullyQualifiedName()), + ADMIN_AUTH_HEADERS); + + // Ensure parent has all the newly created children + subDomain1 = getEntity(subDomain1.getId(), "children,parent", ADMIN_AUTH_HEADERS); + assertEntityReferences( + new ArrayList<>( + List.of( + subSubDomain1.getEntityReference(), + subSubDomain2.getEntityReference(), + subSubDomain3.getEntityReference())), + subDomain1.getChildren()); + assertParent(subSubDomain1, subDomain1.getEntityReference()); + assertParent(subSubDomain2, subDomain1.getEntityReference()); + assertParent(subSubDomain3, subDomain1.getEntityReference()); + + // Create another root domain without hierarchy + Domain secondRootDomain = createEntity(createRequest("B_ROOT_DOMAIN"), ADMIN_AUTH_HEADERS); + + List hierarchyList = getDomainsHierarchy(ADMIN_AUTH_HEADERS).getData(); + + UUID rootDomainId = rootDomain.getId(); + UUID subDomain1Id = subDomain1.getId(); + UUID subDomain2Id = subDomain2.getId(); + UUID subDomain3Id = subDomain3.getId(); + UUID subSubDomain1Id = subSubDomain1.getId(); + UUID subSubDomain2Id = subSubDomain2.getId(); + UUID subSubDomain3Id = subSubDomain3.getId(); + UUID secondRootDomainId = secondRootDomain.getId(); + + EntityHierarchy rootHierarchy = + hierarchyList.stream().filter(h -> h.getId().equals(rootDomainId)).findAny().orElse(null); + assertNotNull(rootHierarchy); + assertEquals(3, rootHierarchy.getChildren().size()); + + List rootChildren = rootHierarchy.getChildren(); + assertEquals(3, rootChildren.size()); + assertTrue(rootChildren.stream().anyMatch(h -> h.getId().equals(subDomain1Id))); + assertTrue(rootChildren.stream().anyMatch(h -> h.getId().equals(subDomain2Id))); + assertTrue(rootChildren.stream().anyMatch(h -> h.getId().equals(subDomain3Id))); + + EntityHierarchy subDomain1Hierarchy = + rootChildren.stream().filter(h -> h.getId().equals(subDomain1Id)).findAny().orElse(null); + assertNotNull(subDomain1Hierarchy); + assertEquals(3, subDomain1Hierarchy.getChildren().size()); + + List subDomain1Children = subDomain1Hierarchy.getChildren(); + assertTrue(subDomain1Children.stream().anyMatch(h -> h.getId().equals(subSubDomain1Id))); + assertTrue(subDomain1Children.stream().anyMatch(h -> h.getId().equals(subSubDomain2Id))); + assertTrue(subDomain1Children.stream().anyMatch(h -> h.getId().equals(subSubDomain3Id))); + + EntityHierarchy subSubDomain1Hierarchy = + subDomain1Children.stream() + .filter(h -> h.getId().equals(subSubDomain1Id)) + .findAny() + .orElse(null); + assertNotNull(subSubDomain1Hierarchy); + assertEquals(0, subSubDomain1Hierarchy.getChildren().size()); + + EntityHierarchy subSubDomain2Hierarchy = + subDomain1Children.stream() + .filter(h -> h.getId().equals(subSubDomain2Id)) + .findAny() + .orElse(null); + assertNotNull(subSubDomain2Hierarchy); + assertEquals(0, subSubDomain2Hierarchy.getChildren().size()); + + EntityHierarchy subSubDomain3Hierarchy = + subDomain1Children.stream() + .filter(h -> h.getId().equals(subSubDomain3Id)) + .findAny() + .orElse(null); + assertNotNull(subSubDomain3Hierarchy); + assertEquals(0, subSubDomain3Hierarchy.getChildren().size()); + + // Verify the new root domain without hierarchy + EntityHierarchy secondRootDomainHierarchy = + hierarchyList.stream() + .filter(h -> h.getId().equals(secondRootDomainId)) + .findAny() + .orElse(null); + assertNotNull(secondRootDomainHierarchy); + assertEquals(0, secondRootDomainHierarchy.getChildren().size()); + } + + private void assertParent(Domain domain, EntityReference expectedParent) + throws HttpResponseException { + assertEquals(expectedParent, domain.getParent()); + // Ensure the parent has the given domain as a child + Domain parent = getEntity(expectedParent.getId(), "children", ADMIN_AUTH_HEADERS); + assertEntityReferencesContain(parent.getChildren(), domain.getEntityReference()); + } + + private EntityHierarchyList getDomainsHierarchy(Map authHeaders) + throws HttpResponseException { + WebTarget target = getResource("domains/hierarchy"); + target = target.queryParam("limit", 25); + return TestUtils.get(target, EntityHierarchyList.class, authHeaders); + } + @Override public CreateDomain createRequest(String name) { return new CreateDomain() diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Domains.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Domains.spec.ts index e2f13affea7..6d644b2f5ab 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Domains.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Domains.spec.ts @@ -141,9 +141,13 @@ test.describe('Domains', () => { const dataProduct1 = new DataProduct(domain); const dataProduct2 = new DataProduct(domain); await domain.create(apiContext); - await sidebarClick(page, SidebarItem.DOMAIN); await page.reload(); - await addAssetsToDomain(page, domain, assets); + + await test.step('Add assets to domain', async () => { + await redirectToHomePage(page); + await sidebarClick(page, SidebarItem.DOMAIN); + await addAssetsToDomain(page, domain, assets); + }); await test.step('Create DataProducts', async () => { await selectDomain(page, domain.data); @@ -582,7 +586,7 @@ test.describe('Domains Rbac', () => { const urlParams = new URLSearchParams(queryString); const qParam = urlParams.get('q'); - expect(qParam).toContain(`domain.fullyQualifiedName:`); + expect(qParam).toEqual(''); }); for (const asset of domainAssset2) { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Entity.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Entity.spec.ts index 5e5294e3d32..01d983cf9ae 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Entity.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Entity.spec.ts @@ -115,7 +115,7 @@ entities.forEach((EntityClass) => { }, false ); - await removeDomain(page); + await removeDomain(page, EntityDataClass.domain1.responseData); } }); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DatabaseClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DatabaseClass.ts index df1bdfc0cbd..d800d4fe3da 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DatabaseClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DatabaseClass.ts @@ -348,6 +348,6 @@ export class DatabaseClass extends EntityClass { await assignDomain(page, domain1); await this.verifyDomainPropagation(page, domain1); await updateDomain(page, domain2); - await removeDomain(page); + await removeDomain(page, domain2); } } diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/EntityClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/EntityClass.ts index d3b91e20cf7..e79eb7edf66 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/EntityClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/EntityClass.ts @@ -124,7 +124,7 @@ export class EntityClass { ) { await assignDomain(page, domain1); await updateDomain(page, domain2); - await removeDomain(page); + await removeDomain(page, domain2); } async owner( diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts index bff8a10b179..01a7a17f01c 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts @@ -152,7 +152,7 @@ export const visitOwnProfilePage = async (page: Page) => { export const assignDomain = async ( page: Page, - domain: { name: string; displayName: string } + domain: { name: string; displayName: string; fullyQualifiedName?: string } ) => { await page.getByTestId('add-domain').click(); await page.waitForSelector('[data-testid="loader"]', { state: 'detached' }); @@ -160,11 +160,13 @@ export const assignDomain = async ( `/api/v1/search/query?q=*${encodeURIComponent(domain.name)}*` ); await page - .getByTestId('selectable-list') + .getByTestId('domain-selectable-tree') .getByTestId('searchbar') .fill(domain.name); await searchDomain; - await page.getByRole('listitem', { name: domain.displayName }).click(); + + await page.getByTestId(`tag-${domain.fullyQualifiedName}`).click(); + await page.getByTestId('saveAssociatedTag').click(); await expect(page.getByTestId('domain-link')).toContainText( domain.displayName @@ -173,33 +175,42 @@ export const assignDomain = async ( export const updateDomain = async ( page: Page, - domain: { name: string; displayName: string } + domain: { name: string; displayName: string; fullyQualifiedName?: string } ) => { await page.getByTestId('add-domain').click(); await page.waitForSelector('[data-testid="loader"]', { state: 'detached' }); - await page.getByTestId('selectable-list').getByTestId('searchbar').clear(); + + await page + .getByTestId('domain-selectable-tree') + .getByTestId('searchbar') + .clear(); + const searchDomain = page.waitForResponse( `/api/v1/search/query?q=*${encodeURIComponent(domain.name)}*` ); await page - .getByTestId('selectable-list') + .getByTestId('domain-selectable-tree') .getByTestId('searchbar') .fill(domain.name); await searchDomain; - await page.getByRole('listitem', { name: domain.displayName }).click(); + + await page.getByTestId(`tag-${domain.fullyQualifiedName}`).click(); + await page.getByTestId('saveAssociatedTag').click(); await expect(page.getByTestId('domain-link')).toContainText( domain.displayName ); }; -export const removeDomain = async (page: Page) => { +export const removeDomain = async ( + page: Page, + domain: { name: string; displayName: string; fullyQualifiedName?: string } +) => { await page.getByTestId('add-domain').click(); await page.waitForSelector('[data-testid="loader"]', { state: 'detached' }); - await expect(page.getByTestId('remove-owner').locator('path')).toBeVisible(); - - await page.getByTestId('remove-owner').locator('svg').click(); + await page.getByTestId(`tag-${domain.fullyQualifiedName}`).click(); + await page.getByTestId('saveAssociatedTag').click(); await expect(page.getByTestId('no-domain-text')).toContainText('No Domain'); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-subdomain.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-subdomain.svg new file mode 100644 index 00000000000..544403812a7 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-subdomain.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetailsPage/DomainDetailsPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetailsPage/DomainDetailsPage.component.tsx index 41869ffddd4..c88cd4fb2d9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetailsPage/DomainDetailsPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetailsPage/DomainDetailsPage.component.tsx @@ -40,6 +40,7 @@ import { useHistory, useParams } from 'react-router-dom'; import { ReactComponent as EditIcon } from '../../../assets/svg/edit-new.svg'; import { ReactComponent as DeleteIcon } from '../../../assets/svg/ic-delete.svg'; import { ReactComponent as DomainIcon } from '../../../assets/svg/ic-domain.svg'; +import { ReactComponent as SubDomainIcon } from '../../../assets/svg/ic-subdomain.svg'; import { ReactComponent as VersionIcon } from '../../../assets/svg/ic-version.svg'; import { ReactComponent as IconDropdown } from '../../../assets/svg/menu.svg'; import { ReactComponent as StyleIcon } from '../../../assets/svg/style.svg'; @@ -638,6 +639,41 @@ const DomainDetailsPage = ({ fetchSubDomains(); }, [domainFqn]); + const iconData = useMemo(() => { + if (domain.style?.iconURL) { + return ( + domain-icon + ); + } else if (isSubDomain) { + return ( + + ); + } + + return ( + + ); + }, [domain, isSubDomain]); + return ( <> - ) : ( - - ) - } + icon={iconData} serviceName="" titleColor={domain.style?.color} /> diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx index 7f90651e9d4..2db0161d7f2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx @@ -82,7 +82,7 @@ import Fqn from '../../../utils/Fqn'; import { buildTree, findExpandableKeysForArray, - findGlossaryTermByFqn, + findItemByFqn, glossaryTermTableColumnsWidth, StatusClass, } from '../../../utils/GlossaryUtils'; @@ -672,10 +672,7 @@ const GlossaryTermTab = ({ ); const terms = cloneDeep(glossaryTerms) ?? []; - const item = findGlossaryTermByFqn( - terms, - record.fullyQualifiedName ?? '' - ); + const item = findItemByFqn(terms, record.fullyQualifiedName ?? ''); (item as ModifiedGlossary).children = data; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/TreeAsyncSelectList.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/TreeAsyncSelectList.tsx index d47ea807096..ccd96f06df9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/TreeAsyncSelectList.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/TreeAsyncSelectList.tsx @@ -43,7 +43,7 @@ import { getEntityName } from '../../../utils/EntityUtils'; import { convertGlossaryTermsToTreeOptions, filterTreeNodeOptions, - findGlossaryTermByFqn, + findItemByFqn, } from '../../../utils/GlossaryUtils'; import { escapeESReservedCharacters, @@ -200,7 +200,7 @@ const TreeAsyncSelectList: FC> = ({ if (lastSelectedMap.has(value)) { return lastSelectedMap.get(value) as SelectOption; } - const initialData = findGlossaryTermByFqn( + const initialData = findItemByFqn( [ ...glossaries, ...(isNull(searchOptions) ? [] : searchOptions), diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableList/DomainSelectableList.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableList/DomainSelectableList.component.tsx index aecba1071cf..fef7bebee64 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableList/DomainSelectableList.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableList/DomainSelectableList.component.tsx @@ -15,26 +15,12 @@ import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ReactComponent as EditIcon } from '../../../assets/svg/edit-new.svg'; import { ReactComponent as DomainIcon } from '../../../assets/svg/ic-domain.svg'; -import { - DE_ACTIVE_COLOR, - PAGE_SIZE_MEDIUM, -} from '../../../constants/constants'; +import { DE_ACTIVE_COLOR } from '../../../constants/constants'; import { NO_PERMISSION_FOR_ACTION } from '../../../constants/HelperTextUtil'; -import { EntityType } from '../../../enums/entity.enum'; -import { SearchIndex } from '../../../enums/search.enum'; import { EntityReference } from '../../../generated/entity/type'; -import { useApplicationStore } from '../../../hooks/useApplicationStore'; -import { getDomainList } from '../../../rest/domainAPI'; -import { searchData } from '../../../rest/miscAPI'; -import { formatDomainsResponse } from '../../../utils/APIUtils'; -import { Transi18next } from '../../../utils/CommonUtils'; -import { - getEntityName, - getEntityReferenceListFromEntities, -} from '../../../utils/EntityUtils'; +import { getEntityName } from '../../../utils/EntityUtils'; import Fqn from '../../../utils/Fqn'; -import { getDomainPath } from '../../../utils/RouterUtils'; -import { SelectableList } from '../SelectableList/SelectableList.component'; +import DomainSelectablTree from '../DomainSelectableTree/DomainSelectableTree'; import './domain-select-dropdown.less'; import { DomainSelectableListProps } from './DomainSelectableList.interface'; @@ -74,57 +60,18 @@ const DomainSelectableList = ({ multiple = false, }: DomainSelectableListProps) => { const { t } = useTranslation(); - const { theme } = useApplicationStore(); const [popupVisible, setPopupVisible] = useState(false); const selectedDomainsList = useMemo(() => { if (selectedDomain) { - return Array.isArray(selectedDomain) ? selectedDomain : [selectedDomain]; + return Array.isArray(selectedDomain) + ? selectedDomain.map((item) => item.fullyQualifiedName) + : [selectedDomain.fullyQualifiedName]; } return []; }, [selectedDomain]); - const fetchOptions = async (searchText: string, after?: string) => { - if (searchText) { - try { - const res = await searchData( - searchText, - 1, - PAGE_SIZE_MEDIUM, - '', - '', - '', - SearchIndex.DOMAIN - ); - - const data = getEntityReferenceListFromEntities( - formatDomainsResponse(res.data.hits.hits), - EntityType.DOMAIN - ); - - return { data, paging: { total: res.data.hits.total.value } }; - } catch (error) { - return { data: [], paging: { total: 0 } }; - } - } else { - try { - const { data, paging } = await getDomainList({ - limit: PAGE_SIZE_MEDIUM, - after: after ?? undefined, - }); - const filterData = getEntityReferenceListFromEntities( - data, - EntityType.DOMAIN - ); - - return { data: filterData, paging }; - } catch (error) { - return { data: [], paging: { total: 0 } }; - } - } - }; - const handleUpdate = useCallback( async (domains: EntityReference[]) => { if (multiple) { @@ -132,6 +79,7 @@ const DomainSelectableList = ({ } else { await onUpdate(domains[0]); } + setPopupVisible(false); }, [onUpdate, multiple] @@ -146,39 +94,16 @@ const DomainSelectableList = ({ - } - values={{ - link: t('label.domain-plural'), - }} - /> - } - fetchOptions={fetchOptions} - multiSelect={multiple} - removeIconTooltipLabel={t('label.remove-entity', { - entity: t('label.domain-lowercase'), - })} - searchPlaceholder={t('label.search-for-type', { - type: t('label.domain'), - })} - selectedItems={selectedDomainsList} + setPopupVisible(false)} - onUpdate={handleUpdate} + onSubmit={handleUpdate} /> } open={popupVisible} - overlayClassName="domain-select-popover" + overlayClassName="domain-select-popover w-400" placement="bottomRight" showArrow={false} trigger="click" diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableList/domain-select-dropdown.less b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableList/domain-select-dropdown.less index 3b869421238..13aae85efc6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableList/domain-select-dropdown.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableList/domain-select-dropdown.less @@ -15,7 +15,11 @@ .domain-select-popover { min-width: 275px; - padding-top: 0; + + &.ant-popover { + padding-top: 0; + } + .ant-popover-inner-content { padding: 0; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableTree/DomainSelectableTree.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableTree/DomainSelectableTree.interface.ts new file mode 100644 index 00000000000..b3c140eda20 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableTree/DomainSelectableTree.interface.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2025 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 { DefaultOptionType } from 'antd/lib/select'; +import { EntityReference } from '../../../generated/entity/type'; + +export interface DomainSelectableTreeProps { + value?: string[]; // array of fqn + onSubmit: (option: EntityReference[]) => Promise; + visible: boolean; + onCancel: () => void; + isMultiple?: boolean; +} + +export type TreeListItem = Omit; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableTree/DomainSelectableTree.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableTree/DomainSelectableTree.tsx new file mode 100644 index 00000000000..090a5df0b75 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableTree/DomainSelectableTree.tsx @@ -0,0 +1,224 @@ +/* + * Copyright 2025 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, Empty, Space, Tree } from 'antd'; +import Search from 'antd/lib/input/Search'; +import { AxiosError } from 'axios'; +import { debounce } from 'lodash'; +import React, { + FC, + Key, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as IconDown } from '../../../assets/svg/ic-arrow-down.svg'; +import { ReactComponent as IconRight } from '../../../assets/svg/ic-arrow-right.svg'; +import { EntityType } from '../../../enums/entity.enum'; +import { Domain } from '../../../generated/entity/domains/domain'; +import { EntityReference } from '../../../generated/tests/testCase'; +import { listDomainHierarchy, searchDomains } from '../../../rest/domainAPI'; +import { convertDomainsToTreeOptions } from '../../../utils/DomainUtils'; +import { getEntityReferenceFromEntity } from '../../../utils/EntityUtils'; +import { findItemByFqn } from '../../../utils/GlossaryUtils'; +import { + escapeESReservedCharacters, + getEncodedFqn, +} from '../../../utils/StringsUtils'; +import { showErrorToast } from '../../../utils/ToastUtils'; +import Loader from '../Loader/Loader'; +import './domain-selectable.less'; +import { + DomainSelectableTreeProps, + TreeListItem, +} from './DomainSelectableTree.interface'; + +const DomainSelectablTree: FC = ({ + onSubmit, + value, + visible, + onCancel, + isMultiple = false, +}) => { + const { t } = useTranslation(); + const [treeData, setTreeData] = useState([]); + const [domains, setDomains] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isSubmitLoading, setIsSubmitLoading] = useState(false); + const [selectedDomains, setSelectedDomains] = useState([]); + const [searchTerm, setSearchTerm] = useState(''); + + const handleSave = async () => { + setIsSubmitLoading(true); + if (isMultiple) { + const selectedData = []; + for (const item of selectedDomains) { + selectedData.push(findItemByFqn(domains, item as string, false)); + } + const domains1 = selectedData.map((item) => + getEntityReferenceFromEntity(item as EntityReference, EntityType.DOMAIN) + ); + await onSubmit(domains1); + } else { + let retn: EntityReference[] = []; + if (selectedDomains.length > 0) { + const initialData = findItemByFqn( + domains, + selectedDomains[0] as string, + false + ); + const domain = getEntityReferenceFromEntity( + initialData as EntityReference, + EntityType.DOMAIN + ); + retn = [domain]; + } + await onSubmit(retn); + } + + setIsSubmitLoading(false); + }; + + const fetchAPI = useCallback(async () => { + try { + setIsLoading(true); + const data = await listDomainHierarchy({ limit: 100 }); + setTreeData(convertDomainsToTreeOptions(data.data, 0, isMultiple)); + setDomains(data.data); + } catch (error) { + showErrorToast(error as AxiosError); + } finally { + setIsLoading(false); + } + }, []); + + const onSelect = (selectedKeys: React.Key[]) => { + if (!isMultiple) { + setSelectedDomains(selectedKeys); + } + }; + + const onCheck = ( + checked: Key[] | { checked: Key[]; halfChecked: Key[] } + ): void => { + if (Array.isArray(checked)) { + setSelectedDomains(checked); + } else { + setSelectedDomains(checked.checked); + } + }; + + const onSearch = debounce(async (value: string) => { + setSearchTerm(value); + if (value) { + try { + setIsLoading(true); + const encodedValue = getEncodedFqn(escapeESReservedCharacters(value)); + const results: Domain[] = await searchDomains(encodedValue); + const updatedTreeData = convertDomainsToTreeOptions( + results, + 0, + isMultiple + ); + setTreeData(updatedTreeData); + } finally { + setIsLoading(false); + } + } else { + fetchAPI(); + } + }, 300); + + const switcherIcon = useCallback(({ expanded }) => { + return expanded ? : ; + }, []); + + const treeContent = useMemo(() => { + if (isLoading) { + return ; + } else if (treeData.length === 0) { + return ( + + ); + } else { + return ( + + ); + } + }, [isLoading, treeData, value, onSelect, isMultiple, searchTerm]); + + useEffect(() => { + if (visible) { + setSearchTerm(''); + fetchAPI(); + } + }, [visible]); + + return ( +
+ onSearch(e.target.value)} + /> + + {treeContent} + + + + + +
+ ); +}; + +export default DomainSelectablTree; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableTree/domain-selectable.less b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableTree/domain-selectable.less new file mode 100644 index 00000000000..bcf651e2e81 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableTree/domain-selectable.less @@ -0,0 +1,26 @@ +/* + * Copyright 2025 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 (reference) url('../../../styles/variables.less'); + +.domain-selectable-tree { + overflow: auto; + height: 270px; + + .ant-tree-treenode { + .ant-tree-node-content-wrapper { + &.ant-tree-node-selected { + background-color: @radio-button-checked-bg; + } + } + } +} diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useDomainStore.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useDomainStore.ts index c25f6fb992d..ffc93273ea8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useDomainStore.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useDomainStore.ts @@ -34,7 +34,7 @@ export const useDomainStore = create()( activeDomain: DEFAULT_DOMAIN_VALUE, // Set default value here activeDomainEntityRef: undefined, domainOptions: [], - updateDomains: (data: Domain[], selectDefault = true) => { + updateDomains: (data: Domain[]) => { const currentUser = useApplicationStore.getState().currentUser; const { isAdmin = false, domains = [] } = currentUser ?? {}; const userDomainsObj = isAdmin ? [] : domains; @@ -51,19 +51,9 @@ export const useDomainStore = create()( set({ domains: filteredDomains, domainOptions: getDomainOptions( - isAdmin ? filteredDomains : userDomainsObj, - isAdmin + isAdmin ? filteredDomains : userDomainsObj ), }); - - if ( - selectDefault && - !isAdmin && - userDomainsObj.length > 0 && - get().activeDomain === DEFAULT_DOMAIN_VALUE - ) { - get().updateActiveDomain(userDomainsObj[0].fullyQualifiedName ?? ''); - } }, updateActiveDomain: (activeDomainKey: string) => { const currentUser = useApplicationStore.getState().currentUser; diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/domainAPI.ts b/openmetadata-ui/src/main/resources/ui/src/rest/domainAPI.ts index ab551758849..7716bbbb86e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/domainAPI.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/domainAPI.ts @@ -14,6 +14,8 @@ import { AxiosResponse } from 'axios'; import { Operation } from 'fast-json-patch'; import { PagingResponse } from 'Models'; +import { PAGE_SIZE_MEDIUM } from '../constants/constants'; +import { SearchIndex } from '../enums/search.enum'; import { CreateDomain } from '../generated/api/domains/createDomain'; import { Domain, EntityReference } from '../generated/entity/domains/domain'; import { EntityHistory } from '../generated/type/entityHistory'; @@ -105,3 +107,31 @@ export const removeAssetsFromDomain = async ( return response.data; }; + +export const listDomainHierarchy = async (params?: ListParams) => { + const response = await APIClient.get>( + `${BASE_URL}/hierarchy`, + { + params, + } + ); + + return response.data; +}; + +export const searchDomains = async (search: string, page = 1) => { + const apiUrl = `/search/query?q=*${search ?? ''}*`; + + const { data } = await APIClient.get(apiUrl, { + params: { + index: SearchIndex.DOMAIN, + from: (page - 1) * PAGE_SIZE_MEDIUM, + size: PAGE_SIZE_MEDIUM, + deleted: false, + track_total_hits: true, + getHierarchy: true, + }, + }); + + return data; +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/DomainUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/DomainUtils.tsx index a5b9e262b7e..3d951113ece 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/DomainUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/DomainUtils.tsx @@ -14,12 +14,16 @@ import { Divider, Space, Typography } from 'antd'; import { ItemType } from 'antd/lib/menu/hooks/useItems'; import classNames from 'classnames'; import { t } from 'i18next'; -import { isEmpty, isUndefined } from 'lodash'; +import { get, isEmpty, isUndefined } from 'lodash'; import React, { Fragment, ReactNode } from 'react'; import { Link } from 'react-router-dom'; +import { ReactComponent as DomainIcon } from '../assets/svg/ic-domain.svg'; +import { ReactComponent as SubDomainIcon } from '../assets/svg/ic-subdomain.svg'; +import { TreeListItem } from '../components/common/DomainSelectableTree/DomainSelectableTree.interface'; import { OwnerLabel } from '../components/common/OwnerLabel/OwnerLabel.component'; import { DEFAULT_DOMAIN_VALUE, + DE_ACTIVE_COLOR, NO_DATA_PLACEHOLDER, } from '../constants/constants'; import { DOMAIN_TYPE_DATA } from '../constants/Domain.constants'; @@ -140,23 +144,33 @@ export const domainTypeTooltipDataRender = () => ( ); -export const getDomainOptions = ( - domains: Domain[] | EntityReference[], - isAdmin = true -) => { - const domainOptions: ItemType[] = - isAdmin || domains.length === 0 - ? [ - { - label: t('label.all-domain-plural'), - key: DEFAULT_DOMAIN_VALUE, - }, - ] - : []; +export const getDomainOptions = (domains: Domain[] | EntityReference[]) => { + const domainOptions: ItemType[] = [ + { + label: t('label.all-domain-plural'), + key: DEFAULT_DOMAIN_VALUE, + }, + ]; + domains.forEach((domain) => { domainOptions.push({ label: getEntityName(domain), key: domain.fullyQualifiedName ?? '', + icon: get(domain, 'parent') ? ( + + ) : ( + + ), }); }); @@ -204,3 +218,54 @@ export const getDomainFieldFromEntityType = ( return 'domain'; } }; + +export const convertDomainsToTreeOptions = ( + options: EntityReference[] | Domain[] = [], + level = 0, + multiple = false +): TreeListItem[] => { + const treeData = options.map((option) => { + const hasChildren = 'children' in option && !isEmpty(option?.children); + + return { + id: option.id, + value: option.fullyQualifiedName, + name: option.name, + label: option.name, + key: option.fullyQualifiedName, + title: ( +
+ {level === 0 ? ( + + ) : ( + + )} + + {getEntityName(option)} +
+ ), + 'data-testid': `tag-${option.fullyQualifiedName}`, + isLeaf: !hasChildren, + selectable: !multiple, + children: hasChildren + ? convertDomainsToTreeOptions( + (option as unknown as Domain)?.children as EntityReference[], + level + 1, + multiple + ) + : undefined, + }; + }); + + return treeData; +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.tsx index 69c70d215e7..c4800dcb6f9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.tsx @@ -35,6 +35,7 @@ import { Status, TermReference, } from '../generated/entity/data/glossaryTerm'; +import { Domain } from '../generated/entity/domains/domain'; import { calculatePercentageFromValue } from './CommonUtils'; import { getEntityName } from './EntityUtils'; import { VersionStatus } from './EntityVersionUtils.interface'; @@ -191,23 +192,28 @@ export const updateGlossaryTermByFqn = ( // This function finds and gives you the glossary term you're looking for. // You can then use this term or update its information in the Glossary or Term with it's reference created // Reference will only be created if withReference is true -export const findGlossaryTermByFqn = ( - list: ModifiedGlossaryTerm[], +export const findItemByFqn = ( + list: ModifiedGlossaryTerm[] | Domain[], fullyQualifiedName: string, withReference = true ): GlossaryTerm | Glossary | ModifiedGlossary | null => { for (const item of list) { - if ((item.fullyQualifiedName ?? item.value) === fullyQualifiedName) { + if ( + (item.fullyQualifiedName ?? (item as ModifiedGlossaryTerm).value) === + fullyQualifiedName + ) { return withReference ? item : { ...item, - fullyQualifiedName: item.fullyQualifiedName ?? item.data?.tagFQN, - ...(item.data ?? {}), + fullyQualifiedName: + item.fullyQualifiedName ?? + (item as ModifiedGlossaryTerm).data?.tagFQN, + ...((item as ModifiedGlossaryTerm).data ?? {}), }; } if (item.children) { - const found = findGlossaryTermByFqn( + const found = findItemByFqn( item.children as ModifiedGlossaryTerm[], fullyQualifiedName );