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 6206548c2d..98b27aa3ff 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 @@ -168,6 +168,7 @@ import com.linkedin.datahub.graphql.resolvers.mutate.RemoveTagResolver; import com.linkedin.datahub.graphql.resolvers.mutate.RemoveTermResolver; import com.linkedin.datahub.graphql.resolvers.mutate.UpdateDescriptionResolver; import com.linkedin.datahub.graphql.resolvers.mutate.UpdateDisplayPropertiesResolver; +import com.linkedin.datahub.graphql.resolvers.mutate.UpdateLinkResolver; import com.linkedin.datahub.graphql.resolvers.mutate.UpdateNameResolver; import com.linkedin.datahub.graphql.resolvers.mutate.UpdateParentNodeResolver; import com.linkedin.datahub.graphql.resolvers.mutate.UpdateUserSettingResolver; @@ -1205,6 +1206,7 @@ public class GmsGraphQLEngine { .dataFetcher( "batchRemoveOwners", new BatchRemoveOwnersResolver(entityService, entityClient)) .dataFetcher("addLink", new AddLinkResolver(entityService, this.entityClient)) + .dataFetcher("updateLink", new UpdateLinkResolver(entityService, this.entityClient)) .dataFetcher("removeLink", new RemoveLinkResolver(entityService, entityClient)) .dataFetcher("addGroupMembers", new AddGroupMembersResolver(this.groupService)) .dataFetcher("removeGroupMembers", new RemoveGroupMembersResolver(this.groupService)) diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/RemoveLinkResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/RemoveLinkResolver.java index 584a0e3e9c..7dc620fcaa 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/RemoveLinkResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/RemoveLinkResolver.java @@ -32,6 +32,7 @@ public class RemoveLinkResolver implements DataFetcher> { + + private final EntityService _entityService; + private final EntityClient _entityClient; + + @Override + public CompletableFuture get(DataFetchingEnvironment environment) throws Exception { + final QueryContext context = environment.getContext(); + final UpdateLinkInput input = + bindArgument(environment.getArgument("input"), UpdateLinkInput.class); + + String currentLinkUrl = input.getCurrentUrl(); + String currentLinkLabel = input.getCurrentLabel(); + String linkUrl = input.getLinkUrl(); + String linkLabel = input.getLabel(); + Urn targetUrn = Urn.createFromString(input.getResourceUrn()); + + if (!LinkUtils.isAuthorizedToUpdateLinks(context, targetUrn) + && !GlossaryUtils.canUpdateGlossaryEntity(targetUrn, context, _entityClient)) { + throw new AuthorizationException( + "Unauthorized to perform this action. Please contact your DataHub administrator."); + } + + return GraphQLConcurrencyUtils.supplyAsync( + () -> { + LinkUtils.validateUpdateInput( + context.getOperationContext(), currentLinkUrl, linkUrl, targetUrn, _entityService); + try { + + log.debug("Updating Link. input: {}", input.toString()); + + Urn actor = CorpuserUrn.createFromString(context.getActorUrn()); + LinkUtils.updateLink( + context.getOperationContext(), + currentLinkUrl, + currentLinkLabel, + linkUrl, + linkLabel, + targetUrn, + actor, + _entityService); + return true; + } catch (Exception e) { + log.error( + "Failed to update link to resource with input {}, {}", + input.toString(), + e.getMessage()); + throw new RuntimeException( + String.format("Failed to update link to resource with input %s", input.toString()), + e); + } + }, + this.getClass().getSimpleName(), + "get"); + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/LinkUtils.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/LinkUtils.java index e6f9d09412..abdfc10a97 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/LinkUtils.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/LinkUtils.java @@ -17,6 +17,8 @@ import com.linkedin.metadata.authorization.PoliciesConfig; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.entity.EntityUtils; import io.datahubproject.metadata.context.OperationContext; +import java.util.Optional; +import java.util.function.Predicate; import javax.annotation.Nonnull; import lombok.extern.slf4j.Slf4j; @@ -53,9 +55,12 @@ public class LinkUtils { entityService); } - public static void removeLink( + public static void updateLink( @Nonnull OperationContext opContext, - String linkUrl, + String currentLinkUrl, + String currentLinkLabel, + String newLinkUrl, + String newLinkLabel, Urn resourceUrn, Urn actor, EntityService entityService) { @@ -67,7 +72,39 @@ public class LinkUtils { Constants.INSTITUTIONAL_MEMORY_ASPECT_NAME, entityService, new InstitutionalMemory()); - removeLink(institutionalMemoryAspect, linkUrl); + + updateLink( + institutionalMemoryAspect, + currentLinkUrl, + currentLinkLabel, + newLinkUrl, + newLinkLabel, + actor); + persistAspect( + opContext, + resourceUrn, + Constants.INSTITUTIONAL_MEMORY_ASPECT_NAME, + institutionalMemoryAspect, + actor, + entityService); + } + + public static void removeLink( + @Nonnull OperationContext opContext, + String linkUrl, + String label, + Urn resourceUrn, + Urn actor, + EntityService entityService) { + InstitutionalMemory institutionalMemoryAspect = + (InstitutionalMemory) + EntityUtils.getAspectFromEntity( + opContext, + resourceUrn.toString(), + Constants.INSTITUTIONAL_MEMORY_ASPECT_NAME, + entityService, + new InstitutionalMemory()); + removeLink(institutionalMemoryAspect, linkUrl, label); persistAspect( opContext, resourceUrn, @@ -85,25 +122,92 @@ public class LinkUtils { InstitutionalMemoryMetadataArray linksArray = institutionalMemoryAspect.getElements(); - // if link exists, do not add it again - if (linksArray.stream().anyMatch(link -> link.getUrl().toString().equals(linkUrl))) { - return; - } - InstitutionalMemoryMetadata newLink = new InstitutionalMemoryMetadata(); newLink.setUrl(new Url(linkUrl)); newLink.setCreateStamp(EntityUtils.getAuditStamp(actor)); newLink.setDescription(linkLabel); // We no longer support, this is really a label. + + // if link exists, do not add it again + if (hasDuplicates(linksArray, newLink)) { + return; + } + linksArray.add(newLink); } - private static void removeLink(InstitutionalMemory institutionalMemoryAspect, String linkUrl) { + private static void removeLink( + InstitutionalMemory institutionalMemoryAspect, String linkUrl, String label) { if (!institutionalMemoryAspect.hasElements()) { institutionalMemoryAspect.setElements(new InstitutionalMemoryMetadataArray()); } InstitutionalMemoryMetadataArray elementsArray = institutionalMemoryAspect.getElements(); - elementsArray.removeIf(link -> link.getUrl().toString().equals(linkUrl)); + + // when label is passed, it's needed to remove link with the same url and label + if (label != null) { + elementsArray.removeIf(getPredicate(linkUrl, label)); + } else { + elementsArray.removeIf(link -> link.getUrl().toString().equals(linkUrl)); + } + } + + private static void updateLink( + InstitutionalMemory institutionalMemoryAspect, + String currentLinkUrl, + String currentLinkLabel, + String newLinkUrl, + String newLinkLabel, + Urn actor) { + if (!institutionalMemoryAspect.hasElements()) { + throw new IllegalArgumentException( + String.format( + "Failed to update the link '%s' with label '%s'. The link does not exist", + currentLinkUrl, currentLinkLabel)); + } + + InstitutionalMemoryMetadataArray elementsArray = institutionalMemoryAspect.getElements(); + + Optional optionalLinkToReplace = + elementsArray.stream().filter(getPredicate(currentLinkUrl, currentLinkLabel)).findFirst(); + if (optionalLinkToReplace.isEmpty()) { + throw new IllegalArgumentException( + String.format( + "Failed to update the link '%s' with label '%s'. The link does not exist", + currentLinkUrl, currentLinkLabel)); + } + InstitutionalMemoryMetadata linkToReplace = optionalLinkToReplace.get(); + + InstitutionalMemoryMetadata updatedLink = new InstitutionalMemoryMetadata(); + updatedLink.setUrl(new Url(newLinkUrl)); + updatedLink.setDescription(newLinkLabel); + updatedLink.setCreateStamp(linkToReplace.getCreateStamp()); + updatedLink.setUpdateStamp(EntityUtils.getAuditStamp(actor)); + + InstitutionalMemoryMetadataArray linksWithoutReplacingOne = + new InstitutionalMemoryMetadataArray(); + linksWithoutReplacingOne.addAll( + elementsArray.stream().filter(link -> !link.equals(linkToReplace)).toList()); + + if (hasDuplicates(linksWithoutReplacingOne, updatedLink)) { + throw new IllegalArgumentException( + String.format("The link '%s' with label '%s' already exists", newLinkUrl, newLinkLabel)); + } + + elementsArray.set(elementsArray.indexOf(linkToReplace), updatedLink); + } + + private static Predicate getPredicate( + String linkUrl, String linkLabel) { + return (link) -> + link.getUrl().toString().equals(linkUrl) & link.getDescription().equals((linkLabel)); + } + + private static boolean hasDuplicates( + InstitutionalMemoryMetadataArray linksArray, InstitutionalMemoryMetadata linkToValidate) { + String url = linkToValidate.getUrl().toString(); + String label = linkToValidate.getDescription(); + + return linksArray.stream().anyMatch(getPredicate(url, label)); } public static boolean isAuthorizedToUpdateLinks(@Nonnull QueryContext context, Urn resourceUrn) { @@ -118,28 +222,44 @@ public class LinkUtils { context, resourceUrn.getEntityType(), resourceUrn.toString(), orPrivilegeGroups); } - public static Boolean validateAddRemoveInput( + public static void validateAddRemoveInput( @Nonnull OperationContext opContext, String linkUrl, Urn resourceUrn, EntityService entityService) { + validateUrl(linkUrl, resourceUrn); + validateResourceUrn(opContext, resourceUrn, entityService); + } + public static void validateUpdateInput( + @Nonnull OperationContext opContext, + String currentLinkUrl, + String linkUrl, + Urn resourceUrn, + EntityService entityService) { + validateUrl(currentLinkUrl, resourceUrn); + validateUrl(linkUrl, resourceUrn); + validateResourceUrn(opContext, resourceUrn, entityService); + } + + private static void validateUrl(String url, Urn resourceUrn) { try { - new Url(linkUrl); + new Url(url); } catch (Exception e) { throw new IllegalArgumentException( String.format( - "Failed to change institutional memory for resource %s. Expected a corp group urn.", + "Failed to change institutional memory for resource %s. Expected an url.", resourceUrn)); } + } + private static void validateResourceUrn( + @Nonnull OperationContext opContext, Urn resourceUrn, EntityService entityService) { if (!entityService.exists(opContext, resourceUrn, true)) { throw new IllegalArgumentException( String.format( "Failed to change institutional memory for resource %s. Resource does not exist.", resourceUrn)); } - - return true; } } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/common/mappers/InstitutionalMemoryMetadataMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/common/mappers/InstitutionalMemoryMetadataMapper.java index 9781643c41..2d1e8443e1 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/common/mappers/InstitutionalMemoryMetadataMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/common/mappers/InstitutionalMemoryMetadataMapper.java @@ -30,6 +30,9 @@ public class InstitutionalMemoryMetadataMapper { result.setAuthor(getAuthor(input.getCreateStamp().getActor().toString())); result.setActor(ResolvedActorMapper.map(input.getCreateStamp().getActor())); result.setCreated(AuditStampMapper.map(context, input.getCreateStamp())); + if (input.getUpdateStamp() != null) { + result.setUpdated(AuditStampMapper.map(context, input.getUpdateStamp())); + } result.setAssociatedUrn(entityUrn.toString()); return result; } diff --git a/datahub-graphql-core/src/main/resources/entity.graphql b/datahub-graphql-core/src/main/resources/entity.graphql index 65e1aa7f46..cab64fc27c 100644 --- a/datahub-graphql-core/src/main/resources/entity.graphql +++ b/datahub-graphql-core/src/main/resources/entity.graphql @@ -604,6 +604,11 @@ type Mutation { """ addLink(input: AddLinkInput!): Boolean + """ + Update a link, or institutional memory, from a particular Entity + """ + updateLink(input: UpdateLinkInput!): Boolean + """ Remove a link, or institutional memory, from a particular Entity """ @@ -3291,6 +3296,11 @@ type InstitutionalMemoryMetadata { """ created: AuditStamp! + """ + An AuditStamp corresponding to the updating of this resource + """ + updated: AuditStamp + """ Deprecated, use label instead Description of the resource @@ -9121,15 +9131,50 @@ input AddLinkInput { resourceUrn: String! } +""" +Input provided when updating the association between a Metadata Entity and a Link +""" +input UpdateLinkInput { + """ + Current url of the link + """ + currentUrl: String! + + """ + Current label of the link + """ + currentLabel: String! + + """ + The new url of the link + """ + linkUrl: String! + + """ + The new label of the link + """ + label: String! + + """ + The urn of the resource or entity to attach the link to, for example a dataset urn + """ + resourceUrn: String! +} + """ Input provided when removing the association between a Metadata Entity and a Link """ input RemoveLinkInput { """ - The url of the link to add or remove, which uniquely identifies the Link + The url of the link to remove, which uniquely identifies the Link """ linkUrl: String! + """ + The label of the link to remove, which uniquely identifies the Link + """ + label: String + """ The urn of the resource or entity to attach the link to, for example a dataset urn """ diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/link/AddLinkResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/link/AddLinkResolverTest.java new file mode 100644 index 0000000000..dab8e4a7a8 --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/link/AddLinkResolverTest.java @@ -0,0 +1,201 @@ +package com.linkedin.datahub.graphql.resolvers.link; + +import static com.linkedin.datahub.graphql.TestUtils.*; +import static org.testng.Assert.assertThrows; + +import com.datahub.authentication.Authentication; +import com.linkedin.common.InstitutionalMemory; +import com.linkedin.common.InstitutionalMemoryMetadata; +import com.linkedin.common.InstitutionalMemoryMetadataArray; +import com.linkedin.common.url.Url; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.exception.AuthorizationException; +import com.linkedin.datahub.graphql.generated.AddLinkInput; +import com.linkedin.datahub.graphql.resolvers.mutate.AddLinkResolver; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.metadata.entity.EntityService; +import graphql.schema.DataFetchingEnvironment; +import java.util.concurrent.CompletionException; +import org.mockito.Mockito; +import org.testng.annotations.Test; + +public class AddLinkResolverTest { + private static final String ASSET_URN = "urn:li:dataset:(test1,test2,test3)"; + private static final String TEST_URL = "https://www.github.com"; + private static final String TEST_LABEL = "Test Label"; + private static final AddLinkInput TEST_INPUT = new AddLinkInput(TEST_URL, TEST_LABEL, ASSET_URN); + + private void setupTest(DataFetchingEnvironment mockEnv) { + QueryContext mockContext = getMockAllowContext(); + Mockito.when(mockContext.getAuthentication()).thenReturn(Mockito.mock(Authentication.class)); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + } + + @Test + public void testGetSuccessNoPreviousAspect() throws Exception { + EntityClient mockClient = LinkTestUtils.initMockClient(); + EntityService mockService = LinkTestUtils.initMockEntityService(true, null); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + + InstitutionalMemory expectedAspect = new InstitutionalMemory(); + InstitutionalMemoryMetadataArray elements = new InstitutionalMemoryMetadataArray(); + InstitutionalMemoryMetadata link = new InstitutionalMemoryMetadata(); + link.setUrl(new Url(TEST_URL)); + link.setDescription(TEST_LABEL); + elements.add(link); + expectedAspect.setElements(elements); + + setupTest(mockEnv); + + AddLinkResolver resolver = new AddLinkResolver(mockService, mockClient); + resolver.get(mockEnv).get(); + + LinkTestUtils.verifyIngestInstitutionalMemory(mockService, 1, expectedAspect); + } + + @Test + public void testGetSuccessWithPreviousAspect() throws Exception { + InstitutionalMemory originalAspect = new InstitutionalMemory(); + InstitutionalMemoryMetadataArray elements = new InstitutionalMemoryMetadataArray(); + InstitutionalMemoryMetadata link = + LinkTestUtils.createLink("https://www.google.com", "Original Label"); + elements.add(link); + originalAspect.setElements(elements); + + EntityClient mockClient = LinkTestUtils.initMockClient(); + EntityService mockService = LinkTestUtils.initMockEntityService(true, originalAspect); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + + InstitutionalMemory expectedAspect = new InstitutionalMemory(); + InstitutionalMemoryMetadataArray newElements = new InstitutionalMemoryMetadataArray(); + InstitutionalMemoryMetadata newLink = LinkTestUtils.createLink(TEST_URL, TEST_LABEL); + // make sure to include existing link + newElements.add(link); + newElements.add(newLink); + expectedAspect.setElements(newElements); + + setupTest(mockEnv); + + AddLinkResolver resolver = new AddLinkResolver(mockService, mockClient); + resolver.get(mockEnv).get(); + + LinkTestUtils.verifyIngestInstitutionalMemory(mockService, 1, expectedAspect); + } + + @Test + public void testGetFailureNoEntity() throws Exception { + EntityClient mockClient = LinkTestUtils.initMockClient(); + EntityService mockService = LinkTestUtils.initMockEntityService(false, null); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + + setupTest(mockEnv); + + AddLinkResolver resolver = new AddLinkResolver(mockService, mockClient); + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + + LinkTestUtils.verifyIngestInstitutionalMemory(mockService, 0, null); + } + + @Test + public void testGetFailureNoPermission() throws Exception { + EntityClient mockClient = LinkTestUtils.initMockClient(); + EntityService mockService = LinkTestUtils.initMockEntityService(true, null); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + + QueryContext mockContext = getMockDenyContext(); + Mockito.when(mockContext.getAuthentication()).thenReturn(Mockito.mock(Authentication.class)); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + AddLinkResolver resolver = new AddLinkResolver(mockService, mockClient); + assertThrows(AuthorizationException.class, () -> resolver.get(mockEnv).join()); + + LinkTestUtils.verifyIngestInstitutionalMemory(mockService, 0, null); + } + + @Test + public void testShouldNotAddLinkWithTheSameUrlAndLabel() throws Exception { + InstitutionalMemory originalAspect = new InstitutionalMemory(); + InstitutionalMemoryMetadataArray elements = new InstitutionalMemoryMetadataArray(); + InstitutionalMemoryMetadata link = LinkTestUtils.createLink(TEST_URL, TEST_LABEL); + elements.add(link); + originalAspect.setElements(elements); + + EntityClient mockClient = LinkTestUtils.initMockClient(); + EntityService mockService = LinkTestUtils.initMockEntityService(true, originalAspect); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + + InstitutionalMemory expectedAspect = new InstitutionalMemory(); + InstitutionalMemoryMetadataArray newElements = new InstitutionalMemoryMetadataArray(); + InstitutionalMemoryMetadata newLink = LinkTestUtils.createLink(TEST_URL, TEST_LABEL); + // should include only already existing link + newElements.add(link); + expectedAspect.setElements(newElements); + + setupTest(mockEnv); + + AddLinkResolver resolver = new AddLinkResolver(mockService, mockClient); + resolver.get(mockEnv).get(); + + LinkTestUtils.verifyIngestInstitutionalMemory(mockService, 1, expectedAspect); + } + + @Test + public void testGetSuccessWhenLinkWithTheSameUrlAndDifferentLabelAdded() throws Exception { + InstitutionalMemory originalAspect = new InstitutionalMemory(); + InstitutionalMemoryMetadataArray elements = new InstitutionalMemoryMetadataArray(); + InstitutionalMemoryMetadata link = LinkTestUtils.createLink(TEST_URL, "Another label"); + + elements.add(link); + originalAspect.setElements(elements); + + EntityClient mockClient = LinkTestUtils.initMockClient(); + EntityService mockService = LinkTestUtils.initMockEntityService(true, originalAspect); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + + InstitutionalMemory expectedAspect = new InstitutionalMemory(); + InstitutionalMemoryMetadataArray newElements = new InstitutionalMemoryMetadataArray(); + InstitutionalMemoryMetadata newLink = LinkTestUtils.createLink(TEST_URL, TEST_LABEL); + // make sure to include existing link + newElements.add(link); + newElements.add(newLink); + expectedAspect.setElements(newElements); + + setupTest(mockEnv); + + AddLinkResolver resolver = new AddLinkResolver(mockService, mockClient); + resolver.get(mockEnv).get(); + + LinkTestUtils.verifyIngestInstitutionalMemory(mockService, 1, expectedAspect); + } + + @Test + public void testGetSuccessWhenLinkWithDifferentUrlAndTheSameLabelAdded() throws Exception { + InstitutionalMemory originalAspect = new InstitutionalMemory(); + InstitutionalMemoryMetadataArray elements = new InstitutionalMemoryMetadataArray(); + InstitutionalMemoryMetadata link = + LinkTestUtils.createLink("https://another-url.com", TEST_LABEL); + elements.add(link); + originalAspect.setElements(elements); + + EntityClient mockClient = LinkTestUtils.initMockClient(); + EntityService mockService = LinkTestUtils.initMockEntityService(true, originalAspect); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + + InstitutionalMemory expectedAspect = new InstitutionalMemory(); + InstitutionalMemoryMetadataArray newElements = new InstitutionalMemoryMetadataArray(); + InstitutionalMemoryMetadata newLink = LinkTestUtils.createLink(TEST_URL, TEST_LABEL); + // make sure to include existing link + newElements.add(link); + newElements.add(newLink); + expectedAspect.setElements(newElements); + + setupTest(mockEnv); + + AddLinkResolver resolver = new AddLinkResolver(mockService, mockClient); + resolver.get(mockEnv).get(); + + LinkTestUtils.verifyIngestInstitutionalMemory(mockService, 1, expectedAspect); + } +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/link/LinkTestUtils.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/link/LinkTestUtils.java new file mode 100644 index 0000000000..fe89365116 --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/link/LinkTestUtils.java @@ -0,0 +1,138 @@ +package com.linkedin.datahub.graphql.resolvers.link; + +import static com.linkedin.datahub.graphql.TestUtils.*; +import static com.linkedin.metadata.Constants.*; +import static org.mockito.ArgumentMatchers.any; + +import com.datahub.authentication.Authentication; +import com.linkedin.common.AuditStamp; +import com.linkedin.common.InstitutionalMemory; +import com.linkedin.common.InstitutionalMemoryMetadata; +import com.linkedin.common.url.Url; +import com.linkedin.common.urn.Urn; +import com.linkedin.data.template.RecordTemplate; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.metadata.entity.EntityService; +import com.linkedin.metadata.entity.ebean.batch.ChangeItemImpl; +import com.linkedin.metadata.search.SearchEntityArray; +import com.linkedin.metadata.search.SearchResult; +import com.linkedin.metadata.utils.GenericRecordUtils; +import com.linkedin.mxe.MetadataChangeProposal; +import graphql.schema.DataFetchingEnvironment; +import io.datahubproject.metadata.context.OperationContext; +import java.time.Clock; +import java.util.Collections; +import java.util.HashMap; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.testng.Assert; + +public class LinkTestUtils { + + public static InstitutionalMemoryMetadata createLink(String url, String label) throws Exception { + InstitutionalMemoryMetadata link = new InstitutionalMemoryMetadata(); + + link.setUrl(new Url(url)); + link.setDescription(label); + + AuditStamp createStamp = new AuditStamp(); + createStamp.setActor(new Urn("urn:corpuser:test")); + createStamp.setTime(Clock.systemUTC().millis()); + link.setCreateStamp(createStamp); + + return link; + } + + public static DataFetchingEnvironment initMockEnv() { + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + QueryContext mockContext = getMockAllowContext(); + Mockito.when(mockContext.getAuthentication()).thenReturn(Mockito.mock(Authentication.class)); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + return mockEnv; + } + + public static EntityService initMockEntityService( + @Nonnull Boolean entityExists, @Nullable RecordTemplate currentAspect) { + EntityService mockService = Mockito.mock(EntityService.class); + + if (entityExists) { + Mockito.when( + mockService.exists(any(OperationContext.class), any(Urn.class), Mockito.eq(true))) + .thenReturn(true); + } else { + Mockito.when( + mockService.exists(any(OperationContext.class), any(Urn.class), Mockito.eq(true))) + .thenReturn(false); + } + + Mockito.when( + mockService.getAspect( + any(OperationContext.class), + any(Urn.class), + Mockito.eq(INSTITUTIONAL_MEMORY_ASPECT_NAME), + any(long.class))) + .thenReturn(currentAspect); + + return mockService; + } + + public static EntityClient initMockClient() throws Exception { + EntityClient mockClient = Mockito.mock(EntityClient.class); + + Mockito.when( + mockClient.filter( + any(), + Mockito.eq(GLOSSARY_TERM_ENTITY_NAME), + Mockito.any(), + Mockito.eq(null), + Mockito.eq(0), + Mockito.eq(1000))) + .thenReturn(new SearchResult().setEntities(new SearchEntityArray())); + Mockito.when( + mockClient.batchGetV2( + any(), + Mockito.eq(GLOSSARY_TERM_ENTITY_NAME), + Mockito.any(), + Mockito.eq(Collections.singleton(GLOSSARY_TERM_INFO_ASPECT_NAME)))) + .thenReturn(new HashMap<>()); + + return mockClient; + } + + static void verifyIngestInstitutionalMemory( + EntityService mockService, int numberOfInvocations, InstitutionalMemory expectedAspect) { + ArgumentCaptor proposalCaptor = + ArgumentCaptor.forClass(MetadataChangeProposal.class); + + Mockito.verify(mockService, Mockito.times(numberOfInvocations)) + .ingestProposal(any(), proposalCaptor.capture(), any(AuditStamp.class), Mockito.eq(false)); + + if (numberOfInvocations > 0) { + // check has time + Assert.assertTrue(proposalCaptor.getValue().getSystemMetadata().getLastObserved() > 0L); + + InstitutionalMemory actualAspect = + GenericRecordUtils.deserializeAspect( + proposalCaptor.getValue().getAspect().getValue(), + proposalCaptor.getValue().getAspect().getContentType(), + InstitutionalMemory.class); + + Assert.assertEquals(actualAspect.getElements().size(), expectedAspect.getElements().size()); + + actualAspect + .getElements() + .forEach( + element -> { + int index = actualAspect.getElements().indexOf(element); + InstitutionalMemoryMetadata expectedElement = + expectedAspect.getElements().get(index); + Assert.assertEquals(element.getUrl(), expectedElement.getUrl()); + Assert.assertEquals(element.getDescription(), expectedElement.getDescription()); + }); + } + } +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/link/RemoveLinkResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/link/RemoveLinkResolverTest.java new file mode 100644 index 0000000000..4a089c9b61 --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/link/RemoveLinkResolverTest.java @@ -0,0 +1,126 @@ +package com.linkedin.datahub.graphql.resolvers.link; + +import static com.linkedin.datahub.graphql.TestUtils.getMockDenyContext; +import static org.testng.Assert.assertThrows; + +import com.datahub.authentication.Authentication; +import com.linkedin.common.*; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.exception.AuthorizationException; +import com.linkedin.datahub.graphql.generated.RemoveLinkInput; +import com.linkedin.datahub.graphql.resolvers.mutate.RemoveLinkResolver; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.metadata.entity.EntityService; +import graphql.schema.DataFetchingEnvironment; +import java.util.concurrent.CompletionException; +import org.mockito.Mockito; +import org.testng.annotations.Test; + +public class RemoveLinkResolverTest { + private static final String ASSET_URN = "urn:li:dataset:(test1,test2,test3)"; + + private static DataFetchingEnvironment initMockEnv(RemoveLinkInput input) { + DataFetchingEnvironment mockEnv = LinkTestUtils.initMockEnv(); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + return mockEnv; + } + + @Test + public void testGetSuccessWhenRemovingExistingLinkByUrlAndLabel() throws Exception { + InstitutionalMemory originalAspect = new InstitutionalMemory(); + InstitutionalMemoryMetadata originalLink = + LinkTestUtils.createLink("https://original-url.com", "Original label"); + InstitutionalMemoryMetadata originalLinkWithAnotherLabel = + LinkTestUtils.createLink("https://original-url.com", "Another label"); + InstitutionalMemoryMetadataArray elements = + new InstitutionalMemoryMetadataArray(originalLink, originalLinkWithAnotherLabel); + originalAspect.setElements(elements); + + InstitutionalMemory expectedAspect = new InstitutionalMemory(); + InstitutionalMemoryMetadataArray expectedElements = + new InstitutionalMemoryMetadataArray(originalLinkWithAnotherLabel); + expectedAspect.setElements(expectedElements); + + EntityService mockService = LinkTestUtils.initMockEntityService(true, originalAspect); + EntityClient mockClient = LinkTestUtils.initMockClient(); + DataFetchingEnvironment mockEnv = + initMockEnv(new RemoveLinkInput("https://original-url.com", "Original label", ASSET_URN)); + RemoveLinkResolver resolver = new RemoveLinkResolver(mockService, mockClient); + resolver.get(mockEnv).get(); + + LinkTestUtils.verifyIngestInstitutionalMemory(mockService, 1, expectedAspect); + } + + @Test + public void testGetSuccessWhenRemovingExistingLinkByUrl() throws Exception { + InstitutionalMemory originalAspect = new InstitutionalMemory(); + InstitutionalMemoryMetadata originalLink = + LinkTestUtils.createLink("https://original-url.com", "Original label"); + InstitutionalMemoryMetadataArray elements = new InstitutionalMemoryMetadataArray(originalLink); + originalAspect.setElements(elements); + + InstitutionalMemory expectedAspect = new InstitutionalMemory(); + InstitutionalMemoryMetadataArray newElements = new InstitutionalMemoryMetadataArray(); + expectedAspect.setElements(newElements); + + EntityService mockService = LinkTestUtils.initMockEntityService(true, originalAspect); + EntityClient mockClient = LinkTestUtils.initMockClient(); + DataFetchingEnvironment mockEnv = + initMockEnv(new RemoveLinkInput("https://original-url.com", null, ASSET_URN)); + RemoveLinkResolver resolver = new RemoveLinkResolver(mockService, mockClient); + resolver.get(mockEnv).get(); + + LinkTestUtils.verifyIngestInstitutionalMemory(mockService, 1, expectedAspect); + } + + @Test + public void testGetSuccessWhenRemovingNotExistingLink() throws Exception { + InstitutionalMemory originalAspect = new InstitutionalMemory(); + InstitutionalMemoryMetadataArray elements = new InstitutionalMemoryMetadataArray(); + originalAspect.setElements(elements); + + InstitutionalMemory expectedAspect = new InstitutionalMemory(); + InstitutionalMemoryMetadataArray newElements = new InstitutionalMemoryMetadataArray(); + expectedAspect.setElements(newElements); + + EntityService mockService = LinkTestUtils.initMockEntityService(true, originalAspect); + EntityClient mockClient = LinkTestUtils.initMockClient(); + DataFetchingEnvironment mockEnv = + initMockEnv(new RemoveLinkInput("https://original-url.com", "Original label", ASSET_URN)); + RemoveLinkResolver resolver = new RemoveLinkResolver(mockService, mockClient); + resolver.get(mockEnv).get(); + + LinkTestUtils.verifyIngestInstitutionalMemory(mockService, 1, expectedAspect); + } + + @Test + public void testGetFailureNoEntity() throws Exception { + EntityClient mockClient = LinkTestUtils.initMockClient(); + EntityService mockService = LinkTestUtils.initMockEntityService(false, null); + + DataFetchingEnvironment mockEnv = + initMockEnv(new RemoveLinkInput("https://original-url.com", "Original label", ASSET_URN)); + RemoveLinkResolver resolver = new RemoveLinkResolver(mockService, mockClient); + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + + LinkTestUtils.verifyIngestInstitutionalMemory(mockService, 0, null); + } + + @Test + public void testGetFailureNoPermission() throws Exception { + EntityClient mockClient = LinkTestUtils.initMockClient(); + EntityService mockService = LinkTestUtils.initMockEntityService(true, null); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + + QueryContext mockContext = getMockDenyContext(); + Mockito.when(mockContext.getAuthentication()).thenReturn(Mockito.mock(Authentication.class)); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))) + .thenReturn(new RemoveLinkInput("https://original-url.com", "Original label", ASSET_URN)); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + RemoveLinkResolver resolver = new RemoveLinkResolver(mockService, mockClient); + assertThrows(AuthorizationException.class, () -> resolver.get(mockEnv).join()); + + LinkTestUtils.verifyIngestInstitutionalMemory(mockService, 0, null); + } +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/link/UpdateLinkResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/link/UpdateLinkResolverTest.java new file mode 100644 index 0000000000..f884e5feee --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/link/UpdateLinkResolverTest.java @@ -0,0 +1,146 @@ +package com.linkedin.datahub.graphql.resolvers.link; + +import static com.linkedin.datahub.graphql.TestUtils.getMockDenyContext; +import static org.testng.Assert.assertThrows; + +import com.datahub.authentication.Authentication; +import com.linkedin.common.*; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.exception.AuthorizationException; +import com.linkedin.datahub.graphql.generated.UpdateLinkInput; +import com.linkedin.datahub.graphql.resolvers.mutate.UpdateLinkResolver; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.metadata.entity.EntityService; +import graphql.schema.DataFetchingEnvironment; +import java.util.concurrent.CompletionException; +import org.mockito.Mockito; +import org.testng.annotations.Test; + +public class UpdateLinkResolverTest { + private static final String ASSET_URN = "urn:li:dataset:(test1,test2,test3)"; + + private static DataFetchingEnvironment initMockEnv(UpdateLinkInput input) { + DataFetchingEnvironment mockEnv = LinkTestUtils.initMockEnv(); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + return mockEnv; + } + + @Test + public void testGetSuccessWhenUpdatingExistingLink() throws Exception { + InstitutionalMemory originalAspect = new InstitutionalMemory(); + InstitutionalMemoryMetadata originalLink = + LinkTestUtils.createLink("https://original-url.com", "Original label"); + InstitutionalMemoryMetadataArray elements = new InstitutionalMemoryMetadataArray(originalLink); + originalAspect.setElements(elements); + + InstitutionalMemory expectedAspect = new InstitutionalMemory(); + InstitutionalMemoryMetadata updatedLink = + LinkTestUtils.createLink("https://updated-url.com", "Updated label"); + InstitutionalMemoryMetadataArray newElements = + new InstitutionalMemoryMetadataArray(updatedLink); + expectedAspect.setElements(newElements); + + EntityService mockService = LinkTestUtils.initMockEntityService(true, originalAspect); + EntityClient mockClient = LinkTestUtils.initMockClient(); + DataFetchingEnvironment mockEnv = + initMockEnv( + new UpdateLinkInput( + "https://original-url.com", + "Original label", + "https://updated-url.com", + "Updated label", + ASSET_URN)); + UpdateLinkResolver resolver = new UpdateLinkResolver(mockService, mockClient); + resolver.get(mockEnv).get(); + + LinkTestUtils.verifyIngestInstitutionalMemory(mockService, 1, expectedAspect); + } + + @Test + public void testGetFailedWhenUpdatingNonExistingLink() throws Exception { + InstitutionalMemory originalAspect = new InstitutionalMemory(); + originalAspect.setElements(new InstitutionalMemoryMetadataArray()); + + EntityService mockService = LinkTestUtils.initMockEntityService(true, originalAspect); + EntityClient mockClient = LinkTestUtils.initMockClient(); + DataFetchingEnvironment mockEnv = + initMockEnv( + new UpdateLinkInput( + "https://original-url.com", + "Original label", + "https://updated-url.com", + "Updated label", + ASSET_URN)); + UpdateLinkResolver resolver = new UpdateLinkResolver(mockService, mockClient); + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + } + + @Test + public void testGetFailedWhenUpdatedLinkIsNotUnique() throws Exception { + InstitutionalMemory originalAspect = new InstitutionalMemory(); + InstitutionalMemoryMetadata originalLink = + LinkTestUtils.createLink("https://original-url.com", "Original label"); + InstitutionalMemoryMetadata duplicatedLink = + LinkTestUtils.createLink("https://duplicated-url.com", "Duplicated label"); + InstitutionalMemoryMetadataArray elements = + new InstitutionalMemoryMetadataArray(originalLink, duplicatedLink); + originalAspect.setElements(elements); + + EntityService mockService = LinkTestUtils.initMockEntityService(true, originalAspect); + EntityClient mockClient = LinkTestUtils.initMockClient(); + DataFetchingEnvironment mockEnv = + initMockEnv( + new UpdateLinkInput( + "https://original-url.com", + "Original label", + "https://duplicated-url.com", + "Duplicated label", + ASSET_URN)); + UpdateLinkResolver resolver = new UpdateLinkResolver(mockService, mockClient); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + } + + @Test + public void testGetFailureNoEntity() throws Exception { + EntityClient mockClient = LinkTestUtils.initMockClient(); + EntityService mockService = LinkTestUtils.initMockEntityService(false, null); + + DataFetchingEnvironment mockEnv = + initMockEnv( + new UpdateLinkInput( + "https://original-url.com", + "Original label", + "https://duplicated-url.com", + "Duplicated label", + ASSET_URN)); + UpdateLinkResolver resolver = new UpdateLinkResolver(mockService, mockClient); + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + + LinkTestUtils.verifyIngestInstitutionalMemory(mockService, 0, null); + } + + @Test + public void testGetFailureNoPermission() throws Exception { + EntityClient mockClient = LinkTestUtils.initMockClient(); + EntityService mockService = LinkTestUtils.initMockEntityService(true, null); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + + QueryContext mockContext = getMockDenyContext(); + Mockito.when(mockContext.getAuthentication()).thenReturn(Mockito.mock(Authentication.class)); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))) + .thenReturn( + new UpdateLinkInput( + "https://original-url.com", + "Original label", + "https://duplicated-url.com", + "Duplicated label", + ASSET_URN)); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + UpdateLinkResolver resolver = new UpdateLinkResolver(mockService, mockClient); + assertThrows(AuthorizationException.class, () -> resolver.get(mockEnv).join()); + + LinkTestUtils.verifyIngestInstitutionalMemory(mockService, 0, null); + } +} diff --git a/datahub-web-react/src/app/entity/shared/components/styled/AddLinkModal.tsx b/datahub-web-react/src/app/entity/shared/components/styled/AddLinkModal.tsx index f6266f2cfa..fff9d0bb21 100644 --- a/datahub-web-react/src/app/entity/shared/components/styled/AddLinkModal.tsx +++ b/datahub-web-react/src/app/entity/shared/components/styled/AddLinkModal.tsx @@ -72,7 +72,12 @@ export const AddLinkModal = ({ buttonProps, refetch }: AddLinkProps) => { , - , ]} @@ -80,7 +85,7 @@ export const AddLinkModal = ({ buttonProps, refetch }: AddLinkProps) => { >
{ { const handleDeleteLink = async (metadata: InstitutionalMemoryMetadata) => { try { await removeLinkMutation({ - variables: { input: { linkUrl: metadata.url, resourceUrn: metadata.associatedUrn || entityUrn } }, + variables: { + input: { + linkUrl: metadata.url, + label: metadata.label, + resourceUrn: metadata.associatedUrn || entityUrn, + }, + }, }); message.success({ content: 'Link Removed', duration: 2 }); } catch (e: unknown) { @@ -79,7 +85,13 @@ export const LinkList = ({ refetch }: LinkListProps) => { if (!linkDetails) return; try { await removeLinkMutation({ - variables: { input: { linkUrl: linkDetails.url, resourceUrn: linkDetails.associatedUrn || entityUrn } }, + variables: { + input: { + linkUrl: linkDetails.url, + label: linkDetails.label, + resourceUrn: linkDetails.associatedUrn || entityUrn, + }, + }, }); await addLinkMutation({ variables: { input: { linkUrl: formData.url, label: formData.label, resourceUrn: mutationUrn } }, diff --git a/datahub-web-react/src/app/entityV2/shared/EntityDropdown/versioning/LinkAssetVersionModal.tsx b/datahub-web-react/src/app/entityV2/shared/EntityDropdown/versioning/LinkAssetVersionModal.tsx index 4eea863a63..a76525df42 100644 --- a/datahub-web-react/src/app/entityV2/shared/EntityDropdown/versioning/LinkAssetVersionModal.tsx +++ b/datahub-web-react/src/app/entityV2/shared/EntityDropdown/versioning/LinkAssetVersionModal.tsx @@ -104,8 +104,8 @@ export default function LinkAssetVersionModal({ urn, entityType, closeModal, ref title="Link a Newer Version" onCancel={close} buttons={[ - { text: 'Cancel', variant: 'text', onClick: close }, - { text: 'Create', variant: 'filled', onClick: handleLink }, + { text: 'Cancel', variant: 'text', onClick: close, key: 'Cancel' }, + { text: 'Create', variant: 'filled', onClick: handleLink, key: 'Create' }, ]} > diff --git a/datahub-web-react/src/app/entityV2/shared/components/styled/AddLinkModal.tsx b/datahub-web-react/src/app/entityV2/shared/components/styled/AddLinkModal.tsx index 88240d53b6..fe960135ad 100644 --- a/datahub-web-react/src/app/entityV2/shared/components/styled/AddLinkModal.tsx +++ b/datahub-web-react/src/app/entityV2/shared/components/styled/AddLinkModal.tsx @@ -1,44 +1,47 @@ import { PlusOutlined } from '@ant-design/icons'; -import { Button as AntButton, Form, Input, Modal, message } from 'antd'; +import { Button as AntButton, message } from 'antd'; import React, { useState } from 'react'; import analytics, { EntityActionType, EventType } from '@app/analytics'; import { useUserContext } from '@app/context/useUserContext'; import { useEntityData, useMutationUrn } from '@app/entity/shared/EntityContext'; +import { FormData, LinkFormModal } from '@app/entityV2/shared/components/styled/LinkFormModal'; import { Button } from '@src/alchemy-components'; -import { ModalButtonContainer } from '@src/app/shared/button/styledComponents'; import { useAddLinkMutation } from '@graphql/mutations.generated'; -type AddLinkProps = { +interface Props { buttonProps?: Record; refetch?: () => Promise; buttonType?: string; -}; +} -export const AddLinkModal = ({ buttonProps, refetch, buttonType }: AddLinkProps) => { +export const AddLinkModal = ({ buttonProps, refetch, buttonType }: Props) => { const [isModalVisible, setIsModalVisible] = useState(false); const mutationUrn = useMutationUrn(); const user = useUserContext(); const { entityType } = useEntityData(); const [addLinkMutation] = useAddLinkMutation(); - const [form] = Form.useForm(); - const showModal = () => { setIsModalVisible(true); }; const handleClose = () => { - form.resetFields(); setIsModalVisible(false); }; - const handleAdd = async (formData: any) => { + const handleAdd = async (formData: FormData) => { if (user?.urn) { try { await addLinkMutation({ - variables: { input: { linkUrl: formData.url, label: formData.label, resourceUrn: mutationUrn } }, + variables: { + input: { + linkUrl: formData.url, + label: formData.label, + resourceUrn: mutationUrn, + }, + }, }); message.success({ content: 'Link Added', duration: 2 }); analytics.event({ @@ -47,6 +50,7 @@ export const AddLinkModal = ({ buttonProps, refetch, buttonType }: AddLinkProps) entityUrn: mutationUrn, actionType: EntityActionType.UpdateLinks, }); + handleClose(); } catch (e: unknown) { message.destroy(); if (e instanceof Error) { @@ -54,7 +58,6 @@ export const AddLinkModal = ({ buttonProps, refetch, buttonType }: AddLinkProps) } } refetch?.(); - handleClose(); } else { message.error({ content: `Error adding link: no user`, duration: 2 }); } @@ -88,56 +91,7 @@ export const AddLinkModal = ({ buttonProps, refetch, buttonType }: AddLinkProps) return ( <> {renderButton(buttonType)} - - - - , - ]} - > - - - - - - - - - + ); }; diff --git a/datahub-web-react/src/app/entityV2/shared/components/styled/LinkFormModal.tsx b/datahub-web-react/src/app/entityV2/shared/components/styled/LinkFormModal.tsx new file mode 100644 index 0000000000..c4c62a57c3 --- /dev/null +++ b/datahub-web-react/src/app/entityV2/shared/components/styled/LinkFormModal.tsx @@ -0,0 +1,80 @@ +import { Form } from 'antd'; +import React, { useCallback, useEffect, useMemo } from 'react'; + +import { Input, Modal } from '@src/alchemy-components'; + +export interface FormData { + url: string; + label: string; +} + +interface Props { + open: boolean; + initialValues?: Partial; + variant: 'create' | 'update'; + onSubmit: (formData: FormData) => void; + onCancel: () => void; +} + +export const LinkFormModal = ({ open, initialValues, variant, onSubmit, onCancel }: Props) => { + const [form] = Form.useForm(); + + const onCancelHandler = useCallback(() => { + onCancel(); + }, [onCancel]); + + // Reset form state to initial values when the form opened/closed + useEffect(() => { + form.resetFields(); + }, [open, form]); + + const title = useMemo(() => (variant === 'create' ? 'Add Link' : 'Update Link'), [variant]); + const submitButtonText = useMemo(() => (variant === 'create' ? 'Create' : 'Update'), [variant]); + + return ( + form.submit() }, + ]} + > +
+ + + + + + + +
+
+ ); +}; diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Documentation/components/LinkList.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Documentation/components/LinkList.tsx index a89f6ed9c1..f0ebc2e523 100644 --- a/datahub-web-react/src/app/entityV2/shared/tabs/Documentation/components/LinkList.tsx +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Documentation/components/LinkList.tsx @@ -1,15 +1,18 @@ import { DeleteOutlined, LinkOutlined } from '@ant-design/icons'; +import { colors } from '@components'; import { Button, List, Typography, message } from 'antd'; -import React from 'react'; +import { Pencil } from 'phosphor-react'; +import React, { useCallback, useState } from 'react'; import { Link } from 'react-router-dom'; import styled from 'styled-components/macro'; import { useEntityData } from '@app/entity/shared/EntityContext'; +import { FormData, LinkFormModal } from '@app/entityV2/shared/components/styled/LinkFormModal'; import { ANTD_GRAY } from '@app/entityV2/shared/constants'; import { formatDateString } from '@app/entityV2/shared/containers/profile/utils'; import { useEntityRegistry } from '@app/useEntityRegistry'; -import { useRemoveLinkMutation } from '@graphql/mutations.generated'; +import { useRemoveLinkMutation, useUpdateLinkMutation } from '@graphql/mutations.generated'; import { InstitutionalMemoryMetadata } from '@types'; const LinkListItem = styled(List.Item)` @@ -25,6 +28,12 @@ const LinkListItem = styled(List.Item)` } `; +const LinkButtonsContainer = styled.div` + display: flex; + flex-direction: row; + gap: 8px; +`; + const ListOffsetIcon = styled.span` margin-left: -18px; margin-right: 6px; @@ -35,25 +44,82 @@ type LinkListProps = { }; export const LinkList = ({ refetch }: LinkListProps) => { + const [isEditFormModalOpened, setIsEditFormModalOpened] = useState(false); + const [editingMetadata, setEditingMetadata] = useState(); + const [initialValuesOfEditForm, setInitialValuesOfEditForm] = useState(); const { urn: entityUrn, entityData } = useEntityData(); const entityRegistry = useEntityRegistry(); + const [updateLinkMutation] = useUpdateLinkMutation(); const [removeLinkMutation] = useRemoveLinkMutation(); const links = entityData?.institutionalMemory?.elements || []; - const handleDeleteLink = async (metadata: InstitutionalMemoryMetadata) => { - try { - await removeLinkMutation({ - variables: { input: { linkUrl: metadata.url, resourceUrn: metadata.associatedUrn || entityUrn } }, - }); - message.success({ content: 'Link Removed', duration: 2 }); - } catch (e: unknown) { - message.destroy(); - if (e instanceof Error) { - message.error({ content: `Error removing link: \n ${e.message || ''}`, duration: 2 }); + const handleDeleteLink = useCallback( + async (metadata: InstitutionalMemoryMetadata) => { + try { + await removeLinkMutation({ + variables: { + input: { + linkUrl: metadata.url, + label: metadata.label, + resourceUrn: metadata.associatedUrn || entityUrn, + }, + }, + }); + message.success({ content: 'Link Removed', duration: 2 }); + } catch (e: unknown) { + message.destroy(); + if (e instanceof Error) { + message.error({ content: `Error removing link: \n ${e.message || ''}`, duration: 2 }); + } } - } - refetch?.(); - }; + refetch?.(); + }, + [refetch, removeLinkMutation, entityUrn], + ); + + const updateLink = useCallback( + async (formData: FormData) => { + if (!editingMetadata) return; + + try { + await updateLinkMutation({ + variables: { + input: { + currentLabel: editingMetadata.label || editingMetadata.description, + currentUrl: editingMetadata.url, + resourceUrn: editingMetadata.associatedUrn || entityUrn, + label: formData.label, + linkUrl: formData.url, + }, + }, + }); + message.success({ content: 'Link Updated', duration: 2 }); + setIsEditFormModalOpened(false); + } catch (e: unknown) { + message.destroy(); + if (e instanceof Error) { + message.error({ content: `Error updating link: \n ${e.message || ''}`, duration: 2 }); + } + } + refetch?.(); + }, + [updateLinkMutation, entityUrn, editingMetadata, refetch], + ); + + const onEdit = useCallback((metadata: InstitutionalMemoryMetadata) => { + setEditingMetadata(metadata); + setInitialValuesOfEditForm({ + label: metadata.label || metadata.description, + url: metadata.url, + }); + setIsEditFormModalOpened(true); + }, []); + + const onEditFormModalClosed = useCallback(() => { + setEditingMetadata(undefined); + setIsEditFormModalOpened(false); + setInitialValuesOfEditForm(undefined); + }, []); return entityData ? ( <> @@ -64,9 +130,14 @@ export const LinkList = ({ refetch }: LinkListProps) => { renderItem={(link) => ( handleDeleteLink(link)} type="text" shape="circle" danger> - - + + + + } > { )} /> )} + ) : null; }; diff --git a/datahub-web-react/src/app/previewV2/shared.tsx b/datahub-web-react/src/app/previewV2/shared.tsx index 2acf749af4..a06cc3f21f 100644 --- a/datahub-web-react/src/app/previewV2/shared.tsx +++ b/datahub-web-react/src/app/previewV2/shared.tsx @@ -6,5 +6,6 @@ export const ActionsAndStatusSection = styled.div` display: flex; flex-direction: row; align-items: center; + justify-content: flex-end; gap: 5px; `; diff --git a/datahub-web-react/src/graphql/mutations.graphql b/datahub-web-react/src/graphql/mutations.graphql index 4edc89384d..7ae679a2c0 100644 --- a/datahub-web-react/src/graphql/mutations.graphql +++ b/datahub-web-react/src/graphql/mutations.graphql @@ -34,6 +34,10 @@ mutation addLink($input: AddLinkInput!) { addLink(input: $input) } +mutation updateLink($input: UpdateLinkInput!) { + updateLink(input: $input) +} + mutation removeLink($input: RemoveLinkInput!) { removeLink(input: $input) } diff --git a/metadata-models/src/main/pegasus/com/linkedin/common/InstitutionalMemoryMetadata.pdl b/metadata-models/src/main/pegasus/com/linkedin/common/InstitutionalMemoryMetadata.pdl index ce55e7a5b3..a89aa76df5 100644 --- a/metadata-models/src/main/pegasus/com/linkedin/common/InstitutionalMemoryMetadata.pdl +++ b/metadata-models/src/main/pegasus/com/linkedin/common/InstitutionalMemoryMetadata.pdl @@ -19,4 +19,9 @@ record InstitutionalMemoryMetadata { * Audit stamp associated with creation of this record */ createStamp: AuditStamp + + /** + * Audit stamp associated with updation of this record + */ + updateStamp: optional AuditStamp } \ No newline at end of file diff --git a/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.aspects.snapshot.json b/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.aspects.snapshot.json index e8238ab57d..33e2e4a32b 100644 --- a/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.aspects.snapshot.json +++ b/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.aspects.snapshot.json @@ -1152,6 +1152,11 @@ "name" : "createStamp", "type" : "AuditStamp", "doc" : "Audit stamp associated with creation of this record" + }, { + "name" : "updateStamp", + "type" : "AuditStamp", + "doc" : "Audit stamp associated with updation of this record", + "optional" : true } ] } }, diff --git a/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.entities.snapshot.json b/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.entities.snapshot.json index 246882091a..ae0ca12c58 100644 --- a/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.entities.snapshot.json +++ b/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.entities.snapshot.json @@ -1135,6 +1135,11 @@ "name" : "createStamp", "type" : "AuditStamp", "doc" : "Audit stamp associated with creation of this record" + }, { + "name" : "updateStamp", + "type" : "AuditStamp", + "doc" : "Audit stamp associated with updation of this record", + "optional" : true } ] } }, diff --git a/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.runs.snapshot.json b/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.runs.snapshot.json index e0b51f687c..a1f2f8f6ab 100644 --- a/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.runs.snapshot.json +++ b/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.runs.snapshot.json @@ -871,6 +871,11 @@ "name" : "createStamp", "type" : "AuditStamp", "doc" : "Audit stamp associated with creation of this record" + }, { + "name" : "updateStamp", + "type" : "AuditStamp", + "doc" : "Audit stamp associated with updation of this record", + "optional" : true } ] } }, diff --git a/metadata-service/restli-api/src/main/snapshot/com.linkedin.operations.operations.snapshot.json b/metadata-service/restli-api/src/main/snapshot/com.linkedin.operations.operations.snapshot.json index 953493bea2..bcb150d82d 100644 --- a/metadata-service/restli-api/src/main/snapshot/com.linkedin.operations.operations.snapshot.json +++ b/metadata-service/restli-api/src/main/snapshot/com.linkedin.operations.operations.snapshot.json @@ -871,6 +871,11 @@ "name" : "createStamp", "type" : "AuditStamp", "doc" : "Audit stamp associated with creation of this record" + }, { + "name" : "updateStamp", + "type" : "AuditStamp", + "doc" : "Audit stamp associated with updation of this record", + "optional" : true } ] } }, diff --git a/metadata-service/restli-api/src/main/snapshot/com.linkedin.platform.platform.snapshot.json b/metadata-service/restli-api/src/main/snapshot/com.linkedin.platform.platform.snapshot.json index 387cebe999..f838990c2d 100644 --- a/metadata-service/restli-api/src/main/snapshot/com.linkedin.platform.platform.snapshot.json +++ b/metadata-service/restli-api/src/main/snapshot/com.linkedin.platform.platform.snapshot.json @@ -1135,6 +1135,11 @@ "name" : "createStamp", "type" : "AuditStamp", "doc" : "Audit stamp associated with creation of this record" + }, { + "name" : "updateStamp", + "type" : "AuditStamp", + "doc" : "Audit stamp associated with updation of this record", + "optional" : true } ] } }, diff --git a/smoke-test/tests/cypress/cypress/e2e/domains/nested_domains.js b/smoke-test/tests/cypress/cypress/e2e/domains/nested_domains.js index 695d69913c..bdf0b8f4fc 100644 --- a/smoke-test/tests/cypress/cypress/e2e/domains/nested_domains.js +++ b/smoke-test/tests/cypress/cypress/e2e/domains/nested_domains.js @@ -126,9 +126,9 @@ describe("Verify nested domains test functionalities", () => { cy.waitTextVisible("Test added"); cy.clickFirstOptionWithTestId("add-link-button"); cy.waitTextVisible("Add Link"); - cy.enterTextInTestId("add-link-modal-url", "www.test.com"); - cy.enterTextInTestId("add-link-modal-label", "Test Label"); - cy.clickOptionWithTestId("add-link-modal-add-button"); + cy.enterTextInTestId("link-form-modal-url", "www.test.com"); + cy.enterTextInTestId("link-form-modal-label", "Test Label"); + cy.clickOptionWithTestId("link-form-modal-submit-button"); cy.waitTextVisible("Test Label"); cy.goToDomainList(); cy.waitTextVisible("Test added"); @@ -148,9 +148,9 @@ describe("Verify nested domains test functionalities", () => { cy.waitTextVisible("Test documentation"); cy.clickFirstOptionWithSpecificTestId("add-link-button", 1); cy.waitTextVisible("URL"); - cy.enterTextInTestId("add-link-modal-url", "www.test.com"); - cy.enterTextInTestId("add-link-modal-label", "Test Label"); - cy.clickOptionWithTestId("add-link-modal-add-button"); + cy.enterTextInTestId("link-form-modal-url", "www.test.com"); + cy.enterTextInTestId("link-form-modal-label", "Test Label"); + cy.clickOptionWithTestId("link-form-modal-submit-button"); cy.waitTextVisible("Test Label"); // add owners @@ -201,9 +201,9 @@ describe("Verify nested domains test functionalities", () => { cy.waitTextVisible("Test added"); cy.clickFirstOptionWithTestId("add-link-button"); cy.waitTextVisible("Add Link"); - cy.enterTextInTestId("add-link-modal-url", "www.test.com"); - cy.enterTextInTestId("add-link-modal-label", "Test Label"); - cy.clickOptionWithTestId("add-link-modal-add-button"); + cy.enterTextInTestId("link-form-modal-url", "www.test.com"); + cy.enterTextInTestId("link-form-modal-label", "Test Label"); + cy.clickOptionWithTestId("link-form-modal-submit-button"); cy.waitTextVisible("Test Label"); cy.goToDomainList(); cy.waitTextVisible("Test added"); diff --git a/smoke-test/tests/cypress/cypress/e2e/domainsV2/v2_nested_domains.js b/smoke-test/tests/cypress/cypress/e2e/domainsV2/v2_nested_domains.js index 8073bc3bea..a6061b8f72 100644 --- a/smoke-test/tests/cypress/cypress/e2e/domainsV2/v2_nested_domains.js +++ b/smoke-test/tests/cypress/cypress/e2e/domainsV2/v2_nested_domains.js @@ -152,9 +152,9 @@ describe("Verify nested domains test functionalities", () => { // Add a new link cy.clickFirstOptionWithTestId("add-link-button"); - cy.enterTextInTestId("add-link-modal-url", "www.test.com"); - cy.enterTextInTestId("add-link-modal-label", "Test Label"); - cy.clickOptionWithTestId("add-link-modal-add-button"); + cy.enterTextInTestId("link-form-modal-url", "www.test.com"); + cy.enterTextInTestId("link-form-modal-label", "Test Label"); + cy.clickOptionWithTestId("link-form-modal-submit-button"); // Verify link addition cy.waitTextVisible("Test Label"); @@ -189,9 +189,9 @@ describe("Verify nested domains test functionalities", () => { // Add a new link cy.clickOptionWithTestId("add-link-button"); - cy.enterTextInTestId("add-link-modal-url", "www.test.com"); - cy.enterTextInTestId("add-link-modal-label", "Test Label"); - cy.clickOptionWithTestId("add-link-modal-add-button"); + cy.enterTextInTestId("link-form-modal-url", "www.test.com"); + cy.enterTextInTestId("link-form-modal-label", "Test Label"); + cy.clickOptionWithTestId("link-form-modal-submit-button"); // Verify link addition cy.waitTextVisible("Test Label"); diff --git a/smoke-test/tests/cypress/cypress/e2e/mutations/edit_documentation.js b/smoke-test/tests/cypress/cypress/e2e/mutations/edit_documentation.js index f772620fb9..623e6a90cf 100644 --- a/smoke-test/tests/cypress/cypress/e2e/mutations/edit_documentation.js +++ b/smoke-test/tests/cypress/cypress/e2e/mutations/edit_documentation.js @@ -48,14 +48,14 @@ describe("edit documentation and link to dataset", () => { .click({ force: true }); cy.waitTextVisible("Link Removed"); cy.clickOptionWithTestId("add-link-button").wait(1000); - cy.enterTextInTestId("add-link-modal-url", wrong_url); + cy.enterTextInTestId("link-form-modal-url", wrong_url); cy.waitTextVisible("This field must be a valid url."); cy.focused().clear(); cy.waitTextVisible("A URL is required."); - cy.enterTextInTestId("add-link-modal-url", correct_url); + cy.enterTextInTestId("link-form-modal-url", correct_url); cy.ensureTextNotPresent("This field must be a valid url."); - cy.enterTextInTestId("add-link-modal-label", "Sample doc"); - cy.clickOptionWithTestId("add-link-modal-add-button"); + cy.enterTextInTestId("link-form-modal-label", "Sample doc"); + cy.clickOptionWithTestId("link-form-modal-submit-button"); cy.waitTextVisible("Link Added"); cy.openEntityTab("Documentation"); cy.get(`[href='${correct_url}']`).should("be.visible"); @@ -66,14 +66,14 @@ describe("edit documentation and link to dataset", () => { cy.visit("/domain/urn:li:domain:marketing/Entities"); cy.waitTextVisible("SampleCypressKafkaDataset"); cy.clickOptionWithTestId("add-link-button").wait(1000); - cy.enterTextInTestId("add-link-modal-url", wrong_url); + cy.enterTextInTestId("link-form-modal-url", wrong_url); cy.waitTextVisible("This field must be a valid url."); cy.focused().clear(); cy.waitTextVisible("A URL is required."); - cy.enterTextInTestId("add-link-modal-url", correct_url); + cy.enterTextInTestId("link-form-modal-url", correct_url); cy.ensureTextNotPresent("This field must be a valid url."); - cy.enterTextInTestId("add-link-modal-label", "Sample doc"); - cy.clickOptionWithTestId("add-link-modal-add-button"); + cy.enterTextInTestId("link-form-modal-label", "Sample doc"); + cy.clickOptionWithTestId("link-form-modal-submit-button"); cy.waitTextVisible("Link Added"); cy.openEntityTab("Documentation"); cy.get("[data-testid='edit-documentation-button']").should("be.visible");