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 4d47e7ce60ef61..3eefd597583d4f 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 @@ -675,7 +675,7 @@ private String getUrnField(DataFetchingEnvironment env) { private void configureMutationResolvers(final RuntimeWiring.Builder builder) { builder.type("Mutation", typeWiring -> typeWiring .dataFetcher("updateDataset", new MutableTypeResolver<>(datasetType)) - .dataFetcher("createTag", new CreateTagResolver(entityService)) + .dataFetcher("createTag", new CreateTagResolver(this.entityClient)) .dataFetcher("updateTag", new MutableTypeResolver<>(tagType)) .dataFetcher("setTagColor", new SetTagColorResolver(entityClient, entityService)) .dataFetcher("deleteTag", new DeleteTagResolver(entityClient)) @@ -707,7 +707,7 @@ private void configureMutationResolvers(final RuntimeWiring.Builder builder) { .dataFetcher("removeUser", new RemoveUserResolver(this.entityClient)) .dataFetcher("removeGroup", new RemoveGroupResolver(this.entityClient)) .dataFetcher("updateUserStatus", new UpdateUserStatusResolver(this.entityClient)) - .dataFetcher("createDomain", new CreateDomainResolver(this.entityService)) + .dataFetcher("createDomain", new CreateDomainResolver(this.entityClient)) .dataFetcher("deleteDomain", new DeleteDomainResolver(entityClient)) .dataFetcher("setDomain", new SetDomainResolver(this.entityClient, this.entityService)) .dataFetcher("updateDeprecation", new UpdateDeprecationResolver(this.entityClient, this.entityService)) diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/domain/CreateDomainResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/domain/CreateDomainResolver.java index 4a435a8bfc9ef5..eb53c5926af324 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/domain/CreateDomainResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/domain/CreateDomainResolver.java @@ -6,9 +6,9 @@ import com.linkedin.datahub.graphql.exception.AuthorizationException; import com.linkedin.datahub.graphql.generated.CreateDomainInput; import com.linkedin.domain.DomainProperties; +import com.linkedin.entity.client.EntityClient; import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.Constants; -import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.key.DomainKey; import com.linkedin.metadata.utils.EntityKeyUtils; import com.linkedin.metadata.utils.GenericRecordUtils; @@ -20,7 +20,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import static com.linkedin.datahub.graphql.authorization.AuthorizationUtils.*; import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.*; /** @@ -30,7 +29,7 @@ @RequiredArgsConstructor public class CreateDomainResolver implements DataFetcher> { - private final EntityService _entityService; + private final EntityClient _entityClient; @Override public CompletableFuture get(DataFetchingEnvironment environment) throws Exception { @@ -52,7 +51,7 @@ public CompletableFuture get(DataFetchingEnvironment environment) throws final String id = input.getId() != null ? input.getId() : UUID.randomUUID().toString(); key.setId(id); - if (_entityService.exists(EntityKeyUtils.convertEntityKeyToUrn(key, Constants.DOMAIN_ENTITY_NAME))) { + if (_entityClient.exists(EntityKeyUtils.convertEntityKeyToUrn(key, Constants.DOMAIN_ENTITY_NAME), context.getAuthentication())) { throw new IllegalArgumentException("This Domain already exists!"); } @@ -64,7 +63,7 @@ public CompletableFuture get(DataFetchingEnvironment environment) throws proposal.setAspect(GenericRecordUtils.serializeAspect(mapDomainProperties(input))); proposal.setChangeType(ChangeType.UPSERT); - return _entityService.ingestProposal(proposal, createAuditStamp(context)).getUrn().toString(); + return _entityClient.ingestProposal(proposal, context.getAuthentication()); } catch (Exception e) { log.error("Failed to create Domain with id: {}, name: {}: {}", input.getId(), input.getName(), e.getMessage()); throw new RuntimeException(String.format("Failed to create Domain with id: %s, name: %s", input.getId(), input.getName()), e); diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/domain/DeleteDomainResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/domain/DeleteDomainResolver.java index e3f0ffd1ac7b13..ba712f8e6c7494 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/domain/DeleteDomainResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/domain/DeleteDomainResolver.java @@ -34,6 +34,7 @@ public CompletableFuture get(final DataFetchingEnvironment environment) if (AuthorizationUtils.canManageDomains(context) || AuthorizationUtils.canDeleteEntity(urn, context)) { try { _entityClient.deleteEntity(urn, context.getAuthentication()); + log.info(String.format("I've successfully deleted the entity %s with urn", domainUrn)); // Asynchronously Delete all references to the entity (to return quickly) CompletableFuture.runAsync(() -> { diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/glossary/CreateGlossaryNodeResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/glossary/CreateGlossaryNodeResolver.java index aab3b4ed862d6f..04b752ff5e7d68 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/glossary/CreateGlossaryNodeResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/glossary/CreateGlossaryNodeResolver.java @@ -11,6 +11,7 @@ import com.linkedin.glossary.GlossaryNodeInfo; import com.linkedin.metadata.Constants; import com.linkedin.metadata.key.GlossaryNodeKey; +import com.linkedin.metadata.utils.EntityKeyUtils; import com.linkedin.metadata.utils.GenericRecordUtils; import com.linkedin.mxe.MetadataChangeProposal; import graphql.schema.DataFetcher; @@ -44,6 +45,10 @@ public CompletableFuture get(DataFetchingEnvironment environment) throws final String id = input.getId() != null ? input.getId() : UUID.randomUUID().toString(); key.setName(id); + if (_entityClient.exists(EntityKeyUtils.convertEntityKeyToUrn(key, Constants.GLOSSARY_NODE_ENTITY_NAME), context.getAuthentication())) { + throw new IllegalArgumentException("This Glossary Node already exists!"); + } + final MetadataChangeProposal proposal = new MetadataChangeProposal(); proposal.setEntityKeyAspect(GenericRecordUtils.serializeAspect(key)); proposal.setEntityType(Constants.GLOSSARY_NODE_ENTITY_NAME); diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/glossary/CreateGlossaryTermResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/glossary/CreateGlossaryTermResolver.java index 3b72b6a4fa5a2d..e40d159f99969e 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/glossary/CreateGlossaryTermResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/glossary/CreateGlossaryTermResolver.java @@ -11,6 +11,7 @@ import com.linkedin.glossary.GlossaryTermInfo; import com.linkedin.metadata.Constants; import com.linkedin.metadata.key.GlossaryTermKey; +import com.linkedin.metadata.utils.EntityKeyUtils; import com.linkedin.metadata.utils.GenericRecordUtils; import com.linkedin.mxe.MetadataChangeProposal; import graphql.schema.DataFetcher; @@ -44,6 +45,10 @@ public CompletableFuture get(DataFetchingEnvironment environment) throws final String id = input.getId() != null ? input.getId() : UUID.randomUUID().toString(); key.setName(id); + if (_entityClient.exists(EntityKeyUtils.convertEntityKeyToUrn(key, Constants.GLOSSARY_TERM_ENTITY_NAME), context.getAuthentication())) { + throw new IllegalArgumentException("This Glossary Term already exists!"); + } + final MetadataChangeProposal proposal = new MetadataChangeProposal(); proposal.setEntityKeyAspect(GenericRecordUtils.serializeAspect(key)); proposal.setEntityType(Constants.GLOSSARY_TERM_ENTITY_NAME); diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/group/CreateGroupResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/group/CreateGroupResolver.java index 3b8fec4e2ba81e..c374aeb438210e 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/group/CreateGroupResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/group/CreateGroupResolver.java @@ -11,6 +11,7 @@ import com.linkedin.identity.CorpGroupInfo; import com.linkedin.metadata.Constants; import com.linkedin.metadata.key.CorpGroupKey; +import com.linkedin.metadata.utils.EntityKeyUtils; import com.linkedin.metadata.utils.GenericRecordUtils; import com.linkedin.mxe.MetadataChangeProposal; import graphql.schema.DataFetcher; @@ -46,6 +47,10 @@ public CompletableFuture get(final DataFetchingEnvironment environment) final String id = input.getId() != null ? input.getId() : UUID.randomUUID().toString(); key.setName(id); // 'name' in the key really reflects nothing more than a stable "id". + if (_entityClient.exists(EntityKeyUtils.convertEntityKeyToUrn(key, Constants.CORP_GROUP_ENTITY_NAME), context.getAuthentication())) { + throw new IllegalArgumentException("This Group already exists!"); + } + // Create the Group info. final CorpGroupInfo info = new CorpGroupInfo(); info.setDisplayName(input.getName()); diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/ingest/secret/CreateSecretResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/ingest/secret/CreateSecretResolver.java index 99f0e1374f530f..8dd72a7b2182cf 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/ingest/secret/CreateSecretResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/ingest/secret/CreateSecretResolver.java @@ -10,6 +10,7 @@ import com.linkedin.metadata.Constants; import com.linkedin.metadata.key.DataHubSecretKey; import com.linkedin.metadata.secret.SecretService; +import com.linkedin.metadata.utils.EntityKeyUtils; import com.linkedin.metadata.utils.GenericRecordUtils; import com.linkedin.mxe.MetadataChangeProposal; import com.linkedin.secret.DataHubSecretValue; @@ -38,34 +39,36 @@ public CreateSecretResolver( @Override public CompletableFuture get(final DataFetchingEnvironment environment) throws Exception { final QueryContext context = environment.getContext(); + final CreateSecretInput input = bindArgument(environment.getArgument("input"), CreateSecretInput.class); return CompletableFuture.supplyAsync(() -> { if (IngestionAuthUtils.canManageSecrets(context)) { - final CreateSecretInput input = bindArgument(environment.getArgument("input"), CreateSecretInput.class); + try { - final MetadataChangeProposal proposal = new MetadataChangeProposal(); + final MetadataChangeProposal proposal = new MetadataChangeProposal(); - // Create the Ingestion source key --> use the display name as a unique id to ensure it's not duplicated. - final DataHubSecretKey key = new DataHubSecretKey(); - key.setId(input.getName()); - proposal.setEntityKeyAspect(GenericRecordUtils.serializeAspect(key)); + // Create the Ingestion source key --> use the display name as a unique id to ensure it's not duplicated. + final DataHubSecretKey key = new DataHubSecretKey(); + key.setId(input.getName()); + proposal.setEntityKeyAspect(GenericRecordUtils.serializeAspect(key)); - // Create the secret value. - final DataHubSecretValue value = new DataHubSecretValue(); - value.setName(input.getName()); - value.setValue(_secretService.encrypt(input.getValue())); - value.setDescription(input.getDescription(), SetMode.IGNORE_NULL); + if (_entityClient.exists(EntityKeyUtils.convertEntityKeyToUrn(key, Constants.SECRETS_ENTITY_NAME), context.getAuthentication())) { + throw new IllegalArgumentException("This Secret already exists!"); + } - proposal.setEntityType(Constants.SECRETS_ENTITY_NAME); - proposal.setAspectName(Constants.SECRET_VALUE_ASPECT_NAME); - proposal.setAspect(GenericRecordUtils.serializeAspect(value)); - proposal.setChangeType(ChangeType.UPSERT); + // Create the secret value. + final DataHubSecretValue value = new DataHubSecretValue(); + value.setName(input.getName()); + value.setValue(_secretService.encrypt(input.getValue())); + value.setDescription(input.getDescription(), SetMode.IGNORE_NULL); - System.out.println(String.format("About to ingest %s", proposal)); + proposal.setEntityType(Constants.SECRETS_ENTITY_NAME); + proposal.setAspectName(Constants.SECRET_VALUE_ASPECT_NAME); + proposal.setAspect(GenericRecordUtils.serializeAspect(value)); + proposal.setChangeType(ChangeType.UPSERT); - try { return _entityClient.ingestProposal(proposal, context.getAuthentication()); } catch (Exception e) { throw new RuntimeException(String.format("Failed to create new secret with name %s", input.getName()), e); diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/tag/CreateTagResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/tag/CreateTagResolver.java index 22e3f9187505d3..3ffa261cae4409 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/tag/CreateTagResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/tag/CreateTagResolver.java @@ -5,9 +5,9 @@ import com.linkedin.datahub.graphql.authorization.AuthorizationUtils; import com.linkedin.datahub.graphql.exception.AuthorizationException; import com.linkedin.datahub.graphql.generated.CreateTagInput; +import com.linkedin.entity.client.EntityClient; import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.Constants; -import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.key.TagKey; import com.linkedin.metadata.utils.EntityKeyUtils; import com.linkedin.metadata.utils.GenericRecordUtils; @@ -20,7 +20,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import static com.linkedin.datahub.graphql.authorization.AuthorizationUtils.*; import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.*; /** @@ -30,7 +29,7 @@ @RequiredArgsConstructor public class CreateTagResolver implements DataFetcher> { - private final EntityService _entityService; + private final EntityClient _entityClient; @Override public CompletableFuture get(DataFetchingEnvironment environment) throws Exception { @@ -52,7 +51,7 @@ public CompletableFuture get(DataFetchingEnvironment environment) throws final String id = input.getId() != null ? input.getId() : UUID.randomUUID().toString(); key.setName(id); - if (_entityService.exists(EntityKeyUtils.convertEntityKeyToUrn(key, Constants.TAG_ENTITY_NAME))) { + if (_entityClient.exists(EntityKeyUtils.convertEntityKeyToUrn(key, Constants.TAG_ENTITY_NAME), context.getAuthentication())) { throw new IllegalArgumentException("This Tag already exists!"); } @@ -63,7 +62,7 @@ public CompletableFuture get(DataFetchingEnvironment environment) throws proposal.setAspectName(Constants.TAG_PROPERTIES_ASPECT_NAME); proposal.setAspect(GenericRecordUtils.serializeAspect(mapTagProperties(input))); proposal.setChangeType(ChangeType.UPSERT); - return _entityService.ingestProposal(proposal, createAuditStamp(context)).getUrn().toString(); + return _entityClient.ingestProposal(proposal, context.getAuthentication()); } catch (Exception e) { log.error("Failed to create Domain with id: {}, name: {}: {}", input.getId(), input.getName(), e.getMessage()); throw new RuntimeException(String.format("Failed to create Domain with id: %s, name: %s", input.getId(), input.getName()), e); diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/test/CreateTestResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/test/CreateTestResolver.java index 7454d07fc99551..d792771df14d42 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/test/CreateTestResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/test/CreateTestResolver.java @@ -8,6 +8,7 @@ import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.Constants; import com.linkedin.metadata.key.TestKey; +import com.linkedin.metadata.utils.EntityKeyUtils; import com.linkedin.metadata.utils.GenericRecordUtils; import com.linkedin.mxe.MetadataChangeProposal; import com.linkedin.test.TestInfo; @@ -34,32 +35,37 @@ public CreateTestResolver(final EntityClient entityClient) { @Override public CompletableFuture get(final DataFetchingEnvironment environment) throws Exception { final QueryContext context = environment.getContext(); + final CreateTestInput input = bindArgument(environment.getArgument("input"), CreateTestInput.class); return CompletableFuture.supplyAsync(() -> { if (canManageTests(context)) { - final CreateTestInput input = bindArgument(environment.getArgument("input"), CreateTestInput.class); - final MetadataChangeProposal proposal = new MetadataChangeProposal(); + try { - // Create new test - // Since we are creating a new Test, we need to generate a unique UUID. - final UUID uuid = UUID.randomUUID(); - final String uuidStr = input.getId() == null ? uuid.toString() : input.getId(); + final MetadataChangeProposal proposal = new MetadataChangeProposal(); - // Create the Ingestion source key - final TestKey key = new TestKey(); - key.setId(uuidStr); - proposal.setEntityKeyAspect(GenericRecordUtils.serializeAspect(key)); + // Create new test + // Since we are creating a new Test, we need to generate a unique UUID. + final UUID uuid = UUID.randomUUID(); + final String uuidStr = input.getId() == null ? uuid.toString() : input.getId(); - // Create the Test info. - final TestInfo info = mapCreateTestInput(input); - proposal.setEntityType(Constants.TEST_ENTITY_NAME); - proposal.setAspectName(Constants.TEST_INFO_ASPECT_NAME); - proposal.setAspect(GenericRecordUtils.serializeAspect(info)); - proposal.setChangeType(ChangeType.UPSERT); + // Create the Ingestion source key + final TestKey key = new TestKey(); + key.setId(uuidStr); + proposal.setEntityKeyAspect(GenericRecordUtils.serializeAspect(key)); + + if (_entityClient.exists(EntityKeyUtils.convertEntityKeyToUrn(key, Constants.TEST_ENTITY_NAME), context.getAuthentication())) { + throw new IllegalArgumentException("This Test already exists!"); + } + + // Create the Test info. + final TestInfo info = mapCreateTestInput(input); + proposal.setEntityType(Constants.TEST_ENTITY_NAME); + proposal.setAspectName(Constants.TEST_INFO_ASPECT_NAME); + proposal.setAspect(GenericRecordUtils.serializeAspect(info)); + proposal.setChangeType(ChangeType.UPSERT); - try { return _entityClient.ingestProposal(proposal, context.getAuthentication()); } catch (Exception e) { throw new RuntimeException(String.format("Failed to perform update against Test with urn %s", input.toString()), e); diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/CreateDomainResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/CreateDomainResolverTest.java index a1dbd4ae064dd7..8fce428519c4b5 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/CreateDomainResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/CreateDomainResolverTest.java @@ -1,16 +1,16 @@ package com.linkedin.datahub.graphql.resolvers.domain; -import com.linkedin.common.AuditStamp; -import com.linkedin.common.urn.UrnUtils; +import com.datahub.authentication.Authentication; import com.linkedin.datahub.graphql.QueryContext; import com.linkedin.datahub.graphql.generated.CreateDomainInput; import com.linkedin.domain.DomainProperties; +import com.linkedin.entity.client.EntityClient; import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.Constants; -import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.key.DomainKey; import com.linkedin.metadata.utils.GenericRecordUtils; import com.linkedin.mxe.MetadataChangeProposal; +import com.linkedin.r2.RemoteInvocationException; import graphql.schema.DataFetchingEnvironment; import java.util.concurrent.CompletionException; import org.mockito.Mockito; @@ -31,12 +31,8 @@ public class CreateDomainResolverTest { @Test public void testGetSuccess() throws Exception { // Create resolver - EntityService mockService = Mockito.mock(EntityService.class); - Mockito.when(mockService.ingestProposal(Mockito.any(MetadataChangeProposal.class), Mockito.any(AuditStamp.class))) - .thenReturn(new EntityService.IngestProposalResult(UrnUtils.getUrn( - String.format("urn:li:tag:%s", - TEST_INPUT.getId())), true)); - CreateDomainResolver resolver = new CreateDomainResolver(mockService); + EntityClient mockClient = Mockito.mock(EntityClient.class); + CreateDomainResolver resolver = new CreateDomainResolver(mockClient); // Execute resolver QueryContext mockContext = getMockAllowContext(); @@ -59,17 +55,17 @@ public void testGetSuccess() throws Exception { proposal.setChangeType(ChangeType.UPSERT); // Not ideal to match against "any", but we don't know the auto-generated execution request id - Mockito.verify(mockService, Mockito.times(1)).ingestProposal( + Mockito.verify(mockClient, Mockito.times(1)).ingestProposal( Mockito.eq(proposal), - Mockito.any(AuditStamp.class) + Mockito.any(Authentication.class) ); } @Test public void testGetUnauthorized() throws Exception { // Create resolver - EntityService mockService = Mockito.mock(EntityService.class); - CreateDomainResolver resolver = new CreateDomainResolver(mockService); + EntityClient mockClient = Mockito.mock(EntityClient.class); + CreateDomainResolver resolver = new CreateDomainResolver(mockClient); // Execute resolver DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); @@ -78,19 +74,19 @@ public void testGetUnauthorized() throws Exception { Mockito.when(mockEnv.getContext()).thenReturn(mockContext); assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); - Mockito.verify(mockService, Mockito.times(0)).ingestProposal( + Mockito.verify(mockClient, Mockito.times(0)).ingestProposal( Mockito.any(), - Mockito.any(AuditStamp.class)); + Mockito.any(Authentication.class)); } @Test public void testGetEntityClientException() throws Exception { // Create resolver - EntityService mockService = Mockito.mock(EntityService.class); - Mockito.doThrow(RuntimeException.class).when(mockService).ingestProposal( + EntityClient mockClient = Mockito.mock(EntityClient.class); + Mockito.doThrow(RemoteInvocationException.class).when(mockClient).ingestProposal( Mockito.any(), - Mockito.any(AuditStamp.class)); - CreateDomainResolver resolver = new CreateDomainResolver(mockService); + Mockito.any(Authentication.class)); + CreateDomainResolver resolver = new CreateDomainResolver(mockClient); // Execute resolver DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/tag/CreateTagResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/tag/CreateTagResolverTest.java index 91217dfc1e2e46..3079cfc074caa5 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/tag/CreateTagResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/tag/CreateTagResolverTest.java @@ -1,13 +1,12 @@ package com.linkedin.datahub.graphql.resolvers.tag; -import com.linkedin.common.AuditStamp; -import com.linkedin.common.urn.UrnUtils; +import com.datahub.authentication.Authentication; import com.linkedin.datahub.graphql.QueryContext; import com.linkedin.datahub.graphql.generated.CreateTagInput; +import com.linkedin.entity.client.EntityClient; import com.linkedin.tag.TagProperties; import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.Constants; -import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.key.TagKey; import com.linkedin.metadata.utils.GenericRecordUtils; import com.linkedin.mxe.MetadataChangeProposal; @@ -31,12 +30,10 @@ public class CreateTagResolverTest { @Test public void testGetSuccess() throws Exception { // Create resolver - EntityService mockService = Mockito.mock(EntityService.class); - Mockito.when(mockService.ingestProposal(Mockito.any(MetadataChangeProposal.class), Mockito.any(AuditStamp.class))) - .thenReturn(new EntityService.IngestProposalResult(UrnUtils.getUrn( - String.format("urn:li:tag:%s", - TEST_INPUT.getId())), true)); - CreateTagResolver resolver = new CreateTagResolver(mockService); + EntityClient mockClient = Mockito.mock(EntityClient.class); + Mockito.when(mockClient.ingestProposal(Mockito.any(MetadataChangeProposal.class), Mockito.any(Authentication.class))) + .thenReturn(String.format("urn:li:tag:%s", TEST_INPUT.getId())); + CreateTagResolver resolver = new CreateTagResolver(mockClient); // Execute resolver QueryContext mockContext = getMockAllowContext(); @@ -59,17 +56,17 @@ public void testGetSuccess() throws Exception { proposal.setChangeType(ChangeType.UPSERT); // Not ideal to match against "any", but we don't know the auto-generated execution request id - Mockito.verify(mockService, Mockito.times(1)).ingestProposal( + Mockito.verify(mockClient, Mockito.times(1)).ingestProposal( Mockito.eq(proposal), - Mockito.any(AuditStamp.class) + Mockito.any(Authentication.class) ); } @Test public void testGetUnauthorized() throws Exception { // Create resolver - EntityService mockService = Mockito.mock(EntityService.class); - CreateTagResolver resolver = new CreateTagResolver(mockService); + EntityClient mockClient = Mockito.mock(EntityClient.class); + CreateTagResolver resolver = new CreateTagResolver(mockClient); // Execute resolver DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); @@ -78,19 +75,19 @@ public void testGetUnauthorized() throws Exception { Mockito.when(mockEnv.getContext()).thenReturn(mockContext); assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); - Mockito.verify(mockService, Mockito.times(0)).ingestProposal( + Mockito.verify(mockClient, Mockito.times(0)).ingestProposal( Mockito.any(), - Mockito.any(AuditStamp.class)); + Mockito.any(Authentication.class)); } @Test public void testGetEntityClientException() throws Exception { // Create resolver - EntityService mockService = Mockito.mock(EntityService.class); - Mockito.doThrow(RuntimeException.class).when(mockService).ingestProposal( + EntityClient mockClient = Mockito.mock(EntityClient.class); + Mockito.doThrow(RuntimeException.class).when(mockClient).ingestProposal( Mockito.any(), - Mockito.any(AuditStamp.class)); - CreateTagResolver resolver = new CreateTagResolver(mockService); + Mockito.any(Authentication.class)); + CreateTagResolver resolver = new CreateTagResolver(mockClient); // Execute resolver DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); diff --git a/datahub-web-react/src/app/domain/CreateDomainModal.tsx b/datahub-web-react/src/app/domain/CreateDomainModal.tsx index 984c226d365079..3306104c1698d8 100644 --- a/datahub-web-react/src/app/domain/CreateDomainModal.tsx +++ b/datahub-web-react/src/app/domain/CreateDomainModal.tsx @@ -16,14 +16,13 @@ const ClickableTag = styled(Tag)` `; type Props = { - visible: boolean; onClose: () => void; onCreate: (id: string | undefined, name: string, description: string) => void; }; const SUGGESTED_DOMAIN_NAMES = ['Engineering', 'Marketing', 'Sales', 'Product']; -export default function CreateDomainModal({ visible, onClose, onCreate }: Props) { +export default function CreateDomainModal({ onClose, onCreate }: Props) { const [stagedName, setStagedName] = useState(''); const [stagedDescription, setStagedDescription] = useState(''); const [stagedId, setStagedId] = useState(undefined); @@ -66,7 +65,7 @@ export default function CreateDomainModal({ visible, onClose, onCreate }: Props) return ( diff --git a/datahub-web-react/src/app/domain/DomainListItem.tsx b/datahub-web-react/src/app/domain/DomainListItem.tsx index c9f82375bb0d09..1dc56aae2dff34 100644 --- a/datahub-web-react/src/app/domain/DomainListItem.tsx +++ b/datahub-web-react/src/app/domain/DomainListItem.tsx @@ -6,6 +6,8 @@ import { IconStyleType } from '../entity/Entity'; import { Domain, EntityType } from '../../types.generated'; import { useEntityRegistry } from '../useEntityRegistry'; import AvatarsGroup from '../shared/avatar/AvatarsGroup'; +import EntityDropdown from '../entity/shared/EntityDropdown'; +import { EntityMenuItems } from '../entity/shared/EntityDropdown/EntityDropdown'; const DomainItemContainer = styled.div` display: flex; @@ -28,9 +30,10 @@ const DomainNameContainer = styled.div` type Props = { domain: Domain; + onDelete?: () => void; }; -export default function DomainListItem({ domain }: Props) { +export default function DomainListItem({ domain, onDelete }: Props) { const entityRegistry = useEntityRegistry(); const displayName = entityRegistry.getDisplayName(EntityType.Domain, domain); const logoIcon = entityRegistry.getIcon(EntityType.Domain, 12, IconStyleType.ACCENT); @@ -54,6 +57,14 @@ export default function DomainListItem({ domain }: Props) { {owners && owners.length > 0 && ( )} + ); diff --git a/datahub-web-react/src/app/domain/DomainsList.tsx b/datahub-web-react/src/app/domain/DomainsList.tsx index 4c2d97dbeba691..e689dc5bc5ac57 100644 --- a/datahub-web-react/src/app/domain/DomainsList.tsx +++ b/datahub-web-react/src/app/domain/DomainsList.tsx @@ -50,6 +50,7 @@ export const DomainsList = () => { const [page, setPage] = useState(1); const [isCreatingDomain, setIsCreatingDomain] = useState(false); + const [removedUrns, setRemovedUrns] = useState([]); const pageSize = DEFAULT_PAGE_SIZE; const start = (page - 1) * pageSize; @@ -70,13 +71,20 @@ export const DomainsList = () => { const domains = (data?.listDomains?.domains || []).sort( (a, b) => (b.entities?.total || 0) - (a.entities?.total || 0), ); + const filteredDomains = domains.filter((domain) => !removedUrns.includes(domain.urn)); const onChangePage = (newPage: number) => { setPage(newPage); }; - // TODO: Handle robust deleting of domains. - + const handleDelete = (urn: string) => { + // Hack to deal with eventual consistency. + const newRemovedUrns = [...removedUrns, urn]; + setRemovedUrns(newRemovedUrns); + setTimeout(function () { + refetch?.(); + }, 3000); + }; return ( <> {!data && loading && } @@ -110,8 +118,10 @@ export const DomainsList = () => { locale={{ emptyText: , }} - dataSource={domains} - renderItem={(item: any) => } + dataSource={filteredDomains} + renderItem={(item: any) => ( + handleDelete(item.urn)} /> + )} /> @@ -130,16 +140,17 @@ export const DomainsList = () => { /> - setIsCreatingDomain(false)} - onCreate={() => { - // Hack to deal with eventual consistency. - setTimeout(function () { - refetch?.(); - }, 2000); - }} - /> + {isCreatingDomain && ( + setIsCreatingDomain(false)} + onCreate={() => { + // Hack to deal with eventual consistency. + setTimeout(function () { + refetch?.(); + }, 2000); + }} + /> + )} ); diff --git a/datahub-web-react/src/app/entity/domain/DomainEntity.tsx b/datahub-web-react/src/app/entity/domain/DomainEntity.tsx index fad78c63139bf5..971b5edb266778 100644 --- a/datahub-web-react/src/app/entity/domain/DomainEntity.tsx +++ b/datahub-web-react/src/app/entity/domain/DomainEntity.tsx @@ -64,7 +64,7 @@ export class DomainEntity implements Entity { useEntityQuery={useGetDomainQuery} useUpdateQuery={undefined} getOverrideProperties={this.getOverridePropertiesFromEntity} - headerDropdownItems={new Set([EntityMenuItems.COPY_URL])} + headerDropdownItems={new Set([EntityMenuItems.COPY_URL, EntityMenuItems.DELETE])} isNameEditable tabs={[ { diff --git a/datahub-web-react/src/app/entity/shared/EntityDropdown/AddDeprecationDetailsModal.tsx b/datahub-web-react/src/app/entity/shared/EntityDropdown/AddDeprecationDetailsModal.tsx index 6fba13d1648500..3dc4b072a314a4 100644 --- a/datahub-web-react/src/app/entity/shared/EntityDropdown/AddDeprecationDetailsModal.tsx +++ b/datahub-web-react/src/app/entity/shared/EntityDropdown/AddDeprecationDetailsModal.tsx @@ -6,7 +6,7 @@ type Props = { urn: string; visible: boolean; onClose: () => void; - refetch?: () => Promise; + refetch?: () => void; }; export const AddDeprecationDetailsModal = ({ urn, visible, onClose, refetch }: Props) => { 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 0f46d1f42e93e9..0a0cbb885e8198 100644 --- a/datahub-web-react/src/app/entity/shared/EntityDropdown/EntityDropdown.tsx +++ b/datahub-web-react/src/app/entity/shared/EntityDropdown/EntityDropdown.tsx @@ -14,14 +14,13 @@ import { Redirect } from 'react-router'; import { EntityType, PlatformPrivileges } from '../../../../types.generated'; import CreateGlossaryEntityModal from './CreateGlossaryEntityModal'; import { AddDeprecationDetailsModal } from './AddDeprecationDetailsModal'; -import { useEntityData, useRefetch } from '../EntityContext'; import { useUpdateDeprecationMutation } from '../../../../graphql/mutations.generated'; import MoveGlossaryEntityModal from './MoveGlossaryEntityModal'; -import useDeleteGlossaryEntity from './useDeleteGlossaryEntity'; -import { PageRoutes } from '../../../../conf/Global'; import { ANTD_GRAY } from '../constants'; import { useEntityRegistry } from '../../../useEntityRegistry'; import { useGetAuthenticatedUser } from '../../../useGetAuthenticatedUser'; +import useDeleteEntity from './useDeleteEntity'; +import { getEntityProfileDeleteRedirectPath } from '../../../shared/deleteUtils'; export enum EntityMenuItems { COPY_URL, @@ -32,11 +31,11 @@ export enum EntityMenuItems { MOVE, } -const MenuIcon = styled(MoreOutlined)` +const MenuIcon = styled(MoreOutlined)<{ fontSize?: number }>` display: flex; justify-content: center; align-items: center; - font-size: 25px; + font-size: ${(props) => props.fontSize || '24'}px; height: 32px; margin-left: 5px; `; @@ -59,22 +58,38 @@ const StyledMenuItem = styled(Menu.Item)<{ disabled: boolean }>` `; interface Props { + urn: string; + entityType: EntityType; + entityData?: any; menuItems: Set; platformPrivileges?: PlatformPrivileges; + size?: number; + refetchForEntity?: () => void; refetchForTerms?: () => void; refetchForNodes?: () => void; refreshBrowser?: () => void; + onDeleteEntity?: () => void; } function EntityDropdown(props: Props) { - const { menuItems, platformPrivileges, refetchForTerms, refetchForNodes, refreshBrowser } = props; + const { + urn, + entityData, + entityType, + menuItems, + platformPrivileges, + refetchForEntity, + refetchForTerms, + refetchForNodes, + refreshBrowser, + onDeleteEntity: onDelete, + size, + } = props; const entityRegistry = useEntityRegistry(); - const { urn, entityData, entityType } = useEntityData(); - const refetch = useRefetch(); const me = useGetAuthenticatedUser(!!platformPrivileges); const [updateDeprecation] = useUpdateDeprecationMutation(); - const { onDeleteEntity, hasBeenDeleted } = useDeleteGlossaryEntity(); + const { onDeleteEntity, hasBeenDeleted } = useDeleteEntity(urn, entityType, entityData, onDelete); const [isCreateTermModalVisible, setIsCreateTermModalVisible] = useState(false); const [isCreateNodeModalVisible, setIsCreateNodeModalVisible] = useState(false); @@ -102,7 +117,7 @@ function EntityDropdown(props: Props) { message.error({ content: `Failed to update Deprecation: \n ${e.message || ''}`, duration: 2 }); } } - refetch?.(); + refetchForEntity?.(); }; const canManageGlossaries = platformPrivileges @@ -111,6 +126,11 @@ function EntityDropdown(props: Props) { const pageUrl = window.location.href; const isDeleteDisabled = !!entityData?.children?.count; + /** + * A default path to redirect to if the entity is deleted. + */ + const deleteRedirectPath = getEntityProfileDeleteRedirectPath(entityType); + return ( <> - + {isCreateTermModalVisible && ( setIsDeprecationModalVisible(false)} - refetch={refetch} + refetch={refetchForEntity} /> )} {isMoveModalVisible && ( setIsMoveModalVisible(false)} refetchData={refreshBrowser} /> )} - {hasBeenDeleted && } + {hasBeenDeleted && !onDelete && deleteRedirectPath && } ); } diff --git a/datahub-web-react/src/app/entity/shared/EntityDropdown/useDeleteEntity.tsx b/datahub-web-react/src/app/entity/shared/EntityDropdown/useDeleteEntity.tsx new file mode 100644 index 00000000000000..3cbbb368878f7e --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/EntityDropdown/useDeleteEntity.tsx @@ -0,0 +1,66 @@ +import { useState } from 'react'; +import { message, Modal } from 'antd'; +import { EntityType } from '../../../../types.generated'; +import { useEntityRegistry } from '../../../useEntityRegistry'; +import { getDeleteEntityMutation } from '../../../shared/deleteUtils'; + +/** + * Performs the flow for deleting an entity of a given type. + * + * @param urn the type of the entity to delete + * @param type the type of the entity to delete + * @param name the name of the entity to delete + */ +function useDeleteEntity(urn: string, type: EntityType, entityData: any, onDelete?: () => void) { + const [hasBeenDeleted, setHasBeenDeleted] = useState(false); + const entityRegistry = useEntityRegistry(); + + const maybeDeleteEntity = getDeleteEntityMutation(type)(); + const deleteEntity = (maybeDeleteEntity && maybeDeleteEntity[0]) || undefined; + + function handleDeleteEntity() { + deleteEntity?.({ + variables: { + urn, + }, + }) + .then(() => { + message.loading({ + content: 'Deleting...', + duration: 2, + }); + setTimeout(() => { + setHasBeenDeleted(true); + onDelete?.(); + message.success({ + content: `Deleted ${entityRegistry.getEntityName(type)}!`, + duration: 2, + }); + }, 2000); + }) + .catch((e) => { + message.destroy(); + message.error({ content: `Failed to delete: \n ${e.message || ''}`, duration: 3 }); + }); + } + + function onDeleteEntity() { + Modal.confirm({ + title: `Delete ${ + (entityData && entityRegistry.getDisplayName(type, entityData)) || entityRegistry.getEntityName(type) + }`, + content: `Are you sure you want to remove this ${entityRegistry.getEntityName(type)}?`, + onOk() { + handleDeleteEntity(); + }, + onCancel() {}, + okText: 'Yes', + maskClosable: true, + closable: true, + }); + } + + return { onDeleteEntity, hasBeenDeleted }; +} + +export default useDeleteEntity; diff --git a/datahub-web-react/src/app/entity/shared/containers/profile/header/EntityHeader.tsx b/datahub-web-react/src/app/entity/shared/containers/profile/header/EntityHeader.tsx index fb7d22a13b3502..7bd977d3e36c3e 100644 --- a/datahub-web-react/src/app/entity/shared/containers/profile/header/EntityHeader.tsx +++ b/datahub-web-react/src/app/entity/shared/containers/profile/header/EntityHeader.tsx @@ -5,7 +5,7 @@ import styled from 'styled-components/macro'; import moment from 'moment'; import { capitalizeFirstLetterOnly } from '../../../../../shared/textUtil'; import { ANTD_GRAY } from '../../../constants'; -import { useEntityData } from '../../../EntityContext'; +import { useEntityData, useRefetch } from '../../../EntityContext'; import analytics, { EventType, EntityActionType } from '../../../../../analytics'; import { EntityHealthStatus } from './EntityHealthStatus'; import { getLocaleTimezone } from '../../../../../shared/time/timeUtils'; @@ -120,6 +120,7 @@ type Props = { export const EntityHeader = ({ refreshBrowser, headerDropdownItems, isNameEditable }: Props) => { const { urn, entityType, entityData } = useEntityData(); + const refetch = useRefetch(); const me = useGetAuthenticatedUser(); const [copiedUrn, setCopiedUrn] = useState(false); const basePlatformName = getPlatformName(entityData); @@ -209,7 +210,11 @@ export const EntityHeader = ({ refreshBrowser, headerDropdownItems, isNameEditab setCopiedUrn(true)} /> {headerDropdownItems && ( diff --git a/datahub-web-react/src/app/glossary/BusinessGlossaryPage.tsx b/datahub-web-react/src/app/glossary/BusinessGlossaryPage.tsx index 788062abc02bff..8c6f7f3c9bf6b7 100644 --- a/datahub-web-react/src/app/glossary/BusinessGlossaryPage.tsx +++ b/datahub-web-react/src/app/glossary/BusinessGlossaryPage.tsx @@ -11,6 +11,7 @@ import GlossaryBrowser from './GlossaryBrowser/GlossaryBrowser'; import GlossarySearch from './GlossarySearch'; import { ProfileSidebarResizer } from '../entity/shared/containers/profile/sidebar/ProfileSidebarResizer'; import EmptyGlossarySection from './EmptyGlossarySection'; +import { EntityType } from '../../types.generated'; export const HeaderWrapper = styled(TabToolbar)` padding: 15px 45px 10px 24px; @@ -65,7 +66,12 @@ function BusinessGlossaryPage() { Glossary + { + // This is a hack -- TODO: Generalize EntityDropdown to support non-entity related items. + } void; onCreate: (name: string, description: string) => void; }; -export default function CreateGroupModal({ visible, onClose, onCreate }: Props) { +export default function CreateGroupModal({ onClose, onCreate }: Props) { const [stagedName, setStagedName] = useState(''); const [stagedDescription, setStagedDescription] = useState(''); const [stagedId, setStagedId] = useState(undefined); @@ -52,7 +51,7 @@ export default function CreateGroupModal({ visible, onClose, onCreate }: Props) return ( diff --git a/datahub-web-react/src/app/identity/group/GroupList.tsx b/datahub-web-react/src/app/identity/group/GroupList.tsx index aa7eef5c1355b1..157a9b85a0441d 100644 --- a/datahub-web-react/src/app/identity/group/GroupList.tsx +++ b/datahub-web-react/src/app/identity/group/GroupList.tsx @@ -68,6 +68,7 @@ export const GroupList = () => { // Hack to deal with eventual consistency. const newRemovedUrns = [...removedUrns, urn]; setRemovedUrns(newRemovedUrns); + console.log(newRemovedUrns); setTimeout(function () { refetch?.(); }, 3000); @@ -122,16 +123,17 @@ export const GroupList = () => { showSizeChanger={false} /> - setIsCreatingGroup(false)} - onCreate={() => { - // Hack to deal with eventual consistency. - setTimeout(function () { - refetch?.(); - }, 2000); - }} - /> + {isCreatingGroup && ( + setIsCreatingGroup(false)} + onCreate={() => { + // Hack to deal with eventual consistency. + setTimeout(function () { + refetch?.(); + }, 2000); + }} + /> + )} ); diff --git a/datahub-web-react/src/app/identity/group/GroupListItem.tsx b/datahub-web-react/src/app/identity/group/GroupListItem.tsx index 30a1f80d962b0f..1a9654d1e93e95 100644 --- a/datahub-web-react/src/app/identity/group/GroupListItem.tsx +++ b/datahub-web-react/src/app/identity/group/GroupListItem.tsx @@ -1,12 +1,12 @@ import React from 'react'; import styled from 'styled-components'; -import { Button, List, message, Modal, Tag, Typography } from 'antd'; +import { List, Tag, Typography } from 'antd'; import { Link } from 'react-router-dom'; -import { DeleteOutlined } from '@ant-design/icons'; import { CorpGroup, EntityType } from '../../../types.generated'; import CustomAvatar from '../../shared/avatar/CustomAvatar'; import { useEntityRegistry } from '../../useEntityRegistry'; -import { useRemoveGroupMutation } from '../../../graphql/group.generated'; +import EntityDropdown from '../../entity/shared/EntityDropdown'; +import { EntityMenuItems } from '../../entity/shared/EntityDropdown/EntityDropdown'; type Props = { group: CorpGroup; @@ -30,38 +30,6 @@ const GroupHeaderContainer = styled.div` export default function GroupListItem({ group, onDelete }: Props) { const entityRegistry = useEntityRegistry(); const displayName = entityRegistry.getDisplayName(EntityType.CorpGroup, group); - - const [removeGroupMutation] = useRemoveGroupMutation(); - - const onRemoveGroup = async (urn: string) => { - try { - await removeGroupMutation({ - variables: { urn }, - }); - message.success({ content: 'Removed group.', duration: 2 }); - } catch (e: unknown) { - message.destroy(); - if (e instanceof Error) { - message.error({ content: `Failed to remove group: \n ${e.message || ''}`, duration: 3 }); - } - } - onDelete?.(); - }; - - const handleRemoveGroup = (urn: string) => { - Modal.confirm({ - title: `Confirm Group Removal`, - content: `Are you sure you want to remove this group?`, - onOk() { - onRemoveGroup(urn); - }, - onCancel() {}, - okText: 'Yes', - maskClosable: true, - closable: true, - }); - }; - return ( @@ -79,11 +47,14 @@ export default function GroupListItem({ group, onDelete }: Props) { {(group as any).memberCount?.total || 0} members -
- -
+
); diff --git a/datahub-web-react/src/app/identity/user/UserListItem.tsx b/datahub-web-react/src/app/identity/user/UserListItem.tsx index 2080dc2582ca1d..035c49d08783a8 100644 --- a/datahub-web-react/src/app/identity/user/UserListItem.tsx +++ b/datahub-web-react/src/app/identity/user/UserListItem.tsx @@ -1,14 +1,14 @@ import React, { useState } from 'react'; import styled from 'styled-components'; -import { Button, Dropdown, List, Menu, message, Modal, Tag, Tooltip, Typography } from 'antd'; +import { Dropdown, List, Menu, Tag, Tooltip, Typography } from 'antd'; import { Link } from 'react-router-dom'; import { DeleteOutlined, MoreOutlined, UnlockOutlined } from '@ant-design/icons'; import { CorpUser, CorpUserStatus, EntityType } from '../../../types.generated'; import CustomAvatar from '../../shared/avatar/CustomAvatar'; import { useEntityRegistry } from '../../useEntityRegistry'; -import { useRemoveUserMutation } from '../../../graphql/user.generated'; import { ANTD_GRAY, REDESIGN_COLORS } from '../../entity/shared/constants'; import ViewResetTokenModal from './ViewResetTokenModal'; +import useDeleteEntity from '../../entity/shared/EntityDropdown/useDeleteEntity'; type Props = { user: CorpUser; @@ -36,6 +36,15 @@ const ButtonGroup = styled.div` align-items: center; `; +const MenuIcon = styled(MoreOutlined)<{ fontSize?: number }>` + display: flex; + justify-content: center; + align-items: center; + font-size: ${(props) => props.fontSize || '24'}px; + height: 32px; + margin-left: 5px; +`; + export default function UserListItem({ user, canManageUserCredentials, onDelete }: Props) { const entityRegistry = useEntityRegistry(); const [isViewingResetToken, setIsViewingResetToken] = useState(false); @@ -43,36 +52,7 @@ export default function UserListItem({ user, canManageUserCredentials, onDelete const isNativeUser: boolean = user.isNativeUser as boolean; const shouldShowPasswordReset: boolean = canManageUserCredentials && isNativeUser; - const [removeUserMutation] = useRemoveUserMutation(); - - const onRemoveUser = async (urn: string) => { - try { - await removeUserMutation({ - variables: { urn }, - }); - message.success({ content: 'Removed user.', duration: 2 }); - } catch (e: unknown) { - message.destroy(); - if (e instanceof Error) { - message.error({ content: `Failed to remove user: \n ${e.message || ''}`, duration: 3 }); - } - } - onDelete?.(); - }; - - const handleRemoveUser = (urn: string) => { - Modal.confirm({ - title: `Confirm User Removal`, - content: `Are you sure you want to remove this user? Note that if you have SSO auto provisioning enabled, this user will be created when they log in again.`, - onOk() { - onRemoveUser(urn); - }, - onCancel() {}, - okText: 'Yes', - maskClosable: true, - closable: true, - }); - }; + const { onDeleteEntity } = useDeleteEntity(user.urn, EntityType.CorpUser, user, onDelete); const getUserStatusToolTip = (userStatus: CorpUserStatus) => { switch (userStatus) { @@ -130,14 +110,14 @@ export default function UserListItem({ user, canManageUserCredentials, onDelete setIsViewingResetToken(true)}>   Reset user password + +  Delete + } > - + - setCopiedUrn(true)} /> - + {displayColorPicker && ( diff --git a/datahub-web-react/src/app/shared/deleteUtils.ts b/datahub-web-react/src/app/shared/deleteUtils.ts new file mode 100644 index 00000000000000..a1ff5c845172c2 --- /dev/null +++ b/datahub-web-react/src/app/shared/deleteUtils.ts @@ -0,0 +1,56 @@ +import { useDeleteAssertionMutation } from '../../graphql/assertion.generated'; +import { useDeleteDomainMutation } from '../../graphql/domain.generated'; +import { useDeleteGlossaryEntityMutation } from '../../graphql/glossary.generated'; +import { useRemoveGroupMutation } from '../../graphql/group.generated'; +import { useDeleteTagMutation } from '../../graphql/tag.generated'; +import { useRemoveUserMutation } from '../../graphql/user.generated'; +import { EntityType } from '../../types.generated'; + +/** + * Returns a relative redirect path which is used after an Entity has been deleted from it's profile page. + * + * @param type the entity type being deleted + */ +export const getEntityProfileDeleteRedirectPath = (type: EntityType) => { + switch (type) { + case EntityType.CorpGroup: + case EntityType.CorpUser: + case EntityType.Domain: + case EntityType.Tag: + // Return Home. + return '/'; + case EntityType.GlossaryNode: + case EntityType.GlossaryTerm: + // Return to glossary page. + return '/glossary'; + default: + return () => undefined; + } +}; + +/** + * Returns a mutation hook for deleting an entity of a particular type. + * + * TODO: Push this back into the entity registry. + * + * @param type the entity type being deleted + */ +export const getDeleteEntityMutation = (type: EntityType) => { + switch (type) { + case EntityType.CorpGroup: + return useRemoveGroupMutation; + case EntityType.CorpUser: + return useRemoveUserMutation; + case EntityType.Assertion: + return useDeleteAssertionMutation; + case EntityType.Domain: + return useDeleteDomainMutation; + case EntityType.Tag: + return useDeleteTagMutation; + case EntityType.GlossaryNode: + case EntityType.GlossaryTerm: + return useDeleteGlossaryEntityMutation; + default: + return () => undefined; + } +}; diff --git a/datahub-web-react/src/graphql/domain.graphql b/datahub-web-react/src/graphql/domain.graphql index 098448993925d5..13c86ea1c1d4cf 100644 --- a/datahub-web-react/src/graphql/domain.graphql +++ b/datahub-web-react/src/graphql/domain.graphql @@ -53,4 +53,8 @@ query listDomains($input: ListDomainsInput!) { mutation createDomain($input: CreateDomainInput!) { createDomain(input: $input) -} \ No newline at end of file +} + +mutation deleteDomain($urn: String!) { + deleteDomain(urn: $urn) +} diff --git a/datahub-web-react/src/graphql/tag.graphql b/datahub-web-react/src/graphql/tag.graphql index e2404123d1205b..f77685a3d08388 100644 --- a/datahub-web-react/src/graphql/tag.graphql +++ b/datahub-web-react/src/graphql/tag.graphql @@ -4,9 +4,9 @@ query getTag($urn: String!) { name description properties { - name - description - colorHex + name + description + colorHex } ownership { ...ownershipFields @@ -23,4 +23,8 @@ mutation updateTag($urn: String!, $input: TagUpdateInput!) { ...ownershipFields } } -} \ No newline at end of file +} + +mutation deleteTag($urn: String!) { + deleteTag(urn: $urn) +} diff --git a/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityService.java b/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityService.java index 5fd7c428750e73..4147560b7281c1 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityService.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityService.java @@ -722,6 +722,17 @@ protected RecordTemplate sendEventForUpdateAspectResult(@Nonnull final Urn urn, return updatedValue; } + /** + * Ingest a new {@link MetadataChangeProposal}. Note that this method does NOT include any additional aspects or do any + * enrichment, instead it changes only those which are provided inside the metadata change proposal. + * + * Do not use this method directly for creating new entities, as it DOES NOT create an Entity Key aspect in the DB. Instead, + * use an Entity Client. + * + * @param metadataChangeProposal the proposal to ingest + * @param auditStamp an audit stamp representing the time and actor proposing the change + * @return an {@link IngestProposalResult} containing the results + */ public IngestProposalResult ingestProposal(@Nonnull MetadataChangeProposal metadataChangeProposal, AuditStamp auditStamp) { @@ -1035,10 +1046,10 @@ protected Map> getLatestAspectUnions( } /** - Returns true if entityType should have some aspect as per its definition + Returns true if entityType should have some aspect as per its definition but aspects given does not have that aspect */ - private boolean isAspectProvided(String entityType, String aspectName, Set aspects) { + private boolean isAspectMissing(String entityType, String aspectName, Set aspects) { return _entityRegistry.getEntitySpec(entityType).getAspectSpecMap().containsKey(aspectName) && !aspects.contains(aspectName); } @@ -1049,17 +1060,17 @@ public List> generateDefaultAspectsIfMissing(@Nonnu Set aspectsToGet = new HashSet<>(); String entityType = urnToEntityName(urn); - boolean shouldCheckBrowsePath = isAspectProvided(entityType, BROWSE_PATHS, includedAspects); + boolean shouldCheckBrowsePath = isAspectMissing(entityType, BROWSE_PATHS, includedAspects); if (shouldCheckBrowsePath) { aspectsToGet.add(BROWSE_PATHS); } - boolean shouldCheckDataPlatform = isAspectProvided(entityType, DATA_PLATFORM_INSTANCE, includedAspects); + boolean shouldCheckDataPlatform = isAspectMissing(entityType, DATA_PLATFORM_INSTANCE, includedAspects); if (shouldCheckDataPlatform) { aspectsToGet.add(DATA_PLATFORM_INSTANCE); } - boolean shouldHaveStatusSet = isAspectProvided(entityType, STATUS, includedAspects); + boolean shouldHaveStatusSet = isAspectMissing(entityType, STATUS, includedAspects); if (shouldHaveStatusSet) { aspectsToGet.add(STATUS); } diff --git a/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/EntityClient.java b/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/EntityClient.java index 1ebe7fe3674b14..6bdf8cafa65e66 100644 --- a/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/EntityClient.java +++ b/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/EntityClient.java @@ -250,6 +250,17 @@ public void deleteEntityReferences(@Nonnull final Urn urn, @Nonnull final Authen public SearchResult filter(@Nonnull String entity, @Nonnull Filter filter, @Nullable SortCriterion sortCriterion, int start, int count, @Nonnull Authentication authentication) throws RemoteInvocationException; + /** + * Checks whether an entity with a given urn exists + * + * @param urn the urn of the entity + * @return true if an entity exists, i.e. there are > 0 aspects in the DB for the entity. This means that the entity + * has not been hard-deleted. + * @throws RemoteInvocationException + */ + @Nonnull + public boolean exists(@Nonnull Urn urn, @Nonnull Authentication authentication) throws RemoteInvocationException; + @Nullable @Deprecated public VersionedAspect getAspect(@Nonnull String urn, @Nonnull String aspect, @Nonnull Long version, @@ -296,6 +307,4 @@ public DataMap getRawAspect(@Nonnull String urn, @Nonnull String aspect, @Nonnul public void producePlatformEvent(@Nonnull String name, @Nullable String key, @Nonnull PlatformEvent event, @Nonnull Authentication authentication) throws Exception; - - Boolean exists(Urn urn, @Nonnull Authentication authentication) throws RemoteInvocationException; } diff --git a/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/JavaEntityClient.java b/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/JavaEntityClient.java index d2ca0906569c8a..b074dda3f123b5 100644 --- a/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/JavaEntityClient.java +++ b/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/JavaEntityClient.java @@ -365,6 +365,12 @@ public SearchResult filter(@Nonnull String entity, @Nonnull Filter filter, @Null return _entitySearchService.filter(entity, filter, sortCriterion, start, count); } + @Nonnull + @Override + public boolean exists(@Nonnull Urn urn, @Nonnull final Authentication authentication) throws RemoteInvocationException { + return _entityService.exists(urn); + } + @SneakyThrows @Override public VersionedAspect getAspect(@Nonnull String urn, @Nonnull String aspect, @Nonnull Long version, @@ -463,9 +469,4 @@ public void producePlatformEvent( @Nonnull Authentication authentication) throws Exception { _eventProducer.producePlatformEvent(name, key, event); } - - @Override - public Boolean exists(Urn urn, @Nonnull Authentication authentication) throws RemoteInvocationException { - return _entityService.exists(urn); - } } diff --git a/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/RestliEntityClient.java b/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/RestliEntityClient.java index d580d4f2392da1..0de87f0620c61e 100644 --- a/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/RestliEntityClient.java +++ b/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/RestliEntityClient.java @@ -514,6 +514,14 @@ public SearchResult filter(@Nonnull String entity, @Nonnull Filter filter, @Null return sendClientRequest(requestBuilder, authentication).getEntity(); } + @Nonnull + @Override + public boolean exists(@Nonnull Urn urn, @Nonnull final Authentication authentication) throws RemoteInvocationException { + EntitiesDoExistsRequestBuilder requestBuilder = ENTITIES_REQUEST_BUILDERS.actionExists() + .urnParam(urn.toString()); + return sendClientRequest(requestBuilder, authentication).getEntity(); + } + /** * Gets aspect at version for an entity * @@ -663,11 +671,4 @@ public void producePlatformEvent(@Nonnull String name, @Nullable String key, @No } sendClientRequest(requestBuilder, authentication); } - - @Override - public Boolean exists(Urn urn, @Nonnull Authentication authentication) throws RemoteInvocationException { - final EntitiesDoExistsRequestBuilder requestBuilder = - ENTITIES_REQUEST_BUILDERS.actionExists().urnParam(urn.toString()); - return sendClientRequest(requestBuilder, authentication).getEntity(); - } } diff --git a/metadata-service/restli-servlet-impl/src/main/java/com/linkedin/metadata/resources/entity/EntityResource.java b/metadata-service/restli-servlet-impl/src/main/java/com/linkedin/metadata/resources/entity/EntityResource.java index c0f2e2200ba49d..0d00c6bc9acf44 100644 --- a/metadata-service/restli-servlet-impl/src/main/java/com/linkedin/metadata/resources/entity/EntityResource.java +++ b/metadata-service/restli-servlet-impl/src/main/java/com/linkedin/metadata/resources/entity/EntityResource.java @@ -84,12 +84,14 @@ public class EntityResource extends CollectionResourceTaskTemplate