diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java index 827496dfb3b0e9..1394c0f6e6ffe0 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java @@ -150,6 +150,7 @@ import com.linkedin.datahub.graphql.resolvers.mutate.BatchRemoveOwnersResolver; import com.linkedin.datahub.graphql.resolvers.mutate.BatchRemoveTagsResolver; import com.linkedin.datahub.graphql.resolvers.mutate.BatchRemoveTermsResolver; +import com.linkedin.datahub.graphql.resolvers.mutate.BatchUpdateDeprecationResolver; import com.linkedin.datahub.graphql.resolvers.mutate.BatchSetDomainResolver; import com.linkedin.datahub.graphql.resolvers.mutate.MutableTypeResolver; import com.linkedin.datahub.graphql.resolvers.mutate.RemoveLinkResolver; @@ -723,6 +724,7 @@ private void configureMutationResolvers(final RuntimeWiring.Builder builder) { .dataFetcher("setDomain", new SetDomainResolver(this.entityClient, this.entityService)) .dataFetcher("batchSetDomain", new BatchSetDomainResolver(this.entityService)) .dataFetcher("updateDeprecation", new UpdateDeprecationResolver(this.entityClient, this.entityService)) + .dataFetcher("batchUpdateDeprecation", new BatchUpdateDeprecationResolver(entityService)) .dataFetcher("unsetDomain", new UnsetDomainResolver(this.entityClient, this.entityService)) .dataFetcher("createSecret", new CreateSecretResolver(this.entityClient, this.secretService)) .dataFetcher("deleteSecret", new DeleteSecretResolver(this.entityClient)) diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/BatchUpdateDeprecationResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/BatchUpdateDeprecationResolver.java new file mode 100644 index 00000000000000..5961dc9087a638 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/BatchUpdateDeprecationResolver.java @@ -0,0 +1,87 @@ +package com.linkedin.datahub.graphql.resolvers.mutate; + +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.exception.AuthorizationException; +import com.linkedin.datahub.graphql.generated.BatchUpdateDeprecationInput; +import com.linkedin.datahub.graphql.generated.ResourceRefInput; +import com.linkedin.datahub.graphql.resolvers.mutate.util.DeprecationUtils; +import com.linkedin.datahub.graphql.resolvers.mutate.util.LabelUtils; +import com.linkedin.metadata.entity.EntityService; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import javax.annotation.Nullable; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.*; + + +@Slf4j +@RequiredArgsConstructor +public class BatchUpdateDeprecationResolver implements DataFetcher> { + + private final EntityService _entityService; + + @Override + public CompletableFuture get(DataFetchingEnvironment environment) throws Exception { + final QueryContext context = environment.getContext(); + final BatchUpdateDeprecationInput input = bindArgument(environment.getArgument("input"), BatchUpdateDeprecationInput.class); + final List resources = input.getResources(); + + return CompletableFuture.supplyAsync(() -> { + + // First, validate the resources + validateInputResources(resources, context); + + try { + // Then execute the bulk update + batchUpdateDeprecation(input.getDeprecated(), input.getNote(), input.getDecommissionTime(), resources, context); + return true; + } catch (Exception e) { + log.error("Failed to perform update against input {}, {}", input.toString(), e.getMessage()); + throw new RuntimeException(String.format("Failed to perform update against input %s", input.toString()), e); + } + }); + } + + private void validateInputResources(List resources, QueryContext context) { + for (ResourceRefInput resource : resources) { + validateInputResource(resource, context); + } + } + + private void validateInputResource(ResourceRefInput resource, QueryContext context) { + final Urn resourceUrn = UrnUtils.getUrn(resource.getResourceUrn()); + if (!DeprecationUtils.isAuthorizedToUpdateDeprecationForEntity(context, resourceUrn)) { + throw new AuthorizationException("Unauthorized to perform this action. Please contact your DataHub administrator."); + } + LabelUtils.validateResource(resourceUrn, resource.getSubResource(), resource.getSubResourceType(), _entityService); + } + + private void batchUpdateDeprecation(boolean deprecated, + @Nullable String note, + @Nullable Long decommissionTime, + List resources, + QueryContext context) { + log.debug("Batch updating deprecation. deprecated: {}, note: {}, decommissionTime: {}, resources: {}", deprecated, note, decommissionTime, resources); + try { + DeprecationUtils.updateDeprecationForResources( + deprecated, + note, + decommissionTime, + resources, + UrnUtils.getUrn(context.getActorUrn()), + _entityService); + } catch (Exception e) { + throw new RuntimeException(String.format("Failed to batch update deprecated to %s for resources with urns %s!", + deprecated, + resources.stream().map(ResourceRefInput::getResourceUrn).collect(Collectors.toList())), + e); + } + } +} \ No newline at end of file diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/DeprecationUtils.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/DeprecationUtils.java new file mode 100644 index 00000000000000..48af0b401084e4 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/DeprecationUtils.java @@ -0,0 +1,95 @@ +package com.linkedin.datahub.graphql.resolvers.mutate.util; + +import com.google.common.collect.ImmutableList; + +import com.linkedin.common.Deprecation; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.data.template.SetMode; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.authorization.AuthorizationUtils; +import com.linkedin.datahub.graphql.authorization.ConjunctivePrivilegeGroup; +import com.linkedin.datahub.graphql.authorization.DisjunctivePrivilegeGroup; +import com.linkedin.datahub.graphql.generated.ResourceRefInput; +import com.linkedin.metadata.Constants; +import com.linkedin.metadata.authorization.PoliciesConfig; +import com.linkedin.metadata.entity.EntityService; +import com.linkedin.mxe.MetadataChangeProposal; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import lombok.extern.slf4j.Slf4j; + +import static com.linkedin.datahub.graphql.resolvers.mutate.MutationUtils.*; + + +@Slf4j +public class DeprecationUtils { + private static final ConjunctivePrivilegeGroup ALL_PRIVILEGES_GROUP = new ConjunctivePrivilegeGroup(ImmutableList.of( + PoliciesConfig.EDIT_ENTITY_PRIVILEGE.getType() + )); + + private DeprecationUtils() { } + + public static boolean isAuthorizedToUpdateDeprecationForEntity(@Nonnull QueryContext context, Urn entityUrn) { + final DisjunctivePrivilegeGroup orPrivilegeGroups = new DisjunctivePrivilegeGroup(ImmutableList.of( + ALL_PRIVILEGES_GROUP, + new ConjunctivePrivilegeGroup(ImmutableList.of(PoliciesConfig.EDIT_ENTITY_DEPRECATION_PRIVILEGE.getType())) + )); + + return AuthorizationUtils.isAuthorized( + context.getAuthorizer(), + context.getActorUrn(), + entityUrn.getEntityType(), + entityUrn.toString(), + orPrivilegeGroups); + } + + public static void updateDeprecationForResources( + boolean deprecated, + @Nullable String note, + @Nullable Long decommissionTime, + List resources, + Urn actor, + EntityService entityService + ) { + final List changes = new ArrayList<>(); + for (ResourceRefInput resource : resources) { + changes.add(buildUpdateDeprecationProposal(deprecated, note, decommissionTime, resource, actor, entityService)); + } + ingestChangeProposals(changes, entityService, actor); + } + + private static MetadataChangeProposal buildUpdateDeprecationProposal( + boolean deprecated, + @Nullable String note, + @Nullable Long decommissionTime, + ResourceRefInput resource, + Urn actor, + EntityService entityService + ) { + Deprecation deprecation = (Deprecation) getAspectFromEntity( + resource.getResourceUrn(), + Constants.DEPRECATION_ASPECT_NAME, + entityService, + new Deprecation()); + deprecation.setActor(actor); + deprecation.setDeprecated(deprecated); + deprecation.setDecommissionTime(decommissionTime, SetMode.REMOVE_IF_NULL); + if (note != null) { + deprecation.setNote(note); + } else { + // Note is required field in GMS. Set to empty string if not provided. + deprecation.setNote(""); + } + return buildMetadataChangeProposal(UrnUtils.getUrn(resource.getResourceUrn()), Constants.DEPRECATION_ASPECT_NAME, deprecation, actor, entityService); + } + + private static void ingestChangeProposals(List changes, EntityService entityService, Urn actor) { + // TODO: Replace this with a batch ingest proposals endpoint. + for (MetadataChangeProposal change : changes) { + entityService.ingestProposal(change, getAuditStamp(actor)); + } + } +} \ No newline at end of file diff --git a/datahub-graphql-core/src/main/resources/entity.graphql b/datahub-graphql-core/src/main/resources/entity.graphql index b08f99321611d3..59f6bb5c0ffa78 100644 --- a/datahub-graphql-core/src/main/resources/entity.graphql +++ b/datahub-graphql-core/src/main/resources/entity.graphql @@ -406,6 +406,11 @@ type Mutation { "Input required to set deprecation for an Entity." input: UpdateDeprecationInput!): Boolean + """ + Updates the deprecation status for a batch of assets. + """ + batchUpdateDeprecation(input: BatchUpdateDeprecationInput!): Boolean + """ Update a particular Corp User's editable properties """ @@ -6923,6 +6928,32 @@ input UpdateDeprecationInput { note: String } + +""" +Input provided when updating the deprecation status for a batch of assets. +""" +input BatchUpdateDeprecationInput { + """ + Whether the Entity is marked as deprecated. + """ + deprecated: Boolean! + + """ + Optional - The time user plan to decommission this entity + """ + decommissionTime: Long + + """ + Optional - Additional information about the entity deprecation plan + """ + note: String + + """ + The target assets to attach the tags to + """ + resources: [ResourceRefInput]! +} + """ Input provided when adding tags to a batch of assets """ diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/deprecation/BatchUpdateDeprecationResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/deprecation/BatchUpdateDeprecationResolverTest.java new file mode 100644 index 00000000000000..49c24770333c73 --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/deprecation/BatchUpdateDeprecationResolverTest.java @@ -0,0 +1,238 @@ +package com.linkedin.datahub.graphql.resolvers.deprecation; + +import com.google.common.collect.ImmutableList; +import com.linkedin.common.AuditStamp; +import com.linkedin.common.Deprecation; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.BatchUpdateDeprecationInput; +import com.linkedin.datahub.graphql.generated.ResourceRefInput; +import com.linkedin.datahub.graphql.resolvers.mutate.BatchUpdateDeprecationResolver; +import com.linkedin.events.metadata.ChangeType; +import com.linkedin.metadata.Constants; +import com.linkedin.metadata.entity.EntityService; +import com.linkedin.metadata.utils.GenericRecordUtils; +import com.linkedin.mxe.MetadataChangeProposal; +import graphql.schema.DataFetchingEnvironment; +import java.util.concurrent.CompletionException; +import org.mockito.Mockito; +import org.testng.annotations.Test; + +import static com.linkedin.datahub.graphql.TestUtils.*; +import static org.testng.Assert.*; + + +public class BatchUpdateDeprecationResolverTest { + + private static final String TEST_ENTITY_URN_1 = "urn:li:dataset:(urn:li:dataPlatform:mysql,my-test,PROD)"; + private static final String TEST_ENTITY_URN_2 = "urn:li:dataset:(urn:li:dataPlatform:mysql,my-test-2,PROD)"; + + @Test + public void testGetSuccessNoExistingDeprecation() throws Exception { + EntityService mockService = Mockito.mock(EntityService.class); + + Mockito.when(mockService.getAspect( + Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN_1)), + Mockito.eq(Constants.DEPRECATION_ASPECT_NAME), + Mockito.eq(0L))) + .thenReturn(null); + + Mockito.when(mockService.getAspect( + Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN_2)), + Mockito.eq(Constants.DEPRECATION_ASPECT_NAME), + Mockito.eq(0L))) + .thenReturn(null); + + + Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_1))).thenReturn(true); + Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_2))).thenReturn(true); + + BatchUpdateDeprecationResolver resolver = new BatchUpdateDeprecationResolver(mockService); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + BatchUpdateDeprecationInput input = new BatchUpdateDeprecationInput(true, 0L, "test", ImmutableList.of( + new ResourceRefInput(TEST_ENTITY_URN_1, null, null), + new ResourceRefInput(TEST_ENTITY_URN_2, null, null))); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + assertTrue(resolver.get(mockEnv).get()); + + final Deprecation newDeprecation = new Deprecation() + .setDeprecated(true) + .setNote("test") + .setDecommissionTime(0L) + .setActor(UrnUtils.getUrn("urn:li:corpuser:test")); + + final MetadataChangeProposal proposal1 = new MetadataChangeProposal(); + proposal1.setEntityUrn(Urn.createFromString(TEST_ENTITY_URN_1)); + proposal1.setEntityType(Constants.DATASET_ENTITY_NAME); + proposal1.setAspectName(Constants.DEPRECATION_ASPECT_NAME); + proposal1.setAspect(GenericRecordUtils.serializeAspect(newDeprecation)); + proposal1.setChangeType(ChangeType.UPSERT); + + Mockito.verify(mockService, Mockito.times(1)).ingestProposal( + Mockito.eq(proposal1), + Mockito.any(AuditStamp.class) + ); + + final MetadataChangeProposal proposal2 = new MetadataChangeProposal(); + proposal2.setEntityUrn(Urn.createFromString(TEST_ENTITY_URN_2)); + proposal2.setEntityType(Constants.DATASET_ENTITY_NAME); + proposal2.setAspectName(Constants.DEPRECATION_ASPECT_NAME); + proposal2.setAspect(GenericRecordUtils.serializeAspect(newDeprecation)); + proposal2.setChangeType(ChangeType.UPSERT); + + Mockito.verify(mockService, Mockito.times(1)).ingestProposal( + Mockito.eq(proposal2), + Mockito.any(AuditStamp.class) + ); + } + + @Test + public void testGetSuccessExistingDeprecation() throws Exception { + final Deprecation originalDeprecation = new Deprecation() + .setDeprecated(false) + .setNote("") + .setActor(UrnUtils.getUrn("urn:li:corpuser:test")); + + EntityService mockService = Mockito.mock(EntityService.class); + + Mockito.when(mockService.getAspect( + Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN_1)), + Mockito.eq(Constants.DEPRECATION_ASPECT_NAME), + Mockito.eq(0L))) + .thenReturn(originalDeprecation); + + Mockito.when(mockService.getAspect( + Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN_2)), + Mockito.eq(Constants.DEPRECATION_ASPECT_NAME), + Mockito.eq(0L))) + .thenReturn(originalDeprecation); + + Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_1))).thenReturn(true); + Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_2))).thenReturn(true); + + BatchUpdateDeprecationResolver resolver = new BatchUpdateDeprecationResolver(mockService); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + BatchUpdateDeprecationInput input = new BatchUpdateDeprecationInput(true, 1L, "test", ImmutableList.of( + new ResourceRefInput(TEST_ENTITY_URN_1, null, null), + new ResourceRefInput(TEST_ENTITY_URN_2, null, null))); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + assertTrue(resolver.get(mockEnv).get()); + + final Deprecation newDeprecation = new Deprecation() + .setDeprecated(true) + .setNote("test") + .setDecommissionTime(1L) + .setActor(UrnUtils.getUrn("urn:li:corpuser:test")); + + final MetadataChangeProposal proposal1 = new MetadataChangeProposal(); + proposal1.setEntityUrn(Urn.createFromString(TEST_ENTITY_URN_1)); + proposal1.setEntityType(Constants.DATASET_ENTITY_NAME); + proposal1.setAspectName(Constants.DEPRECATION_ASPECT_NAME); + proposal1.setAspect(GenericRecordUtils.serializeAspect(newDeprecation)); + proposal1.setChangeType(ChangeType.UPSERT); + + Mockito.verify(mockService, Mockito.times(1)).ingestProposal( + Mockito.eq(proposal1), + Mockito.any(AuditStamp.class) + ); + + final MetadataChangeProposal proposal2 = new MetadataChangeProposal(); + proposal2.setEntityUrn(Urn.createFromString(TEST_ENTITY_URN_2)); + proposal2.setEntityType(Constants.DATASET_ENTITY_NAME); + proposal2.setAspectName(Constants.DEPRECATION_ASPECT_NAME); + proposal2.setAspect(GenericRecordUtils.serializeAspect(newDeprecation)); + proposal2.setChangeType(ChangeType.UPSERT); + + Mockito.verify(mockService, Mockito.times(1)).ingestProposal( + Mockito.eq(proposal2), + Mockito.any(AuditStamp.class) + ); + } + + @Test + public void testGetFailureResourceDoesNotExist() throws Exception { + EntityService mockService = Mockito.mock(EntityService.class); + + Mockito.when(mockService.getAspect( + Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN_1)), + Mockito.eq(Constants.DEPRECATION_ASPECT_NAME), + Mockito.eq(0L))) + .thenReturn(null); + Mockito.when(mockService.getAspect( + Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN_2)), + Mockito.eq(Constants.DEPRECATION_ASPECT_NAME), + Mockito.eq(0L))) + .thenReturn(null); + + Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_1))).thenReturn(false); + Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_2))).thenReturn(true); + + BatchUpdateDeprecationResolver resolver = new BatchUpdateDeprecationResolver(mockService); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + BatchUpdateDeprecationInput input = new BatchUpdateDeprecationInput(true, 1L, "test", ImmutableList.of( + new ResourceRefInput(TEST_ENTITY_URN_1, null, null), + new ResourceRefInput(TEST_ENTITY_URN_2, null, null))); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + Mockito.verify(mockService, Mockito.times(0)).ingestProposal( + Mockito.any(), + Mockito.any(AuditStamp.class)); + } + + @Test + public void testGetUnauthorized() throws Exception { + EntityService mockService = Mockito.mock(EntityService.class); + + BatchUpdateDeprecationResolver resolver = new BatchUpdateDeprecationResolver(mockService); + + // Execute resolver + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + BatchUpdateDeprecationInput input = new BatchUpdateDeprecationInput(true, 1L, "test", ImmutableList.of( + new ResourceRefInput(TEST_ENTITY_URN_1, null, null), + new ResourceRefInput(TEST_ENTITY_URN_2, null, null))); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + QueryContext mockContext = getMockDenyContext(); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + Mockito.verify(mockService, Mockito.times(0)).ingestProposal( + Mockito.any(), + Mockito.any(AuditStamp.class)); + } + + @Test + public void testGetEntityClientException() throws Exception { + EntityService mockService = Mockito.mock(EntityService.class); + + Mockito.doThrow(RuntimeException.class).when(mockService).ingestProposal( + Mockito.any(), + Mockito.any(AuditStamp.class)); + + BatchUpdateDeprecationResolver resolver = new BatchUpdateDeprecationResolver(mockService); + + // Execute resolver + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + QueryContext mockContext = getMockAllowContext(); + BatchUpdateDeprecationInput input = new BatchUpdateDeprecationInput(true, 1L, "test", ImmutableList.of( + new ResourceRefInput(TEST_ENTITY_URN_1, null, null), + new ResourceRefInput(TEST_ENTITY_URN_2, null, null))); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + } +} \ No newline at end of file diff --git a/datahub-web-react/src/app/entity/shared/EntityDropdown/EntityDropdown.tsx b/datahub-web-react/src/app/entity/shared/EntityDropdown/EntityDropdown.tsx index c891e24b1849d9..bf3e46b6582e6c 100644 --- a/datahub-web-react/src/app/entity/shared/EntityDropdown/EntityDropdown.tsx +++ b/datahub-web-react/src/app/entity/shared/EntityDropdown/EntityDropdown.tsx @@ -13,7 +13,7 @@ import { import { Redirect } from 'react-router'; import { EntityType, PlatformPrivileges } from '../../../../types.generated'; import CreateGlossaryEntityModal from './CreateGlossaryEntityModal'; -import { AddDeprecationDetailsModal } from './AddDeprecationDetailsModal'; +import { UpdateDeprecationModal } from './UpdateDeprecationModal'; import { useUpdateDeprecationMutation } from '../../../../graphql/mutations.generated'; import MoveGlossaryEntityModal from './MoveGlossaryEntityModal'; import { ANTD_GRAY } from '../constants'; @@ -233,9 +233,8 @@ function EntityDropdown(props: Props) { /> )} {isDeprecationModalVisible && ( - setIsDeprecationModalVisible(false)} refetch={refetchForEntity} /> diff --git a/datahub-web-react/src/app/entity/shared/EntityDropdown/AddDeprecationDetailsModal.tsx b/datahub-web-react/src/app/entity/shared/EntityDropdown/UpdateDeprecationModal.tsx similarity index 83% rename from datahub-web-react/src/app/entity/shared/EntityDropdown/AddDeprecationDetailsModal.tsx rename to datahub-web-react/src/app/entity/shared/EntityDropdown/UpdateDeprecationModal.tsx index 3dc4b072a314a4..db91ec888f2921 100644 --- a/datahub-web-react/src/app/entity/shared/EntityDropdown/AddDeprecationDetailsModal.tsx +++ b/datahub-web-react/src/app/entity/shared/EntityDropdown/UpdateDeprecationModal.tsx @@ -1,16 +1,15 @@ import React from 'react'; import { Button, DatePicker, Form, Input, message, Modal } from 'antd'; -import { useUpdateDeprecationMutation } from '../../../../graphql/mutations.generated'; +import { useBatchUpdateDeprecationMutation } from '../../../../graphql/mutations.generated'; type Props = { - urn: string; - visible: boolean; + urns: string[]; onClose: () => void; refetch?: () => void; }; -export const AddDeprecationDetailsModal = ({ urn, visible, onClose, refetch }: Props) => { - const [updateDeprecation] = useUpdateDeprecationMutation(); +export const UpdateDeprecationModal = ({ urns, onClose, refetch }: Props) => { + const [batchUpdateDeprecation] = useBatchUpdateDeprecationMutation(); const [form] = Form.useForm(); const handleClose = () => { @@ -21,10 +20,10 @@ export const AddDeprecationDetailsModal = ({ urn, visible, onClose, refetch }: P const handleOk = async (formData: any) => { message.loading({ content: 'Updating...' }); try { - await updateDeprecation({ + await batchUpdateDeprecation({ variables: { input: { - urn, + resources: [...urns.map((urn) => ({ resourceUrn: urn }))], deprecated: true, note: formData.note, decommissionTime: formData.decommissionTime && formData.decommissionTime.unix(), @@ -46,7 +45,7 @@ export const AddDeprecationDetailsModal = ({ urn, visible, onClose, refetch }: P return ( )} {visibleActionGroups.has(SelectActionGroups.DELETE) && ( diff --git a/datahub-web-react/src/app/entity/shared/components/styled/search/action/DeprecationDropdown.tsx b/datahub-web-react/src/app/entity/shared/components/styled/search/action/DeprecationDropdown.tsx index 957654d657f053..9e4c22e1748273 100644 --- a/datahub-web-react/src/app/entity/shared/components/styled/search/action/DeprecationDropdown.tsx +++ b/datahub-web-react/src/app/entity/shared/components/styled/search/action/DeprecationDropdown.tsx @@ -1,27 +1,80 @@ -import React from 'react'; +import { message, Modal } from 'antd'; +import React, { useState } from 'react'; +import { useBatchUpdateDeprecationMutation } from '../../../../../../../graphql/mutations.generated'; +import { UpdateDeprecationModal } from '../../../../EntityDropdown/UpdateDeprecationModal'; import ActionDropdown from './ActionDropdown'; type Props = { urns: Array; disabled: boolean; + refetch?: () => void; }; // eslint-disable-next-line -export default function DeprecationDropdown({ urns, disabled = false }: Props) { - return ( - null, - }, - { - title: 'Mark as undeprecated', - onClick: () => null, +export default function DeprecationDropdown({ urns, disabled = false, refetch }: Props) { + const [isEditModalVisible, setIsEditModalVisible] = useState(false); + const [batchUpdateDeprecationMutation] = useBatchUpdateDeprecationMutation(); + + const batchUndeprecate = () => { + batchUpdateDeprecationMutation({ + variables: { + input: { + resources: [...urns.map((urn) => ({ resourceUrn: urn }))], + deprecated: false, }, - ]} - disabled={disabled} - /> + }, + }) + .then(({ errors }) => { + if (!errors) { + message.success({ content: 'Marked assets as undeprecated!', duration: 2 }); + refetch?.(); + } + }) + .catch((e) => { + message.destroy(); + message.error({ content: `Failed to mark assets as undeprecated: \n ${e.message || ''}`, duration: 3 }); + }); + }; + + return ( + <> + { + setIsEditModalVisible(true); + }, + }, + { + title: 'Mark as undeprecated', + onClick: () => { + Modal.confirm({ + title: `Confirm Mark as undeprecated`, + content: `Are you sure you want to mark these assets as undeprecated?`, + onOk() { + batchUndeprecate(); + }, + onCancel() {}, + okText: 'Yes', + maskClosable: true, + closable: true, + }); + }, + }, + ]} + disabled={disabled} + /> + {isEditModalVisible && ( + { + setIsEditModalVisible(false); + refetch?.(); + }} + /> + )} + ); } diff --git a/datahub-web-react/src/app/recommendations/renderer/component/EntityNameList.tsx b/datahub-web-react/src/app/recommendations/renderer/component/EntityNameList.tsx index c621c7d393d6f8..93956fbf214e74 100644 --- a/datahub-web-react/src/app/recommendations/renderer/component/EntityNameList.tsx +++ b/datahub-web-react/src/app/recommendations/renderer/component/EntityNameList.tsx @@ -126,6 +126,7 @@ export const EntityNameList = ({ const fallbackIcon = entityRegistry.getIcon(entity.type, 18, IconStyleType.ACCENT); const subType = genericProps?.subTypes?.typeNames?.length && genericProps?.subTypes?.typeNames[0]; const entityCount = genericProps?.entityCount; + const deprecation = genericProps?.deprecation; return ( <> @@ -151,6 +152,7 @@ export const EntityNameList = ({ onClick={() => onClick?.(index)} entityCount={entityCount} degree={additionalProperties?.degree} + deprecation={deprecation} /> diff --git a/datahub-web-react/src/graphql/mutations.graphql b/datahub-web-react/src/graphql/mutations.graphql index cab44542351751..9f607065ea9a35 100644 --- a/datahub-web-react/src/graphql/mutations.graphql +++ b/datahub-web-react/src/graphql/mutations.graphql @@ -93,3 +93,7 @@ mutation updateName($input: UpdateNameInput!) { mutation batchSetDomain($input: BatchSetDomainInput!) { batchSetDomain(input: $input) } + +mutation batchUpdateDeprecation($input: BatchUpdateDeprecationInput!) { + batchUpdateDeprecation(input: $input) +}