From 853bbc205b5cc55500925de3b83888f7217ca449 Mon Sep 17 00:00:00 2001 From: sonika-shah <58761340+sonika-shah@users.noreply.github.com> Date: Mon, 18 Nov 2024 17:57:07 +0530 Subject: [PATCH] GEN-1983 : Feat - Support Tag Asset Page (#18505) * Feat : Support bulk adding tag to Assets * remove warnings * add apis for assets remove * Fix: Add tag page (#18461) * add tag page which all Assets * update as per feedbacks * update as per feedbacks * add divider in header badge * remove styling * update permission and refactoring code * updated as per comments * fix sonar cloud issues * add delete asset functionality * refactor entityTypeString * made the top bar fixed to top * add tests for tag page * fix check failures issue * fix tag page check failure * fix flaky test issue * add tag page tests * update the add asset test * update playwright tests * add right panel in tag page * updated as per feedbacks * remove usage test * updated as per feedbacks --------- * Backend: make bulkAssets api async * Backend: limit bulkAssets api async to Tag Assets page * Update Tag page Assets API (#18622) * create branch * update add asset API * add websocket on tags page --------- Co-authored-by: Karan Hotchandani <33024356+karanh37@users.noreply.github.com> Co-authored-by: karanh37 * add failed case for socket operation --------- Co-authored-by: Karan Hotchandani <33024356+karanh37@users.noreply.github.com> Co-authored-by: Sweta Agarwalla <105535990+sweta1308@users.noreply.github.com> Co-authored-by: karanh37 --- .../service/jdbi3/EntityRepository.java | 11 + .../service/jdbi3/TagRepository.java | 126 ++++ .../service/resources/EntityResource.java | 53 ++ .../domains/DataProductResource.java | 2 +- .../service/resources/tags/TagLabelUtil.java | 1 + .../service/resources/tags/TagResource.java | 53 ++ .../service/resources/teams/TeamResource.java | 2 +- .../service/socket/WebSocketManager.java | 2 + .../util/BulkAssetsOperationMessage.java | 22 + .../util/BulkAssetsOperationResponse.java | 16 + .../util/WebsocketNotificationHandler.java | 20 + .../schema/BulkAssetsRequestInterface.java | 26 + .../schema/api/addTagToAssetsRequest.json | 29 + .../ui/playwright/e2e/Pages/Tag.spec.ts | 251 +++++++ .../ui/playwright/e2e/Pages/Tags.spec.ts | 33 - .../ui/playwright/support/tag/TagClass.ts | 1 + .../main/resources/ui/playwright/utils/tag.ts | 89 ++- .../AppRouter/AuthenticatedAppRouter.tsx | 6 + .../AssetSelectionModal.interface.ts | 2 + .../AssetSelectionModal.tsx | 136 +++- .../tabs/AssetsTabs.component.tsx | 26 +- .../tabs/AssetsTabs.interface.ts | 1 + .../Tag/TagsV1/TagsV1.component.tsx | 8 +- .../resources/ui/src/constants/constants.ts | 3 + .../main/resources/ui/src/mocks/Tags.mock.ts | 9 + .../ui/src/pages/TagPage/TagPage.inteface.ts | 16 + .../ui/src/pages/TagPage/TagPage.tsx | 701 ++++++++++++++++++ .../ui/src/pages/TagPage/tag-page.less | 26 + .../src/main/resources/ui/src/rest/tagAPI.ts | 39 +- .../resizable-panels-component.less | 11 + .../resources/ui/src/styles/variables.less | 3 + .../ui/src/utils/Assets/AssetsUtils.ts | 19 + .../ui/src/utils/ClassificationUtils.tsx | 26 +- .../resources/ui/src/utils/EntityUtils.tsx | 25 +- .../resources/ui/src/utils/RouterUtils.ts | 11 + .../main/resources/ui/src/utils/TagsUtils.tsx | 37 + 36 files changed, 1740 insertions(+), 102 deletions(-) create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/util/BulkAssetsOperationMessage.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/util/BulkAssetsOperationResponse.java create mode 100644 openmetadata-spec/src/main/java/org/openmetadata/schema/BulkAssetsRequestInterface.java create mode 100644 openmetadata-spec/src/main/resources/json/schema/api/addTagToAssetsRequest.json create mode 100644 openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Tag.spec.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.inteface.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/pages/TagPage/tag-page.less diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java index df1bcefa8961..be30b747cc3a 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java @@ -119,6 +119,7 @@ import org.apache.commons.lang3.tuple.Pair; import org.jdbi.v3.sqlobject.transaction.Transaction; import org.openmetadata.common.utils.CommonUtil; +import org.openmetadata.schema.BulkAssetsRequestInterface; import org.openmetadata.schema.CreateEntity; import org.openmetadata.schema.EntityInterface; import org.openmetadata.schema.api.VoteRequest; @@ -2511,6 +2512,16 @@ protected void validateColumnTags(List columns) { } } + public BulkOperationResult bulkAddAndValidateTagsToAssets( + UUID glossaryTermId, BulkAssetsRequestInterface request) { + throw new UnsupportedOperationException("Bulk Add tags to Asset operation not supported"); + } + + public BulkOperationResult bulkRemoveAndValidateTagsToAssets( + UUID glossaryTermId, BulkAssetsRequestInterface request) { + throw new UnsupportedOperationException("Bulk Remove tags to Asset operation not supported"); + } + public enum Operation { PUT, PATCH, diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TagRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TagRepository.java index 853e7759a991..ef0d1a158764 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TagRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TagRepository.java @@ -17,25 +17,39 @@ import static org.openmetadata.schema.type.Include.NON_DELETED; import static org.openmetadata.service.Entity.CLASSIFICATION; import static org.openmetadata.service.Entity.TAG; +import static org.openmetadata.service.resources.tags.TagLabelUtil.checkMutuallyExclusiveForParentAndSubField; +import static org.openmetadata.service.resources.tags.TagLabelUtil.getUniqueTags; import static org.openmetadata.service.util.EntityUtil.entityReferenceMatch; import static org.openmetadata.service.util.EntityUtil.getId; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.UUID; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.tuple.ImmutablePair; import org.jdbi.v3.sqlobject.transaction.Transaction; +import org.openmetadata.common.utils.CommonUtil; +import org.openmetadata.schema.BulkAssetsRequestInterface; +import org.openmetadata.schema.EntityInterface; +import org.openmetadata.schema.api.AddTagToAssetsRequest; import org.openmetadata.schema.entity.classification.Classification; import org.openmetadata.schema.entity.classification.Tag; +import org.openmetadata.schema.type.ApiStatus; import org.openmetadata.schema.type.EntityReference; import org.openmetadata.schema.type.ProviderType; import org.openmetadata.schema.type.Relationship; +import org.openmetadata.schema.type.TagLabel; import org.openmetadata.schema.type.TagLabel.TagSource; +import org.openmetadata.schema.type.api.BulkOperationResult; +import org.openmetadata.schema.type.api.BulkResponse; import org.openmetadata.service.Entity; import org.openmetadata.service.exception.CatalogExceptionMessage; import org.openmetadata.service.jdbi3.CollectionDAO.EntityRelationshipRecord; import org.openmetadata.service.resources.tags.TagResource; +import org.openmetadata.service.util.EntityUtil; import org.openmetadata.service.util.EntityUtil.Fields; import org.openmetadata.service.util.FullyQualifiedName; @@ -109,6 +123,118 @@ public void setFullyQualifiedName(Tag tag) { } } + @Override + public BulkOperationResult bulkAddAndValidateTagsToAssets( + UUID classificationTagId, BulkAssetsRequestInterface request) { + AddTagToAssetsRequest addTagToAssetsRequest = (AddTagToAssetsRequest) request; + boolean dryRun = Boolean.TRUE.equals(addTagToAssetsRequest.getDryRun()); + + Tag tag = this.get(null, classificationTagId, getFields("id")); + + BulkOperationResult result = new BulkOperationResult().withDryRun(dryRun); + List failures = new ArrayList<>(); + List success = new ArrayList<>(); + + if (dryRun || CommonUtil.nullOrEmpty(request.getAssets())) { + // Nothing to Validate + return result + .withStatus(ApiStatus.SUCCESS) + .withSuccessRequest(List.of(new BulkResponse().withMessage("Nothing to Validate."))); + } + + // Validation for entityReferences + EntityUtil.populateEntityReferences(request.getAssets()); + + TagLabel tagLabel = + new TagLabel() + .withTagFQN(tag.getFullyQualifiedName()) + .withSource(TagSource.CLASSIFICATION) + .withLabelType(TagLabel.LabelType.MANUAL); + + for (EntityReference ref : request.getAssets()) { + // Update Result Processed + result.setNumberOfRowsProcessed(result.getNumberOfRowsProcessed() + 1); + + EntityRepository entityRepository = Entity.getEntityRepository(ref.getType()); + EntityInterface asset = + entityRepository.get(null, ref.getId(), entityRepository.getFields("tags")); + + try { + Map> allAssetTags = + daoCollection.tagUsageDAO().getTagsByPrefix(asset.getFullyQualifiedName(), "%", true); + checkMutuallyExclusiveForParentAndSubField( + asset.getFullyQualifiedName(), + FullyQualifiedName.buildHash(asset.getFullyQualifiedName()), + allAssetTags, + new ArrayList<>(Collections.singleton(tagLabel)), + false); + success.add(new BulkResponse().withRequest(ref)); + result.setNumberOfRowsPassed(result.getNumberOfRowsPassed() + 1); + } catch (Exception ex) { + failures.add(new BulkResponse().withRequest(ref).withMessage(ex.getMessage())); + result.withFailedRequest(failures); + result.setNumberOfRowsFailed(result.getNumberOfRowsFailed() + 1); + } + // Validate and Store Tags + if (CommonUtil.nullOrEmpty(result.getFailedRequest())) { + List tempList = new ArrayList<>(asset.getTags()); + tempList.add(tagLabel); + // Apply Tags to Entities + entityRepository.applyTags(getUniqueTags(tempList), asset.getFullyQualifiedName()); + + searchRepository.updateEntity(ref); + } + } + + // Add Failed And Suceess Request + result.withFailedRequest(failures).withSuccessRequest(success); + + // Set Final Status + if (result.getNumberOfRowsPassed().equals(result.getNumberOfRowsProcessed())) { + result.withStatus(ApiStatus.SUCCESS); + } else if (result.getNumberOfRowsPassed() > 1) { + result.withStatus(ApiStatus.PARTIAL_SUCCESS); + } else { + result.withStatus(ApiStatus.FAILURE); + } + + return result; + } + + @Override + public BulkOperationResult bulkRemoveAndValidateTagsToAssets( + UUID classificationTagId, BulkAssetsRequestInterface request) { + Tag tag = this.get(null, classificationTagId, getFields("id")); + + BulkOperationResult result = + new BulkOperationResult().withStatus(ApiStatus.SUCCESS).withDryRun(false); + List success = new ArrayList<>(); + + // Validation for entityReferences + EntityUtil.populateEntityReferences(request.getAssets()); + + for (EntityReference ref : request.getAssets()) { + // Update Result Processed + result.setNumberOfRowsProcessed(result.getNumberOfRowsProcessed() + 1); + + EntityRepository entityRepository = Entity.getEntityRepository(ref.getType()); + EntityInterface asset = + entityRepository.get(null, ref.getId(), entityRepository.getFields("id")); + + daoCollection + .tagUsageDAO() + .deleteTagsByTagAndTargetEntity( + tag.getFullyQualifiedName(), asset.getFullyQualifiedName()); + success.add(new BulkResponse().withRequest(ref)); + result.setNumberOfRowsPassed(result.getNumberOfRowsPassed() + 1); + + // Update ES + searchRepository.updateEntity(ref); + } + + return result.withSuccessRequest(success); + } + @Override public EntityRepository.EntityUpdater getUpdater( Tag original, Tag updated, Operation operation) { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/EntityResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/EntityResource.java index cd2def9d0947..747135cdf7c8 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/EntityResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/EntityResource.java @@ -36,11 +36,13 @@ import javax.ws.rs.core.UriInfo; import lombok.Getter; import lombok.extern.slf4j.Slf4j; +import org.openmetadata.schema.BulkAssetsRequestInterface; import org.openmetadata.schema.EntityInterface; import org.openmetadata.schema.type.EntityHistory; import org.openmetadata.schema.type.EntityReference; import org.openmetadata.schema.type.Include; import org.openmetadata.schema.type.MetadataOperation; +import org.openmetadata.schema.type.api.BulkOperationResult; import org.openmetadata.schema.type.csv.CsvImportResult; import org.openmetadata.service.Entity; import org.openmetadata.service.OpenMetadataApplicationConfig; @@ -56,6 +58,7 @@ import org.openmetadata.service.security.policyevaluator.ResourceContext; import org.openmetadata.service.security.policyevaluator.ResourceContextInterface; import org.openmetadata.service.util.AsyncService; +import org.openmetadata.service.util.BulkAssetsOperationResponse; import org.openmetadata.service.util.CSVExportResponse; import org.openmetadata.service.util.EntityUtil; import org.openmetadata.service.util.EntityUtil.Fields; @@ -412,6 +415,56 @@ public Response exportCsvInternalAsync(SecurityContext securityContext, String n return Response.accepted().entity(response).type(MediaType.APPLICATION_JSON).build(); } + public Response bulkAddToAssetsAsync( + SecurityContext securityContext, UUID entityId, BulkAssetsRequestInterface request) { + OperationContext operationContext = + new OperationContext(entityType, MetadataOperation.EDIT_ALL); + authorizer.authorize(securityContext, operationContext, getResourceContextById(entityId)); + String jobId = UUID.randomUUID().toString(); + ExecutorService executorService = AsyncService.getInstance().getExecutorService(); + executorService.submit( + () -> { + try { + BulkOperationResult result = + repository.bulkAddAndValidateTagsToAssets(entityId, request); + WebsocketNotificationHandler.bulkAssetsOperationCompleteNotification( + jobId, securityContext, result); + } catch (Exception e) { + WebsocketNotificationHandler.bulkAssetsOperationFailedNotification( + jobId, securityContext, e.getMessage()); + } + }); + BulkAssetsOperationResponse response = + new BulkAssetsOperationResponse( + jobId, "Bulk Add tags to Asset operation initiated successfully."); + return Response.ok().entity(response).type(MediaType.APPLICATION_JSON).build(); + } + + public Response bulkRemoveFromAssetsAsync( + SecurityContext securityContext, UUID entityId, BulkAssetsRequestInterface request) { + OperationContext operationContext = + new OperationContext(entityType, MetadataOperation.EDIT_ALL); + authorizer.authorize(securityContext, operationContext, getResourceContextById(entityId)); + String jobId = UUID.randomUUID().toString(); + ExecutorService executorService = AsyncService.getInstance().getExecutorService(); + executorService.submit( + () -> { + try { + BulkOperationResult result = + repository.bulkRemoveAndValidateTagsToAssets(entityId, request); + WebsocketNotificationHandler.bulkAssetsOperationCompleteNotification( + jobId, securityContext, result); + } catch (Exception e) { + WebsocketNotificationHandler.bulkAssetsOperationFailedNotification( + jobId, securityContext, e.getMessage()); + } + }); + BulkAssetsOperationResponse response = + new BulkAssetsOperationResponse( + jobId, "Bulk Remove tags to Asset operation initiated successfully."); + return Response.ok().entity(response).type(MediaType.APPLICATION_JSON).build(); + } + public String exportCsvInternal(SecurityContext securityContext, String name) throws IOException { OperationContext operationContext = new OperationContext(entityType, MetadataOperation.VIEW_ALL); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/domains/DataProductResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/domains/DataProductResource.java index bcf97ae37efe..f5a20bbe770a 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/domains/DataProductResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/domains/DataProductResource.java @@ -355,7 +355,7 @@ public Response bulkAddAssets( schema = @Schema(implementation = ChangeEvent.class))), @ApiResponse(responseCode = "404", description = "model for instance {id} is not found") }) - public Response bulkRemoveGlossaryFromAssets( + public Response bulkRemoveAssets( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Parameter(description = "Name of the Data Product", schema = @Schema(type = "string")) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/tags/TagLabelUtil.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/tags/TagLabelUtil.java index 3183229995e6..1fa10db732e9 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/tags/TagLabelUtil.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/tags/TagLabelUtil.java @@ -179,6 +179,7 @@ public static void checkMutuallyExclusiveForParentAndSubField( checkMutuallyExclusive(getUniqueTags(tempList)); } catch (IllegalArgumentException ex) { failed = true; + tempList.removeAll(glossaryTags); errorMessage.append( String.format( "Asset %s has a tag %s which is mutually exclusive with the one of the glossary tags %s. %n", diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/tags/TagResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/tags/TagResource.java index 3dce57ac92d4..1ce89ee5054b 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/tags/TagResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/tags/TagResource.java @@ -50,14 +50,17 @@ import javax.ws.rs.core.SecurityContext; import javax.ws.rs.core.UriInfo; import lombok.extern.slf4j.Slf4j; +import org.openmetadata.schema.api.AddTagToAssetsRequest; import org.openmetadata.schema.api.classification.CreateTag; import org.openmetadata.schema.api.classification.LoadTags; import org.openmetadata.schema.api.data.RestoreEntity; import org.openmetadata.schema.entity.classification.Classification; import org.openmetadata.schema.entity.classification.Tag; +import org.openmetadata.schema.type.ChangeEvent; import org.openmetadata.schema.type.EntityHistory; import org.openmetadata.schema.type.Include; import org.openmetadata.schema.type.MetadataOperation; +import org.openmetadata.schema.type.api.BulkOperationResult; import org.openmetadata.service.Entity; import org.openmetadata.service.OpenMetadataApplicationConfig; import org.openmetadata.service.jdbi3.ClassificationRepository; @@ -503,6 +506,56 @@ public Response restore( return restoreEntity(uriInfo, securityContext, restore.getId()); } + @PUT + @Path("/{id}/assets/add") + @Operation( + operationId = "bulkAddTagToAssets", + summary = "Bulk Add Classification Tag to Assets", + description = "Bulk Add Classification Tag to Assets", + responses = { + @ApiResponse( + responseCode = "200", + description = "OK", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = BulkOperationResult.class))), + @ApiResponse(responseCode = "404", description = "model for instance {id} is not found") + }) + public Response bulkAddTagToAssets( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter(description = "Id of the Entity", schema = @Schema(type = "UUID")) @PathParam("id") + UUID id, + @Valid AddTagToAssetsRequest request) { + return bulkAddToAssetsAsync(securityContext, id, request); + } + + @PUT + @Path("/{id}/assets/remove") + @Operation( + operationId = "bulkRemoveTagFromAssets", + summary = "Bulk Remove Tag from Assets", + description = "Bulk Remove Tag from Assets", + responses = { + @ApiResponse( + responseCode = "200", + description = "OK", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ChangeEvent.class))), + @ApiResponse(responseCode = "404", description = "model for instance {id} is not found") + }) + public Response bulkRemoveTagFromAssets( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter(description = "Id of the Entity", schema = @Schema(type = "UUID")) @PathParam("id") + UUID id, + @Valid AddTagToAssetsRequest request) { + return bulkRemoveFromAssetsAsync(securityContext, id, request); + } + @Override public Tag addHref(UriInfo uriInfo, Tag tag) { super.addHref(uriInfo, tag); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/TeamResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/TeamResource.java index f3debbe21342..61c6a6ecce4d 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/TeamResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/TeamResource.java @@ -444,7 +444,7 @@ public Response bulkAddAssets( schema = @Schema(implementation = ChangeEvent.class))), @ApiResponse(responseCode = "404", description = "model for instance {id} is not found") }) - public Response bulkRemoveGlossaryFromAssets( + public Response bulkRemoveAssets( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Parameter(description = "Name of the Team", schema = @Schema(type = "string")) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/socket/WebSocketManager.java b/openmetadata-service/src/main/java/org/openmetadata/service/socket/WebSocketManager.java index 77e9a8336f34..1988ee5edc89 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/socket/WebSocketManager.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/socket/WebSocketManager.java @@ -30,6 +30,8 @@ public class WebSocketManager { public static final String ANNOUNCEMENT_CHANNEL = "announcementChannel"; public static final String CSV_EXPORT_CHANNEL = "csvExportChannel"; + public static final String BULK_ASSETS_CHANNEL = "bulkAssetsChannel"; + @Getter private final Map> activityFeedEndpoints = new ConcurrentHashMap<>(); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/util/BulkAssetsOperationMessage.java b/openmetadata-service/src/main/java/org/openmetadata/service/util/BulkAssetsOperationMessage.java new file mode 100644 index 000000000000..2333011402fc --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/util/BulkAssetsOperationMessage.java @@ -0,0 +1,22 @@ +package org.openmetadata.service.util; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.openmetadata.schema.type.api.BulkOperationResult; + +@NoArgsConstructor +public class BulkAssetsOperationMessage { + @Getter @Setter private String jobId; + @Getter @Setter private String status; + @Getter @Setter private BulkOperationResult result; + @Getter @Setter private String error; + + public BulkAssetsOperationMessage( + String jobId, String status, BulkOperationResult result, String error) { + this.jobId = jobId; + this.status = status; + this.result = result; + this.error = error; + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/util/BulkAssetsOperationResponse.java b/openmetadata-service/src/main/java/org/openmetadata/service/util/BulkAssetsOperationResponse.java new file mode 100644 index 000000000000..09676cd0f037 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/util/BulkAssetsOperationResponse.java @@ -0,0 +1,16 @@ +package org.openmetadata.service.util; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@NoArgsConstructor +public class BulkAssetsOperationResponse { + @Getter @Setter private String jobId; + @Getter @Setter private String message; + + public BulkAssetsOperationResponse(String jobId, String message) { + this.jobId = jobId; + this.message = message; + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/util/WebsocketNotificationHandler.java b/openmetadata-service/src/main/java/org/openmetadata/service/util/WebsocketNotificationHandler.java index d12dacf71b68..bfac09b6e0a6 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/util/WebsocketNotificationHandler.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/util/WebsocketNotificationHandler.java @@ -34,6 +34,7 @@ import org.openmetadata.schema.type.EntityReference; import org.openmetadata.schema.type.Post; import org.openmetadata.schema.type.Relationship; +import org.openmetadata.schema.type.api.BulkOperationResult; import org.openmetadata.service.Entity; import org.openmetadata.service.jdbi3.CollectionDAO; import org.openmetadata.service.resources.feeds.MessageParser; @@ -67,6 +68,25 @@ public static void sendCsvExportCompleteNotification( .sendToOne(userId, WebSocketManager.CSV_EXPORT_CHANNEL, jsonMessage); } + public static void bulkAssetsOperationCompleteNotification( + String jobId, SecurityContext securityContext, BulkOperationResult result) { + BulkAssetsOperationMessage message = + new BulkAssetsOperationMessage(jobId, "COMPLETED", result, null); + String jsonMessage = JsonUtils.pojoToJson(message); + UUID userId = getUserIdFromSecurityContext(securityContext); + WebSocketManager.getInstance() + .sendToOne(userId, WebSocketManager.BULK_ASSETS_CHANNEL, jsonMessage); + } + + public static void bulkAssetsOperationFailedNotification( + String jobId, SecurityContext securityContext, String errorMessage) { + CSVExportMessage message = new CSVExportMessage(jobId, "FAILED", null, errorMessage); + String jsonMessage = JsonUtils.pojoToJson(message); + UUID userId = getUserIdFromSecurityContext(securityContext); + WebSocketManager.getInstance() + .sendToOne(userId, WebSocketManager.BULK_ASSETS_CHANNEL, jsonMessage); + } + private void handleNotifications(ContainerResponseContext responseContext) { int responseCode = responseContext.getStatus(); if (responseCode == Response.Status.CREATED.getStatusCode() diff --git a/openmetadata-spec/src/main/java/org/openmetadata/schema/BulkAssetsRequestInterface.java b/openmetadata-spec/src/main/java/org/openmetadata/schema/BulkAssetsRequestInterface.java new file mode 100644 index 000000000000..d55ea1bee564 --- /dev/null +++ b/openmetadata-spec/src/main/java/org/openmetadata/schema/BulkAssetsRequestInterface.java @@ -0,0 +1,26 @@ +/* + * Copyright 2021 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. + */ + +package org.openmetadata.schema; + +import java.util.List; +import org.openmetadata.schema.type.EntityReference; + +/** Interface to be implemented by all entities to provide a way to access all the common fields. */ +@SuppressWarnings("unused") +public interface BulkAssetsRequestInterface { + // Lower case entity name to canonical entity name map + List getAssets(); + + void setAssets(List assets); +} diff --git a/openmetadata-spec/src/main/resources/json/schema/api/addTagToAssetsRequest.json b/openmetadata-spec/src/main/resources/json/schema/api/addTagToAssetsRequest.json new file mode 100644 index 000000000000..032f66beb0ed --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/api/addTagToAssetsRequest.json @@ -0,0 +1,29 @@ +{ + "$id": "https://open-metadata.org/schema/api/createBot.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AddTagToAssetsRequest", + "description": "Create Request for adding a tag to assets", + "type": "object", + "javaType": "org.openmetadata.schema.api.AddTagToAssetsRequest", + "javaInterfaces": ["org.openmetadata.schema.BulkAssetsRequestInterface"], + "properties": { + "operation": { + "description": "Operation to be performed", + "type": "string", + "enum": [ + "AddAssets", + "AddClassificationTags" + ] + }, + "dryRun": { + "description": "If true, the request will be validated but no changes will be made", + "type": "boolean", + "default": true + }, + "assets": { + "description": "List of assets to be created against which the tag needs to be added.", + "$ref": "../type/entityReferenceList.json" + } + }, + "additionalProperties": false +} diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Tag.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Tag.spec.ts new file mode 100644 index 000000000000..5f0345321891 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Tag.spec.ts @@ -0,0 +1,251 @@ +/* + * 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 { expect, test } from '@playwright/test'; +import { ClassificationClass } from '../../support/tag/ClassificationClass'; +import { TagClass } from '../../support/tag/TagClass'; +import { + createNewPage, + getApiContext, + redirectToHomePage, +} from '../../utils/common'; +import { + addAssetsToTag, + checkAssetsCount, + removeAssetsFromTag, + setupAssetsForTag, +} from '../../utils/tag'; + +test.use({ storageState: 'playwright/.auth/admin.json' }); + +test.describe('Tag page', () => { + test.slow(true); + + const classification = new ClassificationClass({ + provider: 'system', + mutuallyExclusive: true, + }); + + test.beforeAll(async ({ browser }) => { + const { apiContext, afterAction } = await createNewPage(browser); + await classification.create(apiContext); + await afterAction(); + }); + + test.afterAll(async ({ browser }) => { + const { apiContext, afterAction } = await createNewPage(browser); + await classification.delete(apiContext); + await afterAction(); + }); + + test('Verify Tag UI', async ({ page }) => { + await redirectToHomePage(page); + const { apiContext, afterAction } = await getApiContext(page); + const tag = new TagClass({ + classification: classification.data.name, + }); + try { + await tag.create(apiContext); + const res = page.waitForResponse(`/api/v1/tags/name/*`); + await tag.visitPage(page); + await res; + + await expect(page.getByText(tag.data.name)).toBeVisible(); + await expect(page.getByText(tag.data.description)).toBeVisible(); + + const classificationTable = page.waitForResponse( + `/api/v1/classifications/name/*` + ); + await page.getByRole('link', { name: classification.data.name }).click(); + classificationTable; + + await page.getByTestId(tag.data.name).click(); + await res; + + const classificationPage = page.waitForResponse( + `/api/v1/classifications*` + ); + await page.getByRole('link', { name: 'Classifications' }).click(); + await classificationPage; + } finally { + await tag.delete(apiContext); + await afterAction(); + } + }); + + test('Rename Tag name', async ({ page }) => { + await redirectToHomePage(page); + const { apiContext, afterAction } = await getApiContext(page); + const tag = new TagClass({ + classification: classification.data.name, + }); + try { + await tag.create(apiContext); + const res = page.waitForResponse(`/api/v1/tags/name/*`); + await tag.visitPage(page); + await res; + await page.getByTestId('manage-button').click(); + + await expect( + page.locator('.ant-dropdown-placement-bottomRight') + ).toBeVisible(); + + await page.getByRole('menuitem', { name: 'Rename' }).click(); + + await expect(page.getByRole('dialog')).toBeVisible(); + + await page.getByPlaceholder('Enter display name').fill('TestDisplayName'); + + const updateName = page.waitForResponse(`/api/v1/tags/*`); + await page.getByTestId('save-button').click(); + updateName; + + await expect(page.getByText('TestDisplayName')).toBeVisible(); + } finally { + await tag.delete(apiContext); + await afterAction(); + } + }); + + test('Restyle Tag', async ({ page }) => { + await redirectToHomePage(page); + const { apiContext, afterAction } = await getApiContext(page); + const tag = new TagClass({ + classification: classification.data.name, + }); + try { + await tag.create(apiContext); + const res = page.waitForResponse(`/api/v1/tags/name/*`); + await tag.visitPage(page); + await res; + await page.getByTestId('manage-button').click(); + + await expect( + page.locator('.ant-dropdown-placement-bottomRight') + ).toBeVisible(); + + await page.getByRole('menuitem', { name: 'Style' }).click(); + + await expect(page.getByRole('dialog')).toBeVisible(); + + await page.getByTestId('color-color-input').fill('#6366f1'); + + const updateColor = page.waitForResponse(`/api/v1/tags/*`); + await page.locator('button[type="submit"]').click(); + updateColor; + + await expect(page.getByText(tag.data.name)).toBeVisible(); + } finally { + await tag.delete(apiContext); + await afterAction(); + } + }); + + test('Edit Tag Description', async ({ page }) => { + await redirectToHomePage(page); + const { apiContext, afterAction } = await getApiContext(page); + const tag = new TagClass({ + classification: classification.data.name, + }); + try { + await tag.create(apiContext); + const res = page.waitForResponse(`/api/v1/tags/name/*`); + await tag.visitPage(page); + await res; + await page.getByTestId('edit-description').click(); + + await expect(page.getByRole('dialog')).toBeVisible(); + + await page.locator('.toastui-editor-pseudo-clipboard').clear(); + await page + .locator('.toastui-editor-pseudo-clipboard') + .fill(`This is updated test description for tag ${tag.data.name}.`); + + const editDescription = page.waitForResponse(`/api/v1/tags/*`); + await page.getByTestId('save').click(); + await editDescription; + + await expect(page.getByTestId('viewer-container')).toContainText( + `This is updated test description for tag ${tag.data.name}.` + ); + } finally { + await tag.delete(apiContext); + await afterAction(); + } + }); + + test('Delete a Tag', async ({ page }) => { + await redirectToHomePage(page); + const { apiContext, afterAction } = await getApiContext(page); + const tag = new TagClass({ + classification: classification.data.name, + }); + try { + await tag.create(apiContext); + const res = page.waitForResponse(`/api/v1/tags/name/*`); + await tag.visitPage(page); + await res; + await page.getByTestId('manage-button').click(); + + await expect( + page.locator('.ant-dropdown-placement-bottomRight') + ).toBeVisible(); + + await page.getByRole('menuitem', { name: 'Delete' }).click(); + + await expect(page.getByRole('dialog')).toBeVisible(); + + await page.getByTestId('confirmation-text-input').fill('DELETE'); + + const deleteTag = page.waitForResponse(`/api/v1/tags/*`); + await page.getByTestId('confirm-button').click(); + deleteTag; + + await expect( + page.getByText(classification.data.description) + ).toBeVisible(); + } finally { + await afterAction(); + } + }); + + test('Add and Remove Assets', async ({ page }) => { + await redirectToHomePage(page); + const { apiContext, afterAction } = await getApiContext(page); + const tag = new TagClass({ + classification: classification.data.name, + }); + const { assets } = await setupAssetsForTag(page); + try { + await tag.create(apiContext); + const res = page.waitForResponse(`/api/v1/tags/name/*`); + await tag.visitPage(page); + await res; + + await test.step('Add Asset', async () => { + await addAssetsToTag(page, assets); + + await expect( + page.locator('[role="dialog"].ant-modal') + ).not.toBeVisible(); + }); + + await test.step('Delete Asset', async () => { + await removeAssetsFromTag(page, assets); + await checkAssetsCount(page, 0); + }); + } finally { + await tag.delete(apiContext); + await afterAction(); + } + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Tags.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Tags.spec.ts index ac2bf3b088eb..3cfc7619465f 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Tags.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Tags.spec.ts @@ -373,39 +373,6 @@ test('Classification Page', async ({ page }) => { } ); - await test.step( - 'Should have correct tag usage count and redirection should work', - async () => { - const getTags = page.waitForResponse('/api/v1/tags*'); - await sidebarClick(page, SidebarItem.TAGS); - await getTags; - const classificationResponse = page.waitForResponse( - `/api/v1/tags?*parent=${encodeURIComponent(NEW_CLASSIFICATION.name)}*` - ); - await page - .locator(`[data-testid="side-panel-classification"]`) - .filter({ hasText: NEW_CLASSIFICATION.displayName }) - .click(); - await classificationResponse; - - await expect(page.locator('.activeCategory')).toContainText( - NEW_CLASSIFICATION.displayName - ); - - const count = await page - .locator('[data-testid="usage-count"]') - .textContent(); - - expect(count).toBe('1'); - - const getEntityDetailsPage = page.waitForResponse( - 'api/v1/search/query?q=&index=**' - ); - await page.click('[data-testid="usage-count"]'); - await getEntityDetailsPage; - } - ); - await test.step('Delete tag', async () => { const getTags = page.waitForResponse('/api/v1/tags*'); await sidebarClick(page, SidebarItem.TAGS); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/tag/TagClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/tag/TagClass.ts index 5f8fcaaca500..b58dd3310b95 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/tag/TagClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/tag/TagClass.ts @@ -68,6 +68,7 @@ export class TagClass { page, this.responseData.classification.displayName ); + await page.getByTestId(this.data.name).click(); } async create(apiContext: APIRequestContext) { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/tag.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/tag.ts index a3d13d81bed1..d88aeb599305 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/tag.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/tag.ts @@ -11,8 +11,14 @@ * limitations under the License. */ import { expect, Page } from '@playwright/test'; +import { get } from 'lodash'; import { SidebarItem } from '../constant/sidebar'; +import { DashboardClass } from '../support/entity/DashboardClass'; +import { EntityClass } from '../support/entity/EntityClass'; +import { TableClass } from '../support/entity/TableClass'; +import { TopicClass } from '../support/entity/TopicClass'; import { + getApiContext, NAME_MIN_MAX_LENGTH_VALIDATION_ERROR, NAME_VALIDATION_ERROR, redirectToHomePage, @@ -36,7 +42,88 @@ export const visitClassificationPage = async ( ); await sidebarClick(page, SidebarItem.TAGS); await classificationResponse; - await page.getByRole('menuitem', { name: classificationName }).click(); + await page + .locator(`[data-testid="side-panel-classification"]`) + .filter({ hasText: classificationName }) + .click(); + + await expect(page.locator('.activeCategory')).toContainText( + classificationName + ); +}; + +export const addAssetsToTag = async (page: Page, assets: EntityClass[]) => { + await page.getByTestId('assets').click(); + await page.getByTestId('data-classification-add-button').click(); + + await expect(page.getByRole('dialog')).toBeVisible(); + + for (const asset of assets) { + const name = get(asset, 'entityResponseData.name'); + const fqn = get(asset, 'entityResponseData.fullyQualifiedName'); + + const searchRes = page.waitForResponse( + `/api/v1/search/query?q=${name}&index=all&from=0&size=25&*` + ); + await page + .getByTestId('asset-selection-modal') + .getByTestId('searchbar') + .fill(name); + await searchRes; + + await page.locator(`[data-testid="table-data-card_${fqn}"] input`).check(); + } + + const assetsAddRes = page.waitForResponse(`/api/v1/tags/*/assets/add`); + await page.getByTestId('save-btn').click(); + await assetsAddRes; +}; + +export const removeAssetsFromTag = async ( + page: Page, + assets: EntityClass[] +) => { + for (const asset of assets) { + const fqn = get(asset, 'entityResponseData.fullyQualifiedName'); + await page.locator(`[data-testid="table-data-card_${fqn}"] input`).check(); + } + + const assetsRemoveRes = page.waitForResponse(`/api/v1/tags/*/assets/remove`); + + await page.getByTestId('delete-all-button').click(); + await assetsRemoveRes; +}; + +export const checkAssetsCount = async (page: Page, count: number) => { + await expect( + page.getByTestId('assets').getByTestId('filter-count') + ).toContainText(count.toString()); +}; + +export const setupAssetsForTag = async (page: Page) => { + const { afterAction, apiContext } = await getApiContext(page); + const table = new TableClass(); + const topic = new TopicClass(); + const dashboard = new DashboardClass(); + await Promise.all([ + table.create(apiContext), + topic.create(apiContext), + dashboard.create(apiContext), + ]); + + const assetCleanup = async () => { + await Promise.all([ + table.delete(apiContext), + topic.delete(apiContext), + dashboard.delete(apiContext), + ]); + await afterAction(); + }; + + return { + assets: [table, topic, dashboard], + assetCleanup, + }; }; export async function submitForm(page: Page) { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/AppRouter/AuthenticatedAppRouter.tsx b/openmetadata-ui/src/main/resources/ui/src/components/AppRouter/AuthenticatedAppRouter.tsx index 1f7258468514..64713a58cc20 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/AppRouter/AuthenticatedAppRouter.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/AppRouter/AuthenticatedAppRouter.tsx @@ -24,6 +24,7 @@ import AddCustomMetricPage from '../../pages/AddCustomMetricPage/AddCustomMetric import { CustomizablePage } from '../../pages/CustomizablePage/CustomizablePage'; import DataQualityPage from '../../pages/DataQuality/DataQualityPage'; import ForbiddenPage from '../../pages/ForbiddenPage/ForbiddenPage'; +import TagPage from '../../pages/TagPage/TagPage'; import { checkPermission, userPermissions } from '../../utils/PermissionsUtils'; import AdminProtectedRoute from './AdminProtectedRoute'; import withSuspenseFallback from './withSuspenseFallback'; @@ -505,6 +506,11 @@ const AuthenticatedAppRouter: FunctionComponent = () => { /> + { const { theme } = useApplicationStore(); const { t } = useTranslation(); - const ES_UPDATE_DELAY = 500; const [search, setSearch] = useState(''); const [items, setItems] = useState([]); const [failedStatus, setFailedStatus] = useState(); + const [exportJob, setExportJob] = useState>(); const [selectedItems, setSelectedItems] = useState>(); const [isLoading, setIsLoading] = useState(false); const [activeFilter, setActiveFilter] = useState( type === AssetsOfEntity.GLOSSARY ? SearchIndex.DATA_ASSET : SearchIndex.ALL ); - const [activeEntity, setActiveEntity] = useState(); + const [activeEntity, setActiveEntity] = useState< + Domain | DataProduct | Tag + >(); const [pageNumber, setPageNumber] = useState(1); const [totalCount, setTotalCount] = useState(0); const [isSaveLoading, setIsSaveLoading] = useState(false); + const [assetJobResponse, setAssetJobResponse] = useState(); const [aggregations, setAggregations] = useState(); const [selectedQuickFilters, setSelectedQuickFilters] = useState< ExploreQuickFilterField[] @@ -126,6 +142,8 @@ export const AssetSelectionModal = ({ const [selectedFilter, setSelectedFilter] = useState([]); const [filters, setFilters] = useState([]); + const { socket } = useWebSocketConnector(); + const handleMenuClick = ({ key }: { key: string }) => { setSelectedFilter((prevSelected) => [...prevSelected, key]); }; @@ -170,20 +188,40 @@ export const AssetSelectionModal = ({ ); const fetchCurrentEntity = useCallback(async () => { - if (type === AssetsOfEntity.DOMAIN) { - const data = await getDomainByName(entityFqn); - setActiveEntity(data); - } else if (type === AssetsOfEntity.DATA_PRODUCT) { - const data = await getDataProductByName(entityFqn, { - fields: [TabSpecificField.DOMAIN, TabSpecificField.ASSETS], - }); - setActiveEntity(data); - } else if (type === AssetsOfEntity.GLOSSARY) { - const data = await getGlossaryTermByFQN(entityFqn, { - fields: TabSpecificField.TAGS, - }); - setActiveEntity(data); + let data: GlossaryTerm | Tag | Domain | DataProduct | undefined; + + switch (type) { + case AssetsOfEntity.DOMAIN: + data = await getDomainByName(entityFqn); + + break; + + case AssetsOfEntity.DATA_PRODUCT: + data = await getDataProductByName(entityFqn, { + fields: [TabSpecificField.DOMAIN, TabSpecificField.ASSETS], + }); + + break; + + case AssetsOfEntity.GLOSSARY: + data = await getGlossaryTermByFQN(entityFqn, { + fields: TabSpecificField.TAGS, + }); + + break; + + case AssetsOfEntity.TAG: + data = await getTagByFqn(entityFqn); + + break; + + default: + data = undefined; + + break; } + + setActiveEntity(data); }, [type, entityFqn]); useEffect(() => { @@ -273,6 +311,11 @@ export const AssetSelectionModal = ({ entities ); + break; + + case AssetsOfEntity.TAG: + res = await addAssetsToTags(activeEntity.id ?? '', entities); + break; case AssetsOfEntity.DOMAIN: res = await addAssetsToDomain( @@ -286,16 +329,22 @@ export const AssetSelectionModal = ({ break; } - if ((res as BulkOperationResult).status === Status.Success) { - await new Promise((resolve) => { - setTimeout(() => { - resolve(''); - onSave?.(); - }, ES_UPDATE_DELAY); - }); - onCancel(); + // check if res has jobId property + if (isUndefined((res as CSVExportResponse).jobId)) { + if ((res as BulkOperationResult).status === Status.Success) { + await new Promise((resolve) => { + setTimeout(() => { + resolve(''); + onSave?.(); + }, ES_UPDATE_DELAY); + }); + onCancel(); + } else { + setFailedStatus(res as BulkOperationResult); + } } else { - setFailedStatus(res as BulkOperationResult); + // handle websocket response + setAssetJobResponse(res as CSVExportResponse); } } catch (err) { showErrorToast(err as AxiosError); @@ -452,6 +501,32 @@ export const AssetSelectionModal = ({ }); }, [setQuickFilterQuery, handleQuickFiltersChange, setSelectedQuickFilters]); + useEffect(() => { + if (socket) { + socket.on(SOCKET_EVENTS.BULK_ASSETS_CHANNEL, (newActivity) => { + if (newActivity) { + const activity = JSON.parse(newActivity); + if (activity.status === 'COMPLETED') { + setAssetJobResponse(undefined); + if (activity.result.status === 'success') { + onSave?.(); + onCancel(); + } else { + setFailedStatus(activity.result); + } + } else if (activity.status === 'FAILED') { + setExportJob(activity); + setAssetJobResponse(undefined); + } + } + }); + } + + return () => { + socket && socket.off(SOCKET_EVENTS.BULK_ASSETS_CHANNEL); + }; + }, [socket]); + return ( {t('label.save')} @@ -501,6 +576,15 @@ export const AssetSelectionModal = ({ width={675} onCancel={onCancel}> + {(assetJobResponse || exportJob?.error) && ( + + )} +
(); const [activeEntity, setActiveEntity] = useState< - Domain | DataProduct | GlossaryTerm + Domain | DataProduct | GlossaryTerm | Tag >(); const [selectedItems, setSelectedItems] = useState< @@ -177,12 +181,8 @@ const AssetsTabs = forwardRef( >([]); const [filters, setFilters] = useState([]); const [searchValue, setSearchValue] = useState(''); - const entityTypeString = - type === AssetsOfEntity.GLOSSARY - ? t('label.glossary-term-lowercase') - : type === AssetsOfEntity.DOMAIN - ? t('label.domain-lowercase') - : t('label.data-product-lowercase'); + + const entityTypeString = getEntityTypeString(type); const handleMenuClick = ({ key }: { key: string }) => { setSelectedFilter((prevSelected) => [...prevSelected, key]); @@ -311,6 +311,11 @@ const AssetsTabs = forwardRef( case AssetsOfEntity.GLOSSARY: data = await getGlossaryTermByFQN(fqn); + break; + + case AssetsOfEntity.TAG: + data = await getTagByFqn(fqn); + break; default: break; @@ -417,6 +422,11 @@ const AssetsTabs = forwardRef( break; + case AssetsOfEntity.TAG: + await removeAssetsFromTags(activeEntity.id ?? '', entities); + + break; + case AssetsOfEntity.DOMAIN: await removeAssetsFromDomain( activeEntity.fullyQualifiedName ?? '', @@ -900,7 +910,7 @@ const AssetsTabs = forwardRef( {!(isLoading || isCountLoading) && (
1, + visible: selectedItems.size > 0, })}>
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/AssetsTabs.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/AssetsTabs.interface.ts index aed1d532ae3e..41c39355603a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/AssetsTabs.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/AssetsTabs.interface.ts @@ -23,6 +23,7 @@ export enum AssetsOfEntity { FOLLOWING = 'FOLLOWING', ACCESS_TOKEN = 'ACCESS_TOKEN', LINEAGE = 'LINEAGE', + TAG = 'TAG', } export interface AssetsTabsProps { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Tag/TagsV1/TagsV1.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Tag/TagsV1/TagsV1.component.tsx index cc4a57f41a05..1eba4884903e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Tag/TagsV1/TagsV1.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Tag/TagsV1/TagsV1.component.tsx @@ -22,8 +22,10 @@ import { TAG_START_WITH } from '../../../constants/Tag.constants'; import { TagSource } from '../../../generated/type/tagLabel'; import { reduceColorOpacity } from '../../../utils/CommonUtils'; import { getEntityName } from '../../../utils/EntityUtils'; -import Fqn from '../../../utils/Fqn'; -import { getGlossaryPath, getTagPath } from '../../../utils/RouterUtils'; +import { + getClassificationTagPath, + getGlossaryPath, +} from '../../../utils/RouterUtils'; import { getTagDisplay, getTagTooltip } from '../../../utils/TagsUtils'; import { HighlightedTagLabel } from '../../Explore/EntitySummaryPanel/SummaryList/SummaryList.interface'; import { TagsV1Props } from './TagsV1.interface'; @@ -90,7 +92,7 @@ const TagsV1 = ({ () => (tagType ?? tag.source) === TagSource.Glossary ? getGlossaryPath(tag.tagFQN) - : getTagPath(Fqn.split(tag.tagFQN)[0]), + : getClassificationTagPath(tag.tagFQN), [tagType, tag.source, tag.tagFQN] ); diff --git a/openmetadata-ui/src/main/resources/ui/src/constants/constants.ts b/openmetadata-ui/src/main/resources/ui/src/constants/constants.ts index 80228b14ea1f..591d9de78939 100644 --- a/openmetadata-ui/src/main/resources/ui/src/constants/constants.ts +++ b/openmetadata-ui/src/main/resources/ui/src/constants/constants.ts @@ -169,6 +169,8 @@ export const ROUTES = { SWAGGER: '/docs', TAGS: '/tags', TAG_DETAILS: `/tags/${PLACEHOLDER_ROUTE_FQN}`, + TAG_ITEM: `/tag/${PLACEHOLDER_ROUTE_FQN}`, + TAG_ITEM_WITH_TAB: `/tag/${PLACEHOLDER_ROUTE_FQN}/${PLACEHOLDER_ROUTE_TAB}`, TAG_VERSION: `/tags/${PLACEHOLDER_ROUTE_FQN}/versions/${PLACEHOLDER_ROUTE_VERSION}`, SIGNUP: '/signup', REGISTER: '/register', @@ -298,6 +300,7 @@ export const SOCKET_EVENTS = { CSV_EXPORT_CHANNEL: 'csvExportChannel', SEARCH_INDEX_JOB_BROADCAST_CHANNEL: 'searchIndexJobStatus', DATA_INSIGHTS_JOB_BROADCAST_CHANNEL: 'dataInsightsJobStatus', + BULK_ASSETS_CHANNEL: 'bulkAssetsChannel', }; export const IN_PAGE_SEARCH_ROUTES: Record> = { diff --git a/openmetadata-ui/src/main/resources/ui/src/mocks/Tags.mock.ts b/openmetadata-ui/src/main/resources/ui/src/mocks/Tags.mock.ts index 93555a0c10b1..3fc128b825b7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/mocks/Tags.mock.ts +++ b/openmetadata-ui/src/main/resources/ui/src/mocks/Tags.mock.ts @@ -11,8 +11,17 @@ * limitations under the License. */ +import { OperationPermission } from '../context/PermissionProvider/PermissionProvider.interface'; + export const MOCK_TAG_ENCODED_FQN = '"%22Mock.Tag%22.Tag_1"'; +export const MOCK_TAG_PERMISSIONS = { + Create: true, + Delete: true, + EditAll: true, + ViewAll: true, +} as OperationPermission; + export const mockTagList = [ { id: 'e649c601-44d3-449d-bc04-fbbaf83baf19', diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.inteface.ts b/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.inteface.ts new file mode 100644 index 000000000000..d0e1a2e03ab0 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.inteface.ts @@ -0,0 +1,16 @@ +/* + * 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. + */ +export enum TagTabs { + OVERVIEW = 'overview', + ASSETS = 'assets', +} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.tsx new file mode 100644 index 000000000000..467d5d6e26a1 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.tsx @@ -0,0 +1,701 @@ +/* + * 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 { + Button, + Col, + Divider, + Dropdown, + Row, + Space, + Tabs, + Tooltip, +} from 'antd'; +import { ItemType } from 'antd/lib/menu/hooks/useItems'; +import { AxiosError } from 'axios'; +import { compare } from 'fast-json-patch'; +import { cloneDeep } from 'lodash'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { useHistory, useParams } from 'react-router-dom'; +import { ReactComponent as IconTag } from '../../assets/svg/classification.svg'; +import { ReactComponent as EditIcon } from '../../assets/svg/edit-new.svg'; +import { ReactComponent as IconDelete } from '../../assets/svg/ic-delete.svg'; +import { ReactComponent as IconDropdown } from '../../assets/svg/menu.svg'; +import { ReactComponent as StyleIcon } from '../../assets/svg/style.svg'; +import { DomainLabel } from '../../components/common/DomainLabel/DomainLabel.component'; +import DescriptionV1 from '../../components/common/EntityDescription/DescriptionV1'; +import ErrorPlaceHolder from '../../components/common/ErrorWithPlaceholder/ErrorPlaceHolder'; +import Loader from '../../components/common/Loader/Loader'; +import { ManageButtonItemLabel } from '../../components/common/ManageButtonContentItem/ManageButtonContentItem.component'; +import ResizablePanels from '../../components/common/ResizablePanels/ResizablePanels'; +import StatusBadge from '../../components/common/StatusBadge/StatusBadge.component'; +import { StatusType } from '../../components/common/StatusBadge/StatusBadge.interface'; +import TabsLabel from '../../components/common/TabsLabel/TabsLabel.component'; +import { TitleBreadcrumbProps } from '../../components/common/TitleBreadcrumb/TitleBreadcrumb.interface'; +import { AssetSelectionModal } from '../../components/DataAssets/AssetsSelectionModal/AssetSelectionModal'; +import { EntityHeader } from '../../components/Entity/EntityHeader/EntityHeader.component'; +import EntitySummaryPanel from '../../components/Explore/EntitySummaryPanel/EntitySummaryPanel.component'; +import { EntityDetailsObjectInterface } from '../../components/Explore/ExplorePage.interface'; +import AssetsTabs, { + AssetsTabRef, +} from '../../components/Glossary/GlossaryTerms/tabs/AssetsTabs.component'; +import { AssetsOfEntity } from '../../components/Glossary/GlossaryTerms/tabs/AssetsTabs.interface'; +import EntityDeleteModal from '../../components/Modals/EntityDeleteModal/EntityDeleteModal'; +import EntityNameModal from '../../components/Modals/EntityNameModal/EntityNameModal.component'; +import StyleModal from '../../components/Modals/StyleModal/StyleModal.component'; +import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1'; +import { + BLACK_COLOR, + DE_ACTIVE_COLOR, + ROUTES, +} from '../../constants/constants'; +import { TAGS_DOCS } from '../../constants/docs.constants'; +import { COMMON_RESIZABLE_PANEL_CONFIG } from '../../constants/ResizablePanel.constants'; +import { usePermissionProvider } from '../../context/PermissionProvider/PermissionProvider'; +import { + OperationPermission, + ResourceEntity, +} from '../../context/PermissionProvider/PermissionProvider.interface'; +import { ERROR_PLACEHOLDER_TYPE } from '../../enums/common.enum'; +import { EntityType, TabSpecificField } from '../../enums/entity.enum'; +import { SearchIndex } from '../../enums/search.enum'; +import { ProviderType, Tag } from '../../generated/entity/classification/tag'; +import { Style } from '../../generated/type/tagLabel'; +import { useFqn } from '../../hooks/useFqn'; +import { MOCK_TAG_PERMISSIONS } from '../../mocks/Tags.mock'; +import { searchData } from '../../rest/miscAPI'; +import { deleteTag, getTagByFqn, patchTag } from '../../rest/tagAPI'; +import { getEntityDeleteMessage } from '../../utils/CommonUtils'; +import { getEntityName } from '../../utils/EntityUtils'; +import { DEFAULT_ENTITY_PERMISSION } from '../../utils/PermissionsUtils'; +import { + getClassificationDetailsPath, + getClassificationTagPath, +} from '../../utils/RouterUtils'; +import { + escapeESReservedCharacters, + getEncodedFqn, +} from '../../utils/StringsUtils'; +import { getQueryFilterToExcludeTerms } from '../../utils/TagsUtils'; +import { showErrorToast, showSuccessToast } from '../../utils/ToastUtils'; +import './tag-page.less'; +import { TagTabs } from './TagPage.inteface'; + +const TagPage = () => { + const { t } = useTranslation(); + const { fqn: tagFqn } = useFqn(); + const history = useHistory(); + const { tab: activeTab = TagTabs.OVERVIEW } = useParams<{ tab?: string }>(); + const { getEntityPermission } = usePermissionProvider(); + const [isLoading, setIsLoading] = useState(false); + const [tagItem, setTagItem] = useState(); + const [assetModalVisible, setAssetModalVisible] = useState(false); + const [isDescriptionEditable, setIsDescriptionEditable] = + useState(false); + const [isNameEditing, setIsNameEditing] = useState(false); + const [isStyleEditing, setIsStyleEditing] = useState(false); + const [isDelete, setIsDelete] = useState(false); + const [showActions, setShowActions] = useState(false); + const [assetCount, setAssetCount] = useState(0); + const [tagPermissions, setTagPermissions] = useState( + DEFAULT_ENTITY_PERMISSION + ); + const assetTabRef = useRef(null); + const [previewAsset, setPreviewAsset] = + useState(); + const breadcrumb: TitleBreadcrumbProps['titleLinks'] = useMemo(() => { + return tagItem + ? [ + { + name: 'Classifications', + url: ROUTES.TAGS, + activeTitle: false, + }, + { + name: tagItem.classification?.name ?? '', + url: tagItem.classification?.fullyQualifiedName + ? getClassificationDetailsPath( + tagItem.classification.fullyQualifiedName + ) + : '', + activeTitle: false, + }, + ] + : []; + }, [tagItem]); + + const handleAssetClick = useCallback((asset) => { + setPreviewAsset(asset); + }, []); + + const { editTagsPermission, editDescriptionPermission } = useMemo(() => { + if (tagItem) { + const isEditable = !tagItem.disabled && !tagItem.deleted; + + return { + editTagsPermission: + isEditable && (tagPermissions.EditTags || tagPermissions.EditAll), + editDescriptionPermission: + isEditable && + (tagPermissions.EditDescription || + tagPermissions.EditAll || + tagPermissions.EditTags), + }; + } + + return { editTagsPermission: false, editDescriptionPermission: false }; + }, [tagPermissions, tagItem?.deleted]); + + const fetchCurrentTagPermission = async () => { + if (!tagItem?.id) { + return; + } + try { + const response = await getEntityPermission( + ResourceEntity.TAG, + tagItem?.id + ); + setTagPermissions(response); + } catch (error) { + showErrorToast(error as AxiosError); + } + }; + + const onDescriptionUpdate = async (updatedHTML?: string) => { + if (tagItem) { + if (tagItem.description !== updatedHTML) { + const updatedTableDetails = { + ...tagItem, + description: updatedHTML, + }; + const jsonPatch = compare(tagItem, updatedTableDetails); + try { + const response = await patchTag(tagItem.id ?? '', jsonPatch); + + setTagItem(response); + } catch (error) { + showErrorToast(error as AxiosError); + } + setIsDescriptionEditable(false); + } else { + setIsDescriptionEditable(false); + } + } + }; + + const getTagData = async () => { + try { + setIsLoading(true); + if (tagFqn) { + const response = await getTagByFqn(tagFqn, { + fields: TabSpecificField.DOMAIN, + }); + setTagItem(response); + } + } catch (e) { + showErrorToast(e as AxiosError); + } finally { + setIsLoading(false); + } + }; + + const activeTabHandler = (tab: string) => { + if (tagItem) { + history.push({ + pathname: getClassificationTagPath( + tagItem.fullyQualifiedName ?? '', + tab + ), + }); + } + }; + + const updateTag = async (updatedData: Tag) => { + if (tagItem) { + const jsonPatch = compare(tagItem, updatedData); + + try { + const response = await patchTag(tagItem.id ?? '', jsonPatch); + + setTagItem(response); + } catch (error) { + showErrorToast(error as AxiosError); + } + } + }; + + const onNameSave = async (obj: Tag) => { + if (tagItem) { + const { displayName } = obj; + let updatedDetails = cloneDeep(tagItem); + + updatedDetails = { + ...tagItem, + displayName: displayName?.trim(), + }; + + await updateTag(updatedDetails); + setIsNameEditing(false); + } + }; + + const onStyleSave = async (data: Style) => { + if (tagItem) { + const style: Style = { + color: data.color ?? '', + iconURL: data.iconURL ?? '', + }; + + const updatedDetails = { + ...tagItem, + style, + }; + + await updateTag(updatedDetails); + setIsStyleEditing(false); + } + }; + + const handleTagDelete = async (id: string) => { + try { + await deleteTag(id); + showSuccessToast( + t('server.entity-deleted-successfully', { + entity: t('label.tag-lowercase'), + }) + ); + setIsLoading(true); + + if (tagItem?.classification?.fullyQualifiedName) { + history.push( + getClassificationDetailsPath( + tagItem.classification.fullyQualifiedName + ) + ); + } + } catch (error) { + showErrorToast( + error as AxiosError, + t('server.delete-entity-error', { + entity: t('label.glossary'), + }) + ); + } + }; + + const handleDelete = async () => { + if (tagItem?.id) { + await handleTagDelete(tagItem.id); + setIsDelete(false); + } + }; + + const handleAddTagClick = () => { + history.push(ROUTES.TAGS); + }; + + const fetchClassificationTagAssets = async () => { + try { + const encodedFqn = getEncodedFqn(escapeESReservedCharacters(tagFqn)); + const res = await searchData( + '', + 1, + 0, + `(tags.tagFQN:"${encodedFqn}")`, + '', + '', + SearchIndex.ALL + ); + + setAssetCount(res.data.hits.total.value ?? 0); + if (res.data.hits.total.value === 0) { + setPreviewAsset(undefined); + } + } catch (error) { + setAssetCount(0); + } + }; + + const handleAssetSave = useCallback(() => { + fetchClassificationTagAssets(); + assetTabRef.current?.refreshAssets(); + activeTab !== TagTabs.ASSETS && activeTabHandler(TagTabs.ASSETS); + }, [assetTabRef]); + + const manageButtonContent: ItemType[] = [ + ...(editTagsPermission + ? [ + { + label: ( + + ), + key: 'rename-button', + onClick: (e: { domEvent: { stopPropagation: () => void } }) => { + e.domEvent.stopPropagation(); + setIsNameEditing(true); + setShowActions(false); + }, + }, + { + label: ( + + ), + key: 'edit-style-button', + onClick: (e: { domEvent: { stopPropagation: () => void } }) => { + e.domEvent.stopPropagation(); + setIsStyleEditing(true); + setShowActions(false); + }, + }, + ] + : []), + ...(tagItem?.provider !== ProviderType.System && tagPermissions.EditAll + ? [ + { + label: ( + + ), + key: 'delete-button', + onClick: (e: { domEvent: { stopPropagation: () => void } }) => { + e.domEvent.stopPropagation(); + setIsDelete(true); + setShowActions(false); + }, + }, + ] + : []), + ]; + + const tabItems = useMemo(() => { + const items = [ + { + label: , + key: 'overview', + children: ( + + + + setIsDescriptionEditable(false)} + onDescriptionEdit={() => setIsDescriptionEditable(true)} + onDescriptionUpdate={onDescriptionUpdate} + /> + + +
+ ), + ...COMMON_RESIZABLE_PANEL_CONFIG.LEFT_PANEL, + }} + secondPanel={{ + children: tagItem ? ( + + ) : null, + ...COMMON_RESIZABLE_PANEL_CONFIG.RIGHT_PANEL, + className: + 'entity-resizable-right-panel-container tag-resizable-panel-container', + }} + /> + ), + }, + { + label: ( + + ), + key: 'assets', + children: ( + setAssetModalVisible(true)} + onAssetClick={handleAssetClick} + onRemoveAsset={handleAssetSave} + /> + ), + ...COMMON_RESIZABLE_PANEL_CONFIG.LEFT_PANEL, + }} + hideSecondPanel={!previewAsset} + secondPanel={{ + children: previewAsset && ( + setPreviewAsset(undefined)} + /> + ), + ...COMMON_RESIZABLE_PANEL_CONFIG.RIGHT_PANEL, + className: + 'entity-summary-resizable-right-panel-container tag-resizable-panel-container', + }} + /> + ), + }, + ]; + + return items; + }, [ + tagItem, + previewAsset, + activeTab, + assetCount, + assetTabRef, + handleAssetSave, + isDescriptionEditable, + editTagsPermission, + editDescriptionPermission, + ]); + const icon = useMemo(() => { + if (tagItem?.style?.iconURL) { + return ( + {tagItem.name + ); + } + + return ; + }, [tagItem]); + + useEffect(() => { + getTagData(); + fetchClassificationTagAssets(); + }, []); + + useEffect(() => { + if (tagItem) { + fetchCurrentTagPermission(); + } + }, [tagItem]); + + if (isLoading) { + return ; + } + + if (!tagItem) { + return ( + + ); + } + + return ( + + + + + + + + + + ) + } + breadcrumb={breadcrumb} + entityData={tagItem} + entityType={EntityType.TAG} + icon={icon} + serviceName={tagItem.name} + titleColor={tagItem.style?.color ?? BLACK_COLOR} + /> + + {editTagsPermission && ( + +
+ + {manageButtonContent.length > 0 && ( + + +
+ + )} +
+ + + + + +
+ + setIsDelete(false)} + onConfirm={handleDelete} + /> + + setIsNameEditing(false)} + onSave={onNameSave} + /> + setIsStyleEditing(false)} + onSubmit={onStyleSave} + /> + {tagItem.fullyQualifiedName && assetModalVisible && ( + setAssetModalVisible(false)} + onSave={handleAssetSave} + /> + )} +
+ ); +}; + +export default TagPage; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/tag-page.less b/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/tag-page.less new file mode 100644 index 000000000000..72aaf93140e3 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/tag-page.less @@ -0,0 +1,26 @@ +/* + * 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 url('../../styles/variables.less'); + +.tag-tabs { + .tag-overview-tab, + .assets-tab-container { + height: @tag-page-height; + overflow-y: scroll; + } + + .ant-tabs-nav { + margin: 0 !important; + padding: 0 16px; + } +} diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/tagAPI.ts b/openmetadata-ui/src/main/resources/ui/src/rest/tagAPI.ts index 6373db36eb16..b8cb4af4a2d2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/tagAPI.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/tagAPI.ts @@ -14,11 +14,13 @@ import { AxiosResponse } from 'axios'; import { Operation } from 'fast-json-patch'; import { PagingResponse } from 'Models'; +import { CSVExportResponse } from '../components/Entity/EntityExportModalProvider/EntityExportModalProvider.interface'; import { PAGE_SIZE } from '../constants/constants'; +import { AddTagToAssetsRequest } from '../generated/api/addTagToAssetsRequest'; import { CreateClassification } from '../generated/api/classification/createClassification'; import { CreateTag } from '../generated/api/classification/createTag'; import { Classification } from '../generated/entity/classification/classification'; -import { Tag } from '../generated/entity/classification/tag'; +import { EntityReference, Tag } from '../generated/entity/classification/tag'; import { EntityHistory } from '../generated/type/entityHistory'; import { ListParams } from '../interface/API.interface'; import { getEncodedFqn } from '../utils/StringsUtils'; @@ -56,6 +58,41 @@ export const getAllClassifications = async (params?: ListParams) => { return response.data; }; +export const addAssetsToTags = async ( + tagId: string, + assets: EntityReference[], + dryRun = false +) => { + const data = { + assets: assets, + dryRun: dryRun, + }; + + const response = await APIClient.put< + AddTagToAssetsRequest, + AxiosResponse + >(`/tags/${tagId}/assets/add`, data); + + return response.data; +}; + +export const removeAssetsFromTags = async ( + tagId: string, + assets: EntityReference[] +) => { + const data = { + assets: assets, + dryRun: false, + }; + + const response = await APIClient.put< + AddTagToAssetsRequest, + AxiosResponse + >(`/tags/${tagId}/assets/remove`, data); + + return response.data; +}; + export const getClassificationByName = async ( name: string, params?: ListParams diff --git a/openmetadata-ui/src/main/resources/ui/src/styles/components/resizable-panels-component.less b/openmetadata-ui/src/main/resources/ui/src/styles/components/resizable-panels-component.less index 8fed625f2af7..65404a7e1f9d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/styles/components/resizable-panels-component.less +++ b/openmetadata-ui/src/main/resources/ui/src/styles/components/resizable-panels-component.less @@ -92,3 +92,14 @@ overflow-y: scroll; } } + +// resizable panel in tag page +.tag-height-with-resizable-panel { + height: @tag-page-height; + overflow-y: hidden; + + .tag-resizable-panel-container { + height: @tag-page-height; + overflow-y: scroll; + } +} diff --git a/openmetadata-ui/src/main/resources/ui/src/styles/variables.less b/openmetadata-ui/src/main/resources/ui/src/styles/variables.less index 16954b63af28..e1f685b0f902 100644 --- a/openmetadata-ui/src/main/resources/ui/src/styles/variables.less +++ b/openmetadata-ui/src/main/resources/ui/src/styles/variables.less @@ -129,6 +129,9 @@ // 136px - navbar height @glossary-term-page-height: calc(100vh - 136px - @om-navbar-height); +// 136px - navbar height +@tag-page-height: calc(100vh - 136px - @om-navbar-height); + // Navbar height - 49px for filter bar on explore page @explore-page-height: calc(100vh - @om-navbar-height - 49px); @welcome-page-height: calc(100vh - 112px); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/Assets/AssetsUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/Assets/AssetsUtils.ts index 20b09b2bf798..76720315c557 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/Assets/AssetsUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/Assets/AssetsUtils.ts @@ -12,6 +12,7 @@ */ import { AxiosError } from 'axios'; import { compare, Operation } from 'fast-json-patch'; +import { t } from 'i18next'; import { EntityDetailUnion } from 'Models'; import { MapPatchAPIResponse } from '../../components/DataAssets/AssetsSelectionModal/AssetSelectionModal.interface'; import { AssetsOfEntity } from '../../components/Glossary/GlossaryTerms/tabs/AssetsTabs.interface'; @@ -67,6 +68,7 @@ import { patchStoredProceduresDetails, } from '../../rest/storedProceduresAPI'; import { getTableDetailsByFQN, patchTableDetails } from '../../rest/tableAPI'; +import { getTagByFqn, patchTag } from '../../rest/tagAPI'; import { getTeamByName, patchTeamDetail } from '../../rest/teamsAPI'; import { getTopicByFqn, patchTopicDetails } from '../../rest/topicsAPI'; import { getUserByName, updateUserDetail } from '../../rest/userAPI'; @@ -102,6 +104,8 @@ export const getAPIfromSource = ( return patchGlossaryTerm; case EntityType.GLOSSARY: return patchGlossaries; + case EntityType.TAG: + return patchTag; case EntityType.DATABASE_SCHEMA: return patchDatabaseSchemaDetails; case EntityType.DATABASE: @@ -159,6 +163,8 @@ export const getEntityAPIfromSource = ( return getGlossaryTermByFQN; case EntityType.GLOSSARY: return getGlossariesByName; + case EntityType.TAG: + return getTagByFqn; case EntityType.DATABASE_SCHEMA: return getDatabaseSchemaDetailsByFQN; case EntityType.DATABASE: @@ -224,6 +230,19 @@ const getJsonPatchObject = (entity: Table, activeEntity: Domain) => { return jsonPatch; }; +export function getEntityTypeString(type: string) { + switch (type) { + case AssetsOfEntity.GLOSSARY: + return t('label.glossary-term-lowercase'); + case AssetsOfEntity.DOMAIN: + return t('label.domain-lowercase'); + case AssetsOfEntity.TAG: + return t('label.tag-lowercase'); + default: + return t('label.data-product-lowercase'); + } +} + export const updateDomainAssets = async ( activeEntity: EntityDetailUnion | undefined, type: AssetsOfEntity, diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/ClassificationUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/ClassificationUtils.tsx index 26859364f7cc..ad8641ba1a08 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/ClassificationUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ClassificationUtils.tsx @@ -25,7 +25,8 @@ import { OperationPermission } from '../context/PermissionProvider/PermissionPro import { ProviderType } from '../generated/entity/bot'; import { Tag } from '../generated/entity/classification/tag'; import { DeleteTagsType } from '../pages/TagsPage/TagsPage.interface'; -import { getDeleteIcon, getUsageCountLink } from './TagsUtils'; +import { getClassificationTagPath } from './RouterUtils'; +import { getDeleteIcon } from './TagsUtils'; export const getDeleteButtonData = ( record: Tag, @@ -60,11 +61,13 @@ export const getCommonColumns = (): ColumnsType => [ {record.style?.iconURL && ( )} - + data-testid={record.name} + style={{ color: record.style?.color }} + to={getClassificationTagPath(record.fullyQualifiedName ?? '')}> {record.name} - + {record.disabled ? ( => [ title: t('label.description'), dataIndex: 'description', key: 'description', - render: (text: string, record: Tag) => ( + render: (text: string) => ( <>
@@ -103,19 +106,6 @@ export const getCommonColumns = (): ColumnsType => [ )}
- - {`${t('label.usage')}:`} - {record.usageCount ? ( - - {record.usageCount} - - ) : ( - {t('label.not-used')} - )} - ), }, diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/EntityUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/EntityUtils.tsx index e7dd942d7027..c88ac60c9afb 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/EntityUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/EntityUtils.tsx @@ -68,6 +68,7 @@ import { ServiceCategory, ServiceCategoryPlural } from '../enums/service.enum'; import { PrimaryTableDataTypes } from '../enums/table.enum'; import { Kpi } from '../generated/dataInsight/kpi/kpi'; import { Classification } from '../generated/entity/classification/classification'; +import { Tag } from '../generated/entity/classification/tag'; import { APICollection } from '../generated/entity/data/apiCollection'; import { APIEndpoint } from '../generated/entity/data/apiEndpoint'; import { Chart } from '../generated/entity/data/chart'; @@ -122,6 +123,7 @@ import { BasicEntityOverviewInfo } from './EntityUtils.interface'; import Fqn from './Fqn'; import { getApplicationDetailsPath, + getClassificationTagPath, getDataQualityPagePath, getDomainDetailsPath, getDomainPath, @@ -1524,6 +1526,7 @@ export const getEntityLinkFromType = ( case EntityType.GLOSSARY_TERM: return getGlossaryTermDetailsPath(fullyQualifiedName); case EntityType.TAG: + return getClassificationTagPath(fullyQualifiedName); case EntityType.CLASSIFICATION: return getTagsDetailsPath(fullyQualifiedName); @@ -1892,18 +1895,20 @@ export const getEntityBreadcrumbs = ( ), })), ]; - case EntityType.TAG: - // eslint-disable-next-line no-case-declarations - const fqnTagList = entity.fullyQualifiedName - ? Fqn.split(entity.fullyQualifiedName) - : []; - + case EntityType.TAG: { return [ - ...fqnTagList.map((fqn) => ({ - name: fqn, - url: getTagsDetailsPath(entity?.fullyQualifiedName ?? ''), - })), + { + name: getEntityName((entity as Tag).classification), + url: getTagsDetailsPath( + (entity as Tag).classification?.fullyQualifiedName ?? '' + ), + }, + { + name: entity.name, + url: getClassificationTagPath(entity.fullyQualifiedName ?? ''), + }, ]; + } case EntityType.CLASSIFICATION: return [ diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/RouterUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/RouterUtils.ts index a31de9a4dd89..4226f62c3f27 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/RouterUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/RouterUtils.ts @@ -557,6 +557,17 @@ export const getClassificationDetailsPath = (classificationFqn: string) => { return path; }; +export const getClassificationTagPath = (tagFqn: string, tab?: string) => { + let path = tab ? ROUTES.TAG_ITEM_WITH_TAB : ROUTES.TAG_ITEM; + + if (tab) { + path = path.replace(PLACEHOLDER_ROUTE_TAB, tab); + } + path = path.replace(PLACEHOLDER_ROUTE_FQN, getEncodedFqn(tagFqn)); + + return path; +}; + export const getClassificationVersionsPath = ( classificationFqn: string, version: string diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/TagsUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/TagsUtils.tsx index f397b6cd1dec..2a54c6ad35ad 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/TagsUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/TagsUtils.tsx @@ -25,6 +25,7 @@ import RichTextEditorPreviewer from '../components/common/RichTextEditor/RichTex import { FQN_SEPARATOR_CHAR } from '../constants/char.constants'; import { getExplorePath } from '../constants/constants'; import { SettledStatus } from '../enums/Axios.enum'; +import { EntityType } from '../enums/entity.enum'; import { ExplorePageTabs } from '../enums/Explore.enum'; import { SearchIndex } from '../enums/search.enum'; import { Classification } from '../generated/entity/classification/classification'; @@ -316,3 +317,39 @@ export const createTagObject = (tags: EntityTags[]) => { } as TagLabel) ); }; + +export const getQueryFilterToExcludeTerms = (fqn: string) => ({ + query: { + bool: { + must: [ + { + bool: { + must_not: [ + { + term: { + 'tags.tagFQN': fqn, + }, + }, + ], + }, + }, + { + bool: { + must_not: [ + { + term: { + entityType: EntityType.TAG, + }, + }, + { + term: { + entityType: EntityType.DATA_PRODUCT, + }, + }, + ], + }, + }, + ], + }, + }, +});