feat(links): bring edit links functionality from SaaS (#14559)

Co-authored-by: v-tarasevich-blitz-brain <v.tarasevich@blitz-brain.com>
This commit is contained in:
purnimagarg1 2025-08-27 23:38:20 +05:30 committed by GitHub
parent 4f98d15d9a
commit fd053255cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1152 additions and 128 deletions

View File

@ -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.RemoveTermResolver;
import com.linkedin.datahub.graphql.resolvers.mutate.UpdateDescriptionResolver; import com.linkedin.datahub.graphql.resolvers.mutate.UpdateDescriptionResolver;
import com.linkedin.datahub.graphql.resolvers.mutate.UpdateDisplayPropertiesResolver; 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.UpdateNameResolver;
import com.linkedin.datahub.graphql.resolvers.mutate.UpdateParentNodeResolver; import com.linkedin.datahub.graphql.resolvers.mutate.UpdateParentNodeResolver;
import com.linkedin.datahub.graphql.resolvers.mutate.UpdateUserSettingResolver; import com.linkedin.datahub.graphql.resolvers.mutate.UpdateUserSettingResolver;
@ -1205,6 +1206,7 @@ public class GmsGraphQLEngine {
.dataFetcher( .dataFetcher(
"batchRemoveOwners", new BatchRemoveOwnersResolver(entityService, entityClient)) "batchRemoveOwners", new BatchRemoveOwnersResolver(entityService, entityClient))
.dataFetcher("addLink", new AddLinkResolver(entityService, this.entityClient)) .dataFetcher("addLink", new AddLinkResolver(entityService, this.entityClient))
.dataFetcher("updateLink", new UpdateLinkResolver(entityService, this.entityClient))
.dataFetcher("removeLink", new RemoveLinkResolver(entityService, entityClient)) .dataFetcher("removeLink", new RemoveLinkResolver(entityService, entityClient))
.dataFetcher("addGroupMembers", new AddGroupMembersResolver(this.groupService)) .dataFetcher("addGroupMembers", new AddGroupMembersResolver(this.groupService))
.dataFetcher("removeGroupMembers", new RemoveGroupMembersResolver(this.groupService)) .dataFetcher("removeGroupMembers", new RemoveGroupMembersResolver(this.groupService))

View File

@ -32,6 +32,7 @@ public class RemoveLinkResolver implements DataFetcher<CompletableFuture<Boolean
bindArgument(environment.getArgument("input"), RemoveLinkInput.class); bindArgument(environment.getArgument("input"), RemoveLinkInput.class);
String linkUrl = input.getLinkUrl(); String linkUrl = input.getLinkUrl();
String label = input.getLabel();
Urn targetUrn = Urn.createFromString(input.getResourceUrn()); Urn targetUrn = Urn.createFromString(input.getResourceUrn());
if (!LinkUtils.isAuthorizedToUpdateLinks(context, targetUrn) if (!LinkUtils.isAuthorizedToUpdateLinks(context, targetUrn)
@ -49,7 +50,7 @@ public class RemoveLinkResolver implements DataFetcher<CompletableFuture<Boolean
Urn actor = CorpuserUrn.createFromString(context.getActorUrn()); Urn actor = CorpuserUrn.createFromString(context.getActorUrn());
LinkUtils.removeLink( LinkUtils.removeLink(
context.getOperationContext(), linkUrl, targetUrn, actor, _entityService); context.getOperationContext(), linkUrl, label, targetUrn, actor, _entityService);
return true; return true;
} catch (Exception e) { } catch (Exception e) {
log.error( log.error(

View File

@ -0,0 +1,78 @@
package com.linkedin.datahub.graphql.resolvers.mutate;
import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.*;
import com.linkedin.common.urn.CorpuserUrn;
import com.linkedin.common.urn.Urn;
import com.linkedin.datahub.graphql.QueryContext;
import com.linkedin.datahub.graphql.concurrency.GraphQLConcurrencyUtils;
import com.linkedin.datahub.graphql.exception.AuthorizationException;
import com.linkedin.datahub.graphql.generated.UpdateLinkInput;
import com.linkedin.datahub.graphql.resolvers.mutate.util.GlossaryUtils;
import com.linkedin.datahub.graphql.resolvers.mutate.util.LinkUtils;
import com.linkedin.entity.client.EntityClient;
import com.linkedin.metadata.entity.EntityService;
import graphql.schema.DataFetcher;
import graphql.schema.DataFetchingEnvironment;
import java.util.concurrent.CompletableFuture;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@RequiredArgsConstructor
public class UpdateLinkResolver implements DataFetcher<CompletableFuture<Boolean>> {
private final EntityService _entityService;
private final EntityClient _entityClient;
@Override
public CompletableFuture<Boolean> 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");
}
}

View File

@ -17,6 +17,8 @@ import com.linkedin.metadata.authorization.PoliciesConfig;
import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.entity.EntityService;
import com.linkedin.metadata.entity.EntityUtils; import com.linkedin.metadata.entity.EntityUtils;
import io.datahubproject.metadata.context.OperationContext; import io.datahubproject.metadata.context.OperationContext;
import java.util.Optional;
import java.util.function.Predicate;
import javax.annotation.Nonnull; import javax.annotation.Nonnull;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -53,9 +55,12 @@ public class LinkUtils {
entityService); entityService);
} }
public static void removeLink( public static void updateLink(
@Nonnull OperationContext opContext, @Nonnull OperationContext opContext,
String linkUrl, String currentLinkUrl,
String currentLinkLabel,
String newLinkUrl,
String newLinkLabel,
Urn resourceUrn, Urn resourceUrn,
Urn actor, Urn actor,
EntityService<?> entityService) { EntityService<?> entityService) {
@ -67,7 +72,39 @@ public class LinkUtils {
Constants.INSTITUTIONAL_MEMORY_ASPECT_NAME, Constants.INSTITUTIONAL_MEMORY_ASPECT_NAME,
entityService, entityService,
new InstitutionalMemory()); 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( persistAspect(
opContext, opContext,
resourceUrn, resourceUrn,
@ -85,25 +122,92 @@ public class LinkUtils {
InstitutionalMemoryMetadataArray linksArray = institutionalMemoryAspect.getElements(); 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(); InstitutionalMemoryMetadata newLink = new InstitutionalMemoryMetadata();
newLink.setUrl(new Url(linkUrl)); newLink.setUrl(new Url(linkUrl));
newLink.setCreateStamp(EntityUtils.getAuditStamp(actor)); newLink.setCreateStamp(EntityUtils.getAuditStamp(actor));
newLink.setDescription(linkLabel); // We no longer support, this is really a label. 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); linksArray.add(newLink);
} }
private static void removeLink(InstitutionalMemory institutionalMemoryAspect, String linkUrl) { private static void removeLink(
InstitutionalMemory institutionalMemoryAspect, String linkUrl, String label) {
if (!institutionalMemoryAspect.hasElements()) { if (!institutionalMemoryAspect.hasElements()) {
institutionalMemoryAspect.setElements(new InstitutionalMemoryMetadataArray()); institutionalMemoryAspect.setElements(new InstitutionalMemoryMetadataArray());
} }
InstitutionalMemoryMetadataArray elementsArray = institutionalMemoryAspect.getElements(); 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<InstitutionalMemoryMetadata> 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<InstitutionalMemoryMetadata> 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) { public static boolean isAuthorizedToUpdateLinks(@Nonnull QueryContext context, Urn resourceUrn) {
@ -118,28 +222,44 @@ public class LinkUtils {
context, resourceUrn.getEntityType(), resourceUrn.toString(), orPrivilegeGroups); context, resourceUrn.getEntityType(), resourceUrn.toString(), orPrivilegeGroups);
} }
public static Boolean validateAddRemoveInput( public static void validateAddRemoveInput(
@Nonnull OperationContext opContext, @Nonnull OperationContext opContext,
String linkUrl, String linkUrl,
Urn resourceUrn, Urn resourceUrn,
EntityService<?> entityService) { 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 { try {
new Url(linkUrl); new Url(url);
} catch (Exception e) { } catch (Exception e) {
throw new IllegalArgumentException( throw new IllegalArgumentException(
String.format( 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)); resourceUrn));
} }
}
private static void validateResourceUrn(
@Nonnull OperationContext opContext, Urn resourceUrn, EntityService<?> entityService) {
if (!entityService.exists(opContext, resourceUrn, true)) { if (!entityService.exists(opContext, resourceUrn, true)) {
throw new IllegalArgumentException( throw new IllegalArgumentException(
String.format( String.format(
"Failed to change institutional memory for resource %s. Resource does not exist.", "Failed to change institutional memory for resource %s. Resource does not exist.",
resourceUrn)); resourceUrn));
} }
return true;
} }
} }

View File

@ -30,6 +30,9 @@ public class InstitutionalMemoryMetadataMapper {
result.setAuthor(getAuthor(input.getCreateStamp().getActor().toString())); result.setAuthor(getAuthor(input.getCreateStamp().getActor().toString()));
result.setActor(ResolvedActorMapper.map(input.getCreateStamp().getActor())); result.setActor(ResolvedActorMapper.map(input.getCreateStamp().getActor()));
result.setCreated(AuditStampMapper.map(context, input.getCreateStamp())); result.setCreated(AuditStampMapper.map(context, input.getCreateStamp()));
if (input.getUpdateStamp() != null) {
result.setUpdated(AuditStampMapper.map(context, input.getUpdateStamp()));
}
result.setAssociatedUrn(entityUrn.toString()); result.setAssociatedUrn(entityUrn.toString());
return result; return result;
} }

View File

@ -604,6 +604,11 @@ type Mutation {
""" """
addLink(input: AddLinkInput!): Boolean 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 Remove a link, or institutional memory, from a particular Entity
""" """
@ -3291,6 +3296,11 @@ type InstitutionalMemoryMetadata {
""" """
created: AuditStamp! created: AuditStamp!
"""
An AuditStamp corresponding to the updating of this resource
"""
updated: AuditStamp
""" """
Deprecated, use label instead Deprecated, use label instead
Description of the resource Description of the resource
@ -9121,15 +9131,50 @@ input AddLinkInput {
resourceUrn: String! 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 provided when removing the association between a Metadata Entity and a Link
""" """
input RemoveLinkInput { 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! 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 The urn of the resource or entity to attach the link to, for example a dataset urn
""" """

View File

@ -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);
}
}

View File

@ -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<ChangeItemImpl> initMockEntityService(
@Nonnull Boolean entityExists, @Nullable RecordTemplate currentAspect) {
EntityService<ChangeItemImpl> 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<MetadataChangeProposal> 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());
});
}
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -72,7 +72,12 @@ export const AddLinkModal = ({ buttonProps, refetch }: AddLinkProps) => {
<Button type="text" onClick={handleClose}> <Button type="text" onClick={handleClose}>
Cancel Cancel
</Button>, </Button>,
<Button data-testid="add-link-modal-add-button" form="addLinkForm" key="submit" htmlType="submit"> <Button
data-testid="link-form-modal-submit-button"
form="addLinkForm"
key="submit"
htmlType="submit"
>
Add Add
</Button>, </Button>,
]} ]}
@ -80,7 +85,7 @@ export const AddLinkModal = ({ buttonProps, refetch }: AddLinkProps) => {
> >
<Form form={form} name="addLinkForm" onFinish={handleAdd} layout="vertical"> <Form form={form} name="addLinkForm" onFinish={handleAdd} layout="vertical">
<Form.Item <Form.Item
data-testid="add-link-modal-url" data-testid="link-form-modal-url"
name="url" name="url"
label="URL" label="URL"
rules={[ rules={[
@ -98,7 +103,7 @@ export const AddLinkModal = ({ buttonProps, refetch }: AddLinkProps) => {
<Input placeholder="https://" autoFocus /> <Input placeholder="https://" autoFocus />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
data-testid="add-link-modal-label" data-testid="link-form-modal-label"
name="label" name="label"
label="Label" label="Label"
rules={[ rules={[

View File

@ -49,7 +49,13 @@ export const LinkList = ({ refetch }: LinkListProps) => {
const handleDeleteLink = async (metadata: InstitutionalMemoryMetadata) => { const handleDeleteLink = async (metadata: InstitutionalMemoryMetadata) => {
try { try {
await removeLinkMutation({ 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 }); message.success({ content: 'Link Removed', duration: 2 });
} catch (e: unknown) { } catch (e: unknown) {
@ -79,7 +85,13 @@ export const LinkList = ({ refetch }: LinkListProps) => {
if (!linkDetails) return; if (!linkDetails) return;
try { try {
await removeLinkMutation({ 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({ await addLinkMutation({
variables: { input: { linkUrl: formData.url, label: formData.label, resourceUrn: mutationUrn } }, variables: { input: { linkUrl: formData.url, label: formData.label, resourceUrn: mutationUrn } },

View File

@ -104,8 +104,8 @@ export default function LinkAssetVersionModal({ urn, entityType, closeModal, ref
title="Link a Newer Version" title="Link a Newer Version"
onCancel={close} onCancel={close}
buttons={[ buttons={[
{ text: 'Cancel', variant: 'text', onClick: close }, { text: 'Cancel', variant: 'text', onClick: close, key: 'Cancel' },
{ text: 'Create', variant: 'filled', onClick: handleLink }, { text: 'Create', variant: 'filled', onClick: handleLink, key: 'Create' },
]} ]}
> >
<Form <Form

View File

@ -54,8 +54,8 @@ export default function UnlinkAssetVersionModal({ urn, entityType, closeModal, v
title="Are you sure?" title="Are you sure?"
subtitle="Would you like to unlink this version?" subtitle="Would you like to unlink this version?"
buttons={[ buttons={[
{ text: 'No', variant: 'text', onClick: closeModal }, { text: 'No', variant: 'text', onClick: closeModal, key: 'no' },
{ text: 'Yes', variant: 'filled', onClick: handleUnlink }, { text: 'Yes', variant: 'filled', onClick: handleUnlink, key: 'yes' },
]} ]}
onCancel={closeModal} onCancel={closeModal}
/> />

View File

@ -1,44 +1,47 @@
import { PlusOutlined } from '@ant-design/icons'; 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 React, { useState } from 'react';
import analytics, { EntityActionType, EventType } from '@app/analytics'; import analytics, { EntityActionType, EventType } from '@app/analytics';
import { useUserContext } from '@app/context/useUserContext'; import { useUserContext } from '@app/context/useUserContext';
import { useEntityData, useMutationUrn } from '@app/entity/shared/EntityContext'; 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 { Button } from '@src/alchemy-components';
import { ModalButtonContainer } from '@src/app/shared/button/styledComponents';
import { useAddLinkMutation } from '@graphql/mutations.generated'; import { useAddLinkMutation } from '@graphql/mutations.generated';
type AddLinkProps = { interface Props {
buttonProps?: Record<string, unknown>; buttonProps?: Record<string, unknown>;
refetch?: () => Promise<any>; refetch?: () => Promise<any>;
buttonType?: string; buttonType?: string;
}; }
export const AddLinkModal = ({ buttonProps, refetch, buttonType }: AddLinkProps) => { export const AddLinkModal = ({ buttonProps, refetch, buttonType }: Props) => {
const [isModalVisible, setIsModalVisible] = useState(false); const [isModalVisible, setIsModalVisible] = useState(false);
const mutationUrn = useMutationUrn(); const mutationUrn = useMutationUrn();
const user = useUserContext(); const user = useUserContext();
const { entityType } = useEntityData(); const { entityType } = useEntityData();
const [addLinkMutation] = useAddLinkMutation(); const [addLinkMutation] = useAddLinkMutation();
const [form] = Form.useForm();
const showModal = () => { const showModal = () => {
setIsModalVisible(true); setIsModalVisible(true);
}; };
const handleClose = () => { const handleClose = () => {
form.resetFields();
setIsModalVisible(false); setIsModalVisible(false);
}; };
const handleAdd = async (formData: any) => { const handleAdd = async (formData: FormData) => {
if (user?.urn) { if (user?.urn) {
try { try {
await addLinkMutation({ 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 }); message.success({ content: 'Link Added', duration: 2 });
analytics.event({ analytics.event({
@ -47,6 +50,7 @@ export const AddLinkModal = ({ buttonProps, refetch, buttonType }: AddLinkProps)
entityUrn: mutationUrn, entityUrn: mutationUrn,
actionType: EntityActionType.UpdateLinks, actionType: EntityActionType.UpdateLinks,
}); });
handleClose();
} catch (e: unknown) { } catch (e: unknown) {
message.destroy(); message.destroy();
if (e instanceof Error) { if (e instanceof Error) {
@ -54,7 +58,6 @@ export const AddLinkModal = ({ buttonProps, refetch, buttonType }: AddLinkProps)
} }
} }
refetch?.(); refetch?.();
handleClose();
} else { } else {
message.error({ content: `Error adding link: no user`, duration: 2 }); message.error({ content: `Error adding link: no user`, duration: 2 });
} }
@ -88,56 +91,7 @@ export const AddLinkModal = ({ buttonProps, refetch, buttonType }: AddLinkProps)
return ( return (
<> <>
{renderButton(buttonType)} {renderButton(buttonType)}
<Modal <LinkFormModal variant="create" open={isModalVisible} onSubmit={handleAdd} onCancel={handleClose} />
title="Add Link"
visible={isModalVisible}
destroyOnClose
onCancel={handleClose}
footer={[
<ModalButtonContainer>
<Button variant="text" onClick={handleClose}>
Cancel
</Button>
<Button data-testid="add-link-modal-add-button" form="addLinkForm" key="submit">
Add
</Button>
</ModalButtonContainer>,
]}
>
<Form form={form} name="addLinkForm" onFinish={handleAdd} layout="vertical">
<Form.Item
data-testid="add-link-modal-url"
name="url"
label="URL"
rules={[
{
required: true,
message: 'A URL is required.',
},
{
type: 'url',
warningOnly: true,
message: 'This field must be a valid url.',
},
]}
>
<Input placeholder="https://" autoFocus />
</Form.Item>
<Form.Item
data-testid="add-link-modal-label"
name="label"
label="Label"
rules={[
{
required: true,
message: 'A label is required.',
},
]}
>
<Input placeholder="A short label for this link" />
</Form.Item>
</Form>
</Modal>
</> </>
); );
}; };

View File

@ -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<FormData>;
variant: 'create' | 'update';
onSubmit: (formData: FormData) => void;
onCancel: () => void;
}
export const LinkFormModal = ({ open, initialValues, variant, onSubmit, onCancel }: Props) => {
const [form] = Form.useForm<FormData>();
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 (
<Modal
title={title}
open={open}
destroyOnClose
onCancel={onCancelHandler}
buttons={[
{ text: 'Cancel', variant: 'outline', color: 'gray', onClick: onCancelHandler },
{ text: submitButtonText, onClick: () => form.submit() },
]}
>
<Form form={form} name="linkForm" onFinish={onSubmit} layout="vertical">
<Form.Item
data-testid="link-form-modal-url"
name="url"
initialValue={initialValues?.url}
rules={[
{
required: true,
message: 'A URL is required.',
},
{
type: 'url',
message: 'This field must be a valid url.',
},
]}
>
<Input label="URL" placeholder="https://" autoFocus />
</Form.Item>
<Form.Item
data-testid="link-form-modal-label"
name="label"
initialValue={initialValues?.label}
rules={[
{
required: true,
message: 'A label is required.',
},
]}
>
<Input label="Label" placeholder="A short label for this link" />
</Form.Item>
</Form>
</Modal>
);
};

View File

@ -1,15 +1,18 @@
import { DeleteOutlined, LinkOutlined } from '@ant-design/icons'; import { DeleteOutlined, LinkOutlined } from '@ant-design/icons';
import { colors } from '@components';
import { Button, List, Typography, message } from 'antd'; 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 { Link } from 'react-router-dom';
import styled from 'styled-components/macro'; import styled from 'styled-components/macro';
import { useEntityData } from '@app/entity/shared/EntityContext'; 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 { ANTD_GRAY } from '@app/entityV2/shared/constants';
import { formatDateString } from '@app/entityV2/shared/containers/profile/utils'; import { formatDateString } from '@app/entityV2/shared/containers/profile/utils';
import { useEntityRegistry } from '@app/useEntityRegistry'; import { useEntityRegistry } from '@app/useEntityRegistry';
import { useRemoveLinkMutation } from '@graphql/mutations.generated'; import { useRemoveLinkMutation, useUpdateLinkMutation } from '@graphql/mutations.generated';
import { InstitutionalMemoryMetadata } from '@types'; import { InstitutionalMemoryMetadata } from '@types';
const LinkListItem = styled(List.Item)` 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` const ListOffsetIcon = styled.span`
margin-left: -18px; margin-left: -18px;
margin-right: 6px; margin-right: 6px;
@ -35,25 +44,82 @@ type LinkListProps = {
}; };
export const LinkList = ({ refetch }: LinkListProps) => { export const LinkList = ({ refetch }: LinkListProps) => {
const [isEditFormModalOpened, setIsEditFormModalOpened] = useState<boolean>(false);
const [editingMetadata, setEditingMetadata] = useState<InstitutionalMemoryMetadata>();
const [initialValuesOfEditForm, setInitialValuesOfEditForm] = useState<FormData>();
const { urn: entityUrn, entityData } = useEntityData(); const { urn: entityUrn, entityData } = useEntityData();
const entityRegistry = useEntityRegistry(); const entityRegistry = useEntityRegistry();
const [updateLinkMutation] = useUpdateLinkMutation();
const [removeLinkMutation] = useRemoveLinkMutation(); const [removeLinkMutation] = useRemoveLinkMutation();
const links = entityData?.institutionalMemory?.elements || []; const links = entityData?.institutionalMemory?.elements || [];
const handleDeleteLink = async (metadata: InstitutionalMemoryMetadata) => { const handleDeleteLink = useCallback(
try { async (metadata: InstitutionalMemoryMetadata) => {
await removeLinkMutation({ try {
variables: { input: { linkUrl: metadata.url, resourceUrn: metadata.associatedUrn || entityUrn } }, await removeLinkMutation({
}); variables: {
message.success({ content: 'Link Removed', duration: 2 }); input: {
} catch (e: unknown) { linkUrl: metadata.url,
message.destroy(); label: metadata.label,
if (e instanceof Error) { resourceUrn: metadata.associatedUrn || entityUrn,
message.error({ content: `Error removing link: \n ${e.message || ''}`, duration: 2 }); },
},
});
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 ? ( return entityData ? (
<> <>
@ -64,9 +130,14 @@ export const LinkList = ({ refetch }: LinkListProps) => {
renderItem={(link) => ( renderItem={(link) => (
<LinkListItem <LinkListItem
extra={ extra={
<Button onClick={() => handleDeleteLink(link)} type="text" shape="circle" danger> <LinkButtonsContainer>
<DeleteOutlined /> <Button onClick={() => onEdit(link)} type="text" shape="circle">
</Button> <Pencil size={16} color={colors.gray[500]} />
</Button>
<Button onClick={() => handleDeleteLink(link)} type="text" shape="circle" danger>
<DeleteOutlined />
</Button>
</LinkButtonsContainer>
} }
> >
<List.Item.Meta <List.Item.Meta
@ -93,6 +164,13 @@ export const LinkList = ({ refetch }: LinkListProps) => {
)} )}
/> />
)} )}
<LinkFormModal
variant="update"
open={isEditFormModalOpened}
initialValues={initialValuesOfEditForm}
onCancel={onEditFormModalClosed}
onSubmit={updateLink}
/>
</> </>
) : null; ) : null;
}; };

View File

@ -6,5 +6,6 @@ export const ActionsAndStatusSection = styled.div`
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: flex-end;
gap: 5px; gap: 5px;
`; `;

View File

@ -34,6 +34,10 @@ mutation addLink($input: AddLinkInput!) {
addLink(input: $input) addLink(input: $input)
} }
mutation updateLink($input: UpdateLinkInput!) {
updateLink(input: $input)
}
mutation removeLink($input: RemoveLinkInput!) { mutation removeLink($input: RemoveLinkInput!) {
removeLink(input: $input) removeLink(input: $input)
} }

View File

@ -19,4 +19,9 @@ record InstitutionalMemoryMetadata {
* Audit stamp associated with creation of this record * Audit stamp associated with creation of this record
*/ */
createStamp: AuditStamp createStamp: AuditStamp
/**
* Audit stamp associated with updation of this record
*/
updateStamp: optional AuditStamp
} }

View File

@ -1152,6 +1152,11 @@
"name" : "createStamp", "name" : "createStamp",
"type" : "AuditStamp", "type" : "AuditStamp",
"doc" : "Audit stamp associated with creation of this record" "doc" : "Audit stamp associated with creation of this record"
}, {
"name" : "updateStamp",
"type" : "AuditStamp",
"doc" : "Audit stamp associated with updation of this record",
"optional" : true
} ] } ]
} }
}, },

View File

@ -1135,6 +1135,11 @@
"name" : "createStamp", "name" : "createStamp",
"type" : "AuditStamp", "type" : "AuditStamp",
"doc" : "Audit stamp associated with creation of this record" "doc" : "Audit stamp associated with creation of this record"
}, {
"name" : "updateStamp",
"type" : "AuditStamp",
"doc" : "Audit stamp associated with updation of this record",
"optional" : true
} ] } ]
} }
}, },

View File

@ -871,6 +871,11 @@
"name" : "createStamp", "name" : "createStamp",
"type" : "AuditStamp", "type" : "AuditStamp",
"doc" : "Audit stamp associated with creation of this record" "doc" : "Audit stamp associated with creation of this record"
}, {
"name" : "updateStamp",
"type" : "AuditStamp",
"doc" : "Audit stamp associated with updation of this record",
"optional" : true
} ] } ]
} }
}, },

View File

@ -871,6 +871,11 @@
"name" : "createStamp", "name" : "createStamp",
"type" : "AuditStamp", "type" : "AuditStamp",
"doc" : "Audit stamp associated with creation of this record" "doc" : "Audit stamp associated with creation of this record"
}, {
"name" : "updateStamp",
"type" : "AuditStamp",
"doc" : "Audit stamp associated with updation of this record",
"optional" : true
} ] } ]
} }
}, },

View File

@ -1135,6 +1135,11 @@
"name" : "createStamp", "name" : "createStamp",
"type" : "AuditStamp", "type" : "AuditStamp",
"doc" : "Audit stamp associated with creation of this record" "doc" : "Audit stamp associated with creation of this record"
}, {
"name" : "updateStamp",
"type" : "AuditStamp",
"doc" : "Audit stamp associated with updation of this record",
"optional" : true
} ] } ]
} }
}, },

View File

@ -126,9 +126,9 @@ describe("Verify nested domains test functionalities", () => {
cy.waitTextVisible("Test added"); cy.waitTextVisible("Test added");
cy.clickFirstOptionWithTestId("add-link-button"); cy.clickFirstOptionWithTestId("add-link-button");
cy.waitTextVisible("Add Link"); cy.waitTextVisible("Add Link");
cy.enterTextInTestId("add-link-modal-url", "www.test.com"); cy.enterTextInTestId("link-form-modal-url", "www.test.com");
cy.enterTextInTestId("add-link-modal-label", "Test Label"); cy.enterTextInTestId("link-form-modal-label", "Test Label");
cy.clickOptionWithTestId("add-link-modal-add-button"); cy.clickOptionWithTestId("link-form-modal-submit-button");
cy.waitTextVisible("Test Label"); cy.waitTextVisible("Test Label");
cy.goToDomainList(); cy.goToDomainList();
cy.waitTextVisible("Test added"); cy.waitTextVisible("Test added");
@ -148,9 +148,9 @@ describe("Verify nested domains test functionalities", () => {
cy.waitTextVisible("Test documentation"); cy.waitTextVisible("Test documentation");
cy.clickFirstOptionWithSpecificTestId("add-link-button", 1); cy.clickFirstOptionWithSpecificTestId("add-link-button", 1);
cy.waitTextVisible("URL"); cy.waitTextVisible("URL");
cy.enterTextInTestId("add-link-modal-url", "www.test.com"); cy.enterTextInTestId("link-form-modal-url", "www.test.com");
cy.enterTextInTestId("add-link-modal-label", "Test Label"); cy.enterTextInTestId("link-form-modal-label", "Test Label");
cy.clickOptionWithTestId("add-link-modal-add-button"); cy.clickOptionWithTestId("link-form-modal-submit-button");
cy.waitTextVisible("Test Label"); cy.waitTextVisible("Test Label");
// add owners // add owners
@ -201,9 +201,9 @@ describe("Verify nested domains test functionalities", () => {
cy.waitTextVisible("Test added"); cy.waitTextVisible("Test added");
cy.clickFirstOptionWithTestId("add-link-button"); cy.clickFirstOptionWithTestId("add-link-button");
cy.waitTextVisible("Add Link"); cy.waitTextVisible("Add Link");
cy.enterTextInTestId("add-link-modal-url", "www.test.com"); cy.enterTextInTestId("link-form-modal-url", "www.test.com");
cy.enterTextInTestId("add-link-modal-label", "Test Label"); cy.enterTextInTestId("link-form-modal-label", "Test Label");
cy.clickOptionWithTestId("add-link-modal-add-button"); cy.clickOptionWithTestId("link-form-modal-submit-button");
cy.waitTextVisible("Test Label"); cy.waitTextVisible("Test Label");
cy.goToDomainList(); cy.goToDomainList();
cy.waitTextVisible("Test added"); cy.waitTextVisible("Test added");

View File

@ -152,9 +152,9 @@ describe("Verify nested domains test functionalities", () => {
// Add a new link // Add a new link
cy.clickFirstOptionWithTestId("add-link-button"); cy.clickFirstOptionWithTestId("add-link-button");
cy.enterTextInTestId("add-link-modal-url", "www.test.com"); cy.enterTextInTestId("link-form-modal-url", "www.test.com");
cy.enterTextInTestId("add-link-modal-label", "Test Label"); cy.enterTextInTestId("link-form-modal-label", "Test Label");
cy.clickOptionWithTestId("add-link-modal-add-button"); cy.clickOptionWithTestId("link-form-modal-submit-button");
// Verify link addition // Verify link addition
cy.waitTextVisible("Test Label"); cy.waitTextVisible("Test Label");
@ -189,9 +189,9 @@ describe("Verify nested domains test functionalities", () => {
// Add a new link // Add a new link
cy.clickOptionWithTestId("add-link-button"); cy.clickOptionWithTestId("add-link-button");
cy.enterTextInTestId("add-link-modal-url", "www.test.com"); cy.enterTextInTestId("link-form-modal-url", "www.test.com");
cy.enterTextInTestId("add-link-modal-label", "Test Label"); cy.enterTextInTestId("link-form-modal-label", "Test Label");
cy.clickOptionWithTestId("add-link-modal-add-button"); cy.clickOptionWithTestId("link-form-modal-submit-button");
// Verify link addition // Verify link addition
cy.waitTextVisible("Test Label"); cy.waitTextVisible("Test Label");

View File

@ -48,14 +48,14 @@ describe("edit documentation and link to dataset", () => {
.click({ force: true }); .click({ force: true });
cy.waitTextVisible("Link Removed"); cy.waitTextVisible("Link Removed");
cy.clickOptionWithTestId("add-link-button").wait(1000); 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.waitTextVisible("This field must be a valid url.");
cy.focused().clear(); cy.focused().clear();
cy.waitTextVisible("A URL is required."); 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.ensureTextNotPresent("This field must be a valid url.");
cy.enterTextInTestId("add-link-modal-label", "Sample doc"); cy.enterTextInTestId("link-form-modal-label", "Sample doc");
cy.clickOptionWithTestId("add-link-modal-add-button"); cy.clickOptionWithTestId("link-form-modal-submit-button");
cy.waitTextVisible("Link Added"); cy.waitTextVisible("Link Added");
cy.openEntityTab("Documentation"); cy.openEntityTab("Documentation");
cy.get(`[href='${correct_url}']`).should("be.visible"); 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.visit("/domain/urn:li:domain:marketing/Entities");
cy.waitTextVisible("SampleCypressKafkaDataset"); cy.waitTextVisible("SampleCypressKafkaDataset");
cy.clickOptionWithTestId("add-link-button").wait(1000); 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.waitTextVisible("This field must be a valid url.");
cy.focused().clear(); cy.focused().clear();
cy.waitTextVisible("A URL is required."); 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.ensureTextNotPresent("This field must be a valid url.");
cy.enterTextInTestId("add-link-modal-label", "Sample doc"); cy.enterTextInTestId("link-form-modal-label", "Sample doc");
cy.clickOptionWithTestId("add-link-modal-add-button"); cy.clickOptionWithTestId("link-form-modal-submit-button");
cy.waitTextVisible("Link Added"); cy.waitTextVisible("Link Added");
cy.openEntityTab("Documentation"); cy.openEntityTab("Documentation");
cy.get("[data-testid='edit-documentation-button']").should("be.visible"); cy.get("[data-testid='edit-documentation-button']").should("be.visible");