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.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))

View File

@ -32,6 +32,7 @@ public class RemoveLinkResolver implements DataFetcher<CompletableFuture<Boolean
bindArgument(environment.getArgument("input"), RemoveLinkInput.class);
String linkUrl = input.getLinkUrl();
String label = input.getLabel();
Urn targetUrn = Urn.createFromString(input.getResourceUrn());
if (!LinkUtils.isAuthorizedToUpdateLinks(context, targetUrn)
@ -49,7 +50,7 @@ public class RemoveLinkResolver implements DataFetcher<CompletableFuture<Boolean
Urn actor = CorpuserUrn.createFromString(context.getActorUrn());
LinkUtils.removeLink(
context.getOperationContext(), linkUrl, targetUrn, actor, _entityService);
context.getOperationContext(), linkUrl, label, targetUrn, actor, _entityService);
return true;
} catch (Exception e) {
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.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,26 +122,93 @@ 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();
// 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) {
final DisjunctivePrivilegeGroup orPrivilegeGroups =
@ -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;
}
}

View File

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

View File

@ -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
"""

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}>
Cancel
</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
</Button>,
]}
@ -80,7 +85,7 @@ export const AddLinkModal = ({ buttonProps, refetch }: AddLinkProps) => {
>
<Form form={form} name="addLinkForm" onFinish={handleAdd} layout="vertical">
<Form.Item
data-testid="add-link-modal-url"
data-testid="link-form-modal-url"
name="url"
label="URL"
rules={[
@ -98,7 +103,7 @@ export const AddLinkModal = ({ buttonProps, refetch }: AddLinkProps) => {
<Input placeholder="https://" autoFocus />
</Form.Item>
<Form.Item
data-testid="add-link-modal-label"
data-testid="link-form-modal-label"
name="label"
label="Label"
rules={[

View File

@ -49,7 +49,13 @@ export const LinkList = ({ refetch }: LinkListProps) => {
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 } },

View File

@ -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' },
]}
>
<Form

View File

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

View File

@ -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<string, unknown>;
refetch?: () => Promise<any>;
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)}
<Modal
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>
<LinkFormModal variant="create" open={isModalVisible} onSubmit={handleAdd} onCancel={handleClose} />
</>
);
};

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 { 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,15 +44,26 @@ type 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 entityRegistry = useEntityRegistry();
const [updateLinkMutation] = useUpdateLinkMutation();
const [removeLinkMutation] = useRemoveLinkMutation();
const links = entityData?.institutionalMemory?.elements || [];
const handleDeleteLink = async (metadata: InstitutionalMemoryMetadata) => {
const handleDeleteLink = useCallback(
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) {
@ -53,7 +73,53 @@ export const LinkList = ({ refetch }: LinkListProps) => {
}
}
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) => (
<LinkListItem
extra={
<LinkButtonsContainer>
<Button onClick={() => onEdit(link)} type="text" shape="circle">
<Pencil size={16} color={colors.gray[500]} />
</Button>
<Button onClick={() => handleDeleteLink(link)} type="text" shape="circle" danger>
<DeleteOutlined />
</Button>
</LinkButtonsContainer>
}
>
<List.Item.Meta
@ -93,6 +164,13 @@ export const LinkList = ({ refetch }: LinkListProps) => {
)}
/>
)}
<LinkFormModal
variant="update"
open={isEditFormModalOpened}
initialValues={initialValuesOfEditForm}
onCancel={onEditFormModalClosed}
onSubmit={updateLink}
/>
</>
) : null;
};

View File

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

View File

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

View File

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

View File

@ -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
} ]
}
},

View File

@ -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
} ]
}
},

View File

@ -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
} ]
}
},

View File

@ -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
} ]
}
},

View File

@ -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
} ]
}
},

View File

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

View File

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

View File

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