feat(groups & owners) Add ability to edit group name + assign creator as owner of metadata (#6047)

Adds ability to edit user group names from the UI. Also sets the creator or Glossary Terms, Glossary Nodes, and Domains to be the owner of said item.
This commit is contained in:
Ankit keshari 2022-10-07 01:06:39 +05:30 committed by GitHub
parent 3106e42e89
commit 11092c73cf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 230 additions and 50 deletions

View File

@ -767,7 +767,7 @@ public class GmsGraphQLEngine {
.dataFetcher("removeUser", new RemoveUserResolver(this.entityClient)) .dataFetcher("removeUser", new RemoveUserResolver(this.entityClient))
.dataFetcher("removeGroup", new RemoveGroupResolver(this.entityClient)) .dataFetcher("removeGroup", new RemoveGroupResolver(this.entityClient))
.dataFetcher("updateUserStatus", new UpdateUserStatusResolver(this.entityClient)) .dataFetcher("updateUserStatus", new UpdateUserStatusResolver(this.entityClient))
.dataFetcher("createDomain", new CreateDomainResolver(this.entityClient)) .dataFetcher("createDomain", new CreateDomainResolver(this.entityClient, this.entityService))
.dataFetcher("deleteDomain", new DeleteDomainResolver(entityClient)) .dataFetcher("deleteDomain", new DeleteDomainResolver(entityClient))
.dataFetcher("setDomain", new SetDomainResolver(this.entityClient, this.entityService)) .dataFetcher("setDomain", new SetDomainResolver(this.entityClient, this.entityService))
.dataFetcher("batchSetDomain", new BatchSetDomainResolver(this.entityService)) .dataFetcher("batchSetDomain", new BatchSetDomainResolver(this.entityService))
@ -789,8 +789,8 @@ public class GmsGraphQLEngine {
.dataFetcher("updateTest", new UpdateTestResolver(this.entityClient)) .dataFetcher("updateTest", new UpdateTestResolver(this.entityClient))
.dataFetcher("deleteTest", new DeleteTestResolver(this.entityClient)) .dataFetcher("deleteTest", new DeleteTestResolver(this.entityClient))
.dataFetcher("reportOperation", new ReportOperationResolver(this.entityClient)) .dataFetcher("reportOperation", new ReportOperationResolver(this.entityClient))
.dataFetcher("createGlossaryTerm", new CreateGlossaryTermResolver(this.entityClient)) .dataFetcher("createGlossaryTerm", new CreateGlossaryTermResolver(this.entityClient, this.entityService))
.dataFetcher("createGlossaryNode", new CreateGlossaryNodeResolver(this.entityClient)) .dataFetcher("createGlossaryNode", new CreateGlossaryNodeResolver(this.entityClient, this.entityService))
.dataFetcher("updateParentNode", new UpdateParentNodeResolver(entityService)) .dataFetcher("updateParentNode", new UpdateParentNodeResolver(entityService))
.dataFetcher("deleteGlossaryEntity", .dataFetcher("deleteGlossaryEntity",
new DeleteGlossaryEntityResolver(this.entityClient, this.entityService)) new DeleteGlossaryEntityResolver(this.entityClient, this.entityService))

View File

@ -5,9 +5,13 @@ import com.linkedin.datahub.graphql.QueryContext;
import com.linkedin.datahub.graphql.authorization.AuthorizationUtils; import com.linkedin.datahub.graphql.authorization.AuthorizationUtils;
import com.linkedin.datahub.graphql.exception.AuthorizationException; import com.linkedin.datahub.graphql.exception.AuthorizationException;
import com.linkedin.datahub.graphql.generated.CreateDomainInput; import com.linkedin.datahub.graphql.generated.CreateDomainInput;
import com.linkedin.datahub.graphql.generated.OwnerEntityType;
import com.linkedin.datahub.graphql.generated.OwnershipType;
import com.linkedin.datahub.graphql.resolvers.mutate.util.OwnerUtils;
import com.linkedin.domain.DomainProperties; import com.linkedin.domain.DomainProperties;
import com.linkedin.entity.client.EntityClient; import com.linkedin.entity.client.EntityClient;
import com.linkedin.events.metadata.ChangeType; import com.linkedin.events.metadata.ChangeType;
import com.linkedin.metadata.entity.EntityService;
import com.linkedin.metadata.Constants; import com.linkedin.metadata.Constants;
import com.linkedin.metadata.key.DomainKey; import com.linkedin.metadata.key.DomainKey;
import com.linkedin.metadata.utils.EntityKeyUtils; import com.linkedin.metadata.utils.EntityKeyUtils;
@ -30,6 +34,7 @@ import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.*;
public class CreateDomainResolver implements DataFetcher<CompletableFuture<String>> { public class CreateDomainResolver implements DataFetcher<CompletableFuture<String>> {
private final EntityClient _entityClient; private final EntityClient _entityClient;
private final EntityService _entityService;
@Override @Override
public CompletableFuture<String> get(DataFetchingEnvironment environment) throws Exception { public CompletableFuture<String> get(DataFetchingEnvironment environment) throws Exception {
@ -63,7 +68,9 @@ public class CreateDomainResolver implements DataFetcher<CompletableFuture<Strin
proposal.setAspect(GenericRecordUtils.serializeAspect(mapDomainProperties(input))); proposal.setAspect(GenericRecordUtils.serializeAspect(mapDomainProperties(input)));
proposal.setChangeType(ChangeType.UPSERT); proposal.setChangeType(ChangeType.UPSERT);
return _entityClient.ingestProposal(proposal, context.getAuthentication()); String domainUrn = _entityClient.ingestProposal(proposal, context.getAuthentication());
OwnerUtils.addCreatorAsOwner(context, domainUrn, OwnerEntityType.CORP_USER, OwnershipType.TECHNICAL_OWNER, _entityService);
return domainUrn;
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to create Domain with id: {}, name: {}: {}", input.getId(), input.getName(), e.getMessage()); log.error("Failed to create Domain with id: {}, name: {}: {}", input.getId(), input.getName(), e.getMessage());
throw new RuntimeException(String.format("Failed to create Domain with id: %s, name: %s", input.getId(), input.getName()), e); throw new RuntimeException(String.format("Failed to create Domain with id: %s, name: %s", input.getId(), input.getName()), e);

View File

@ -6,9 +6,13 @@ import com.linkedin.datahub.graphql.QueryContext;
import com.linkedin.datahub.graphql.authorization.AuthorizationUtils; import com.linkedin.datahub.graphql.authorization.AuthorizationUtils;
import com.linkedin.datahub.graphql.exception.AuthorizationException; import com.linkedin.datahub.graphql.exception.AuthorizationException;
import com.linkedin.datahub.graphql.generated.CreateGlossaryEntityInput; import com.linkedin.datahub.graphql.generated.CreateGlossaryEntityInput;
import com.linkedin.datahub.graphql.generated.OwnerEntityType;
import com.linkedin.datahub.graphql.generated.OwnershipType;
import com.linkedin.datahub.graphql.resolvers.mutate.util.OwnerUtils;
import com.linkedin.entity.client.EntityClient; import com.linkedin.entity.client.EntityClient;
import com.linkedin.events.metadata.ChangeType; import com.linkedin.events.metadata.ChangeType;
import com.linkedin.glossary.GlossaryNodeInfo; import com.linkedin.glossary.GlossaryNodeInfo;
import com.linkedin.metadata.entity.EntityService;
import com.linkedin.metadata.Constants; import com.linkedin.metadata.Constants;
import com.linkedin.metadata.key.GlossaryNodeKey; import com.linkedin.metadata.key.GlossaryNodeKey;
import com.linkedin.metadata.utils.EntityKeyUtils; import com.linkedin.metadata.utils.EntityKeyUtils;
@ -30,6 +34,7 @@ import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.bindArgument;
public class CreateGlossaryNodeResolver implements DataFetcher<CompletableFuture<String>> { public class CreateGlossaryNodeResolver implements DataFetcher<CompletableFuture<String>> {
private final EntityClient _entityClient; private final EntityClient _entityClient;
private final EntityService _entityService;
@Override @Override
public CompletableFuture<String> get(DataFetchingEnvironment environment) throws Exception { public CompletableFuture<String> get(DataFetchingEnvironment environment) throws Exception {
@ -56,7 +61,9 @@ public class CreateGlossaryNodeResolver implements DataFetcher<CompletableFuture
proposal.setAspect(GenericRecordUtils.serializeAspect(mapGlossaryNodeInfo(input))); proposal.setAspect(GenericRecordUtils.serializeAspect(mapGlossaryNodeInfo(input)));
proposal.setChangeType(ChangeType.UPSERT); proposal.setChangeType(ChangeType.UPSERT);
return _entityClient.ingestProposal(proposal, context.getAuthentication()); String glossaryNodeUrn = _entityClient.ingestProposal(proposal, context.getAuthentication());
OwnerUtils.addCreatorAsOwner(context, glossaryNodeUrn, OwnerEntityType.CORP_USER, OwnershipType.TECHNICAL_OWNER, _entityService);
return glossaryNodeUrn;
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to create GlossaryNode with id: {}, name: {}: {}", input.getId(), input.getName(), e.getMessage()); log.error("Failed to create GlossaryNode with id: {}, name: {}: {}", input.getId(), input.getName(), e.getMessage());
throw new RuntimeException(String.format("Failed to create GlossaryNode with id: %s, name: %s", input.getId(), input.getName()), e); throw new RuntimeException(String.format("Failed to create GlossaryNode with id: %s, name: %s", input.getId(), input.getName()), e);

View File

@ -6,9 +6,13 @@ import com.linkedin.datahub.graphql.QueryContext;
import com.linkedin.datahub.graphql.authorization.AuthorizationUtils; import com.linkedin.datahub.graphql.authorization.AuthorizationUtils;
import com.linkedin.datahub.graphql.exception.AuthorizationException; import com.linkedin.datahub.graphql.exception.AuthorizationException;
import com.linkedin.datahub.graphql.generated.CreateGlossaryEntityInput; import com.linkedin.datahub.graphql.generated.CreateGlossaryEntityInput;
import com.linkedin.datahub.graphql.generated.OwnerEntityType;
import com.linkedin.datahub.graphql.generated.OwnershipType;
import com.linkedin.datahub.graphql.resolvers.mutate.util.OwnerUtils;
import com.linkedin.entity.client.EntityClient; import com.linkedin.entity.client.EntityClient;
import com.linkedin.events.metadata.ChangeType; import com.linkedin.events.metadata.ChangeType;
import com.linkedin.glossary.GlossaryTermInfo; import com.linkedin.glossary.GlossaryTermInfo;
import com.linkedin.metadata.entity.EntityService;
import com.linkedin.metadata.Constants; import com.linkedin.metadata.Constants;
import com.linkedin.metadata.key.GlossaryTermKey; import com.linkedin.metadata.key.GlossaryTermKey;
import com.linkedin.metadata.utils.EntityKeyUtils; import com.linkedin.metadata.utils.EntityKeyUtils;
@ -30,6 +34,7 @@ import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.bindArgument;
public class CreateGlossaryTermResolver implements DataFetcher<CompletableFuture<String>> { public class CreateGlossaryTermResolver implements DataFetcher<CompletableFuture<String>> {
private final EntityClient _entityClient; private final EntityClient _entityClient;
private final EntityService _entityService;
@Override @Override
public CompletableFuture<String> get(DataFetchingEnvironment environment) throws Exception { public CompletableFuture<String> get(DataFetchingEnvironment environment) throws Exception {
@ -56,7 +61,9 @@ public class CreateGlossaryTermResolver implements DataFetcher<CompletableFuture
proposal.setAspect(GenericRecordUtils.serializeAspect(mapGlossaryTermInfo(input))); proposal.setAspect(GenericRecordUtils.serializeAspect(mapGlossaryTermInfo(input)));
proposal.setChangeType(ChangeType.UPSERT); proposal.setChangeType(ChangeType.UPSERT);
return _entityClient.ingestProposal(proposal, context.getAuthentication()); String glossaryTermUrn = _entityClient.ingestProposal(proposal, context.getAuthentication());
OwnerUtils.addCreatorAsOwner(context, glossaryTermUrn, OwnerEntityType.CORP_USER, OwnershipType.TECHNICAL_OWNER, _entityService);
return glossaryTermUrn;
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to create GlossaryTerm with id: {}, name: {}: {}", input.getId(), input.getName(), e.getMessage()); log.error("Failed to create GlossaryTerm with id: {}, name: {}: {}", input.getId(), input.getName(), e.getMessage());
throw new RuntimeException(String.format("Failed to create GlossaryTerm with id: %s, name: %s", input.getId(), input.getName()), e); throw new RuntimeException(String.format("Failed to create GlossaryTerm with id: %s, name: %s", input.getId(), input.getName()), e);

View File

@ -10,6 +10,7 @@ import com.linkedin.datahub.graphql.generated.UpdateNameInput;
import com.linkedin.domain.DomainProperties; import com.linkedin.domain.DomainProperties;
import com.linkedin.glossary.GlossaryTermInfo; import com.linkedin.glossary.GlossaryTermInfo;
import com.linkedin.glossary.GlossaryNodeInfo; import com.linkedin.glossary.GlossaryNodeInfo;
import com.linkedin.identity.CorpGroupInfo;
import com.linkedin.metadata.Constants; import com.linkedin.metadata.Constants;
import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.entity.EntityService;
import graphql.schema.DataFetcher; import graphql.schema.DataFetcher;
@ -47,6 +48,8 @@ public class UpdateNameResolver implements DataFetcher<CompletableFuture<Boolean
return updateGlossaryNodeName(targetUrn, input, environment.getContext()); return updateGlossaryNodeName(targetUrn, input, environment.getContext());
case Constants.DOMAIN_ENTITY_NAME: case Constants.DOMAIN_ENTITY_NAME:
return updateDomainName(targetUrn, input, environment.getContext()); return updateDomainName(targetUrn, input, environment.getContext());
case Constants.CORP_GROUP_ENTITY_NAME:
return updateGroupName(targetUrn, input, environment.getContext());
default: default:
throw new RuntimeException( throw new RuntimeException(
String.format("Failed to update name. Unsupported resource type %s provided.", targetUrn)); String.format("Failed to update name. Unsupported resource type %s provided.", targetUrn));
@ -125,4 +128,28 @@ public class UpdateNameResolver implements DataFetcher<CompletableFuture<Boolean
} }
throw new AuthorizationException("Unauthorized to perform this action. Please contact your DataHub administrator."); throw new AuthorizationException("Unauthorized to perform this action. Please contact your DataHub administrator.");
} }
private Boolean updateGroupName(
Urn targetUrn,
UpdateNameInput input,
QueryContext context
) {
if (AuthorizationUtils.canManageUsersAndGroups(context)) {
try {
CorpGroupInfo corpGroupInfo = (CorpGroupInfo) getAspectFromEntity(
targetUrn.toString(), Constants.CORP_GROUP_INFO_ASPECT_NAME, _entityService, null);
if (corpGroupInfo == null) {
throw new IllegalArgumentException("Group does not exist");
}
corpGroupInfo.setDisplayName(input.getName());
Urn actor = CorpuserUrn.createFromString(context.getActorUrn());
persistAspect(targetUrn, Constants.CORP_GROUP_INFO_ASPECT_NAME, corpGroupInfo, actor, _entityService);
return true;
} catch (Exception e) {
throw new RuntimeException(String.format("Failed to perform update against input %s", input), e);
}
}
throw new AuthorizationException("Unauthorized to perform this action. Please contact your DataHub administrator.");
}
} }

View File

@ -1,6 +1,7 @@
package com.linkedin.datahub.graphql.resolvers.mutate.util; package com.linkedin.datahub.graphql.resolvers.mutate.util;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import com.linkedin.common.urn.CorpuserUrn;
import com.linkedin.common.Owner; import com.linkedin.common.Owner;
import com.linkedin.common.OwnerArray; import com.linkedin.common.OwnerArray;
@ -34,6 +35,7 @@ import static com.linkedin.datahub.graphql.resolvers.mutate.MutationUtils.*;
// TODO: Move to consuming from OwnerService // TODO: Move to consuming from OwnerService
@Slf4j @Slf4j
public class OwnerUtils { public class OwnerUtils {
private static final ConjunctivePrivilegeGroup ALL_PRIVILEGES_GROUP = new ConjunctivePrivilegeGroup(ImmutableList.of( private static final ConjunctivePrivilegeGroup ALL_PRIVILEGES_GROUP = new ConjunctivePrivilegeGroup(ImmutableList.of(
PoliciesConfig.EDIT_ENTITY_PRIVILEGE.getType() PoliciesConfig.EDIT_ENTITY_PRIVILEGE.getType()
)); ));
@ -218,4 +220,23 @@ public class OwnerUtils {
entityService.ingestProposal(change, getAuditStamp(actor), false); entityService.ingestProposal(change, getAuditStamp(actor), false);
} }
} }
public static void addCreatorAsOwner(
QueryContext context,
String urn,
OwnerEntityType ownerEntityType,
com.linkedin.datahub.graphql.generated.OwnershipType ownershipType,
EntityService entityService) {
try {
Urn actorUrn = CorpuserUrn.createFromString(context.getActorUrn());
addOwnersToResources(
ImmutableList.of(new OwnerInput(actorUrn.toString(), ownerEntityType, ownershipType)),
ImmutableList.of(new ResourceRefInput(urn, null, null)),
actorUrn,
entityService
);
} catch (Exception e) {
log.error(String.format("Failed to add creator as owner of tag %s", urn), e);
}
}
} }

View File

@ -1,17 +1,12 @@
package com.linkedin.datahub.graphql.resolvers.tag; package com.linkedin.datahub.graphql.resolvers.tag;
import com.google.common.collect.ImmutableList;
import com.linkedin.common.urn.CorpuserUrn;
import com.linkedin.common.urn.Urn;
import com.linkedin.data.template.SetMode; import com.linkedin.data.template.SetMode;
import com.linkedin.datahub.graphql.QueryContext; import com.linkedin.datahub.graphql.QueryContext;
import com.linkedin.datahub.graphql.authorization.AuthorizationUtils; import com.linkedin.datahub.graphql.authorization.AuthorizationUtils;
import com.linkedin.datahub.graphql.exception.AuthorizationException; import com.linkedin.datahub.graphql.exception.AuthorizationException;
import com.linkedin.datahub.graphql.generated.CreateTagInput; import com.linkedin.datahub.graphql.generated.CreateTagInput;
import com.linkedin.datahub.graphql.generated.OwnerEntityType; import com.linkedin.datahub.graphql.generated.OwnerEntityType;
import com.linkedin.datahub.graphql.generated.OwnerInput;
import com.linkedin.datahub.graphql.generated.OwnershipType; import com.linkedin.datahub.graphql.generated.OwnershipType;
import com.linkedin.datahub.graphql.generated.ResourceRefInput;
import com.linkedin.datahub.graphql.resolvers.mutate.util.OwnerUtils; import com.linkedin.datahub.graphql.resolvers.mutate.util.OwnerUtils;
import com.linkedin.entity.client.EntityClient; import com.linkedin.entity.client.EntityClient;
import com.linkedin.events.metadata.ChangeType; import com.linkedin.events.metadata.ChangeType;
@ -74,7 +69,7 @@ public class CreateTagResolver implements DataFetcher<CompletableFuture<String>>
proposal.setChangeType(ChangeType.UPSERT); proposal.setChangeType(ChangeType.UPSERT);
String tagUrn = _entityClient.ingestProposal(proposal, context.getAuthentication()); String tagUrn = _entityClient.ingestProposal(proposal, context.getAuthentication());
addCreatorAsOwner(context, tagUrn); OwnerUtils.addCreatorAsOwner(context, tagUrn, OwnerEntityType.CORP_USER, OwnershipType.TECHNICAL_OWNER, _entityService);
return tagUrn; return tagUrn;
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to create Tag with id: {}, name: {}: {}", input.getId(), input.getName(), e.getMessage()); log.error("Failed to create Tag with id: {}, name: {}: {}", input.getId(), input.getName(), e.getMessage());
@ -89,18 +84,4 @@ public class CreateTagResolver implements DataFetcher<CompletableFuture<String>>
result.setDescription(input.getDescription(), SetMode.IGNORE_NULL); result.setDescription(input.getDescription(), SetMode.IGNORE_NULL);
return result; return result;
} }
private void addCreatorAsOwner(QueryContext context, String tagUrn) {
try {
Urn actorUrn = CorpuserUrn.createFromString(context.getActorUrn());
OwnerUtils.addOwnersToResources(
ImmutableList.of(new OwnerInput(actorUrn.toString(), OwnerEntityType.CORP_USER, OwnershipType.TECHNICAL_OWNER)),
ImmutableList.of(new ResourceRefInput(tagUrn, null, null)),
actorUrn,
_entityService
);
} catch (Exception e) {
log.error(String.format("Failed to add creator as owner of tag %s", tagUrn), e);
}
}
} }

View File

@ -9,6 +9,7 @@ import com.linkedin.events.metadata.ChangeType;
import com.linkedin.metadata.Constants; import com.linkedin.metadata.Constants;
import com.linkedin.metadata.key.DomainKey; import com.linkedin.metadata.key.DomainKey;
import com.linkedin.metadata.utils.GenericRecordUtils; import com.linkedin.metadata.utils.GenericRecordUtils;
import com.linkedin.metadata.entity.EntityService;
import com.linkedin.mxe.MetadataChangeProposal; import com.linkedin.mxe.MetadataChangeProposal;
import com.linkedin.r2.RemoteInvocationException; import com.linkedin.r2.RemoteInvocationException;
import graphql.schema.DataFetchingEnvironment; import graphql.schema.DataFetchingEnvironment;
@ -27,12 +28,16 @@ public class CreateDomainResolverTest {
"test-name", "test-name",
"test-description" "test-description"
); );
private static final String TEST_ENTITY_URN = "urn:li:dataset:(urn:li:dataPlatform:mysql,my-test,PROD)";
private static final String TEST_TAG_1_URN = "urn:li:tag:test-id-1";
private static final String TEST_TAG_2_URN = "urn:li:tag:test-id-2";
@Test @Test
public void testGetSuccess() throws Exception { public void testGetSuccess() throws Exception {
// Create resolver // Create resolver
EntityClient mockClient = Mockito.mock(EntityClient.class); EntityClient mockClient = Mockito.mock(EntityClient.class);
CreateDomainResolver resolver = new CreateDomainResolver(mockClient); EntityService mockService = Mockito.mock(EntityService.class);
CreateDomainResolver resolver = new CreateDomainResolver(mockClient, mockService);
// Execute resolver // Execute resolver
QueryContext mockContext = getMockAllowContext(); QueryContext mockContext = getMockAllowContext();
@ -65,7 +70,8 @@ public class CreateDomainResolverTest {
public void testGetUnauthorized() throws Exception { public void testGetUnauthorized() throws Exception {
// Create resolver // Create resolver
EntityClient mockClient = Mockito.mock(EntityClient.class); EntityClient mockClient = Mockito.mock(EntityClient.class);
CreateDomainResolver resolver = new CreateDomainResolver(mockClient); EntityService mockService = Mockito.mock(EntityService.class);
CreateDomainResolver resolver = new CreateDomainResolver(mockClient, mockService);
// Execute resolver // Execute resolver
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
@ -83,10 +89,11 @@ public class CreateDomainResolverTest {
public void testGetEntityClientException() throws Exception { public void testGetEntityClientException() throws Exception {
// Create resolver // Create resolver
EntityClient mockClient = Mockito.mock(EntityClient.class); EntityClient mockClient = Mockito.mock(EntityClient.class);
EntityService mockService = Mockito.mock(EntityService.class);
Mockito.doThrow(RemoteInvocationException.class).when(mockClient).ingestProposal( Mockito.doThrow(RemoteInvocationException.class).when(mockClient).ingestProposal(
Mockito.any(), Mockito.any(),
Mockito.any(Authentication.class)); Mockito.any(Authentication.class));
CreateDomainResolver resolver = new CreateDomainResolver(mockClient); CreateDomainResolver resolver = new CreateDomainResolver(mockClient, mockService);
// Execute resolver // Execute resolver
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);

View File

@ -10,6 +10,7 @@ import com.linkedin.glossary.GlossaryNodeInfo;
import com.linkedin.metadata.Constants; import com.linkedin.metadata.Constants;
import com.linkedin.metadata.key.GlossaryNodeKey; import com.linkedin.metadata.key.GlossaryNodeKey;
import com.linkedin.metadata.utils.GenericRecordUtils; import com.linkedin.metadata.utils.GenericRecordUtils;
import com.linkedin.metadata.entity.EntityService;
import com.linkedin.mxe.MetadataChangeProposal; import com.linkedin.mxe.MetadataChangeProposal;
import graphql.schema.DataFetchingEnvironment; import graphql.schema.DataFetchingEnvironment;
import org.mockito.Mockito; import org.mockito.Mockito;
@ -74,10 +75,11 @@ public class CreateGlossaryNodeResolverTest {
@Test @Test
public void testGetSuccess() throws Exception { public void testGetSuccess() throws Exception {
EntityClient mockClient = Mockito.mock(EntityClient.class); EntityClient mockClient = Mockito.mock(EntityClient.class);
EntityService mockService = Mockito.mock(EntityService.class);
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
final MetadataChangeProposal proposal = setupTest(mockEnv, TEST_INPUT, "test-description", parentNodeUrn); final MetadataChangeProposal proposal = setupTest(mockEnv, TEST_INPUT, "test-description", parentNodeUrn);
CreateGlossaryNodeResolver resolver = new CreateGlossaryNodeResolver(mockClient); CreateGlossaryNodeResolver resolver = new CreateGlossaryNodeResolver(mockClient, mockService);
resolver.get(mockEnv).get(); resolver.get(mockEnv).get();
Mockito.verify(mockClient, Mockito.times(1)).ingestProposal( Mockito.verify(mockClient, Mockito.times(1)).ingestProposal(
@ -89,10 +91,11 @@ public class CreateGlossaryNodeResolverTest {
@Test @Test
public void testGetSuccessNoDescription() throws Exception { public void testGetSuccessNoDescription() throws Exception {
EntityClient mockClient = Mockito.mock(EntityClient.class); EntityClient mockClient = Mockito.mock(EntityClient.class);
EntityService mockService = Mockito.mock(EntityService.class);
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
final MetadataChangeProposal proposal = setupTest(mockEnv, TEST_INPUT_NO_DESCRIPTION, "", parentNodeUrn); final MetadataChangeProposal proposal = setupTest(mockEnv, TEST_INPUT_NO_DESCRIPTION, "", parentNodeUrn);
CreateGlossaryNodeResolver resolver = new CreateGlossaryNodeResolver(mockClient); CreateGlossaryNodeResolver resolver = new CreateGlossaryNodeResolver(mockClient, mockService);
resolver.get(mockEnv).get(); resolver.get(mockEnv).get();
Mockito.verify(mockClient, Mockito.times(1)).ingestProposal( Mockito.verify(mockClient, Mockito.times(1)).ingestProposal(
@ -104,10 +107,11 @@ public class CreateGlossaryNodeResolverTest {
@Test @Test
public void testGetSuccessNoParentNode() throws Exception { public void testGetSuccessNoParentNode() throws Exception {
EntityClient mockClient = Mockito.mock(EntityClient.class); EntityClient mockClient = Mockito.mock(EntityClient.class);
EntityService mockService = Mockito.mock(EntityService.class);
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
final MetadataChangeProposal proposal = setupTest(mockEnv, TEST_INPUT_NO_PARENT_NODE, "test-description", null); final MetadataChangeProposal proposal = setupTest(mockEnv, TEST_INPUT_NO_PARENT_NODE, "test-description", null);
CreateGlossaryNodeResolver resolver = new CreateGlossaryNodeResolver(mockClient); CreateGlossaryNodeResolver resolver = new CreateGlossaryNodeResolver(mockClient, mockService);
resolver.get(mockEnv).get(); resolver.get(mockEnv).get();
Mockito.verify(mockClient, Mockito.times(1)).ingestProposal( Mockito.verify(mockClient, Mockito.times(1)).ingestProposal(

View File

@ -10,6 +10,7 @@ import com.linkedin.glossary.GlossaryTermInfo;
import com.linkedin.metadata.Constants; import com.linkedin.metadata.Constants;
import com.linkedin.metadata.key.GlossaryTermKey; import com.linkedin.metadata.key.GlossaryTermKey;
import com.linkedin.metadata.utils.GenericRecordUtils; import com.linkedin.metadata.utils.GenericRecordUtils;
import com.linkedin.metadata.entity.EntityService;
import com.linkedin.mxe.MetadataChangeProposal; import com.linkedin.mxe.MetadataChangeProposal;
import graphql.schema.DataFetchingEnvironment; import graphql.schema.DataFetchingEnvironment;
import org.mockito.Mockito; import org.mockito.Mockito;
@ -75,10 +76,11 @@ public class CreateGlossaryTermResolverTest {
@Test @Test
public void testGetSuccess() throws Exception { public void testGetSuccess() throws Exception {
EntityClient mockClient = Mockito.mock(EntityClient.class); EntityClient mockClient = Mockito.mock(EntityClient.class);
EntityService mockService = Mockito.mock(EntityService.class);
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
final MetadataChangeProposal proposal = setupTest(mockEnv, TEST_INPUT, "test-description", parentNodeUrn); final MetadataChangeProposal proposal = setupTest(mockEnv, TEST_INPUT, "test-description", parentNodeUrn);
CreateGlossaryTermResolver resolver = new CreateGlossaryTermResolver(mockClient); CreateGlossaryTermResolver resolver = new CreateGlossaryTermResolver(mockClient, mockService);
resolver.get(mockEnv).get(); resolver.get(mockEnv).get();
Mockito.verify(mockClient, Mockito.times(1)).ingestProposal( Mockito.verify(mockClient, Mockito.times(1)).ingestProposal(
@ -90,10 +92,11 @@ public class CreateGlossaryTermResolverTest {
@Test @Test
public void testGetSuccessNoDescription() throws Exception { public void testGetSuccessNoDescription() throws Exception {
EntityClient mockClient = Mockito.mock(EntityClient.class); EntityClient mockClient = Mockito.mock(EntityClient.class);
EntityService mockService = Mockito.mock(EntityService.class);
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
final MetadataChangeProposal proposal = setupTest(mockEnv, TEST_INPUT_NO_DESCRIPTION, "", parentNodeUrn); final MetadataChangeProposal proposal = setupTest(mockEnv, TEST_INPUT_NO_DESCRIPTION, "", parentNodeUrn);
CreateGlossaryTermResolver resolver = new CreateGlossaryTermResolver(mockClient); CreateGlossaryTermResolver resolver = new CreateGlossaryTermResolver(mockClient, mockService);
resolver.get(mockEnv).get(); resolver.get(mockEnv).get();
Mockito.verify(mockClient, Mockito.times(1)).ingestProposal( Mockito.verify(mockClient, Mockito.times(1)).ingestProposal(
@ -105,10 +108,11 @@ public class CreateGlossaryTermResolverTest {
@Test @Test
public void testGetSuccessNoParentNode() throws Exception { public void testGetSuccessNoParentNode() throws Exception {
EntityClient mockClient = Mockito.mock(EntityClient.class); EntityClient mockClient = Mockito.mock(EntityClient.class);
EntityService mockService = Mockito.mock(EntityService.class);
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
final MetadataChangeProposal proposal = setupTest(mockEnv, TEST_INPUT_NO_PARENT_NODE, "test-description", null); final MetadataChangeProposal proposal = setupTest(mockEnv, TEST_INPUT_NO_PARENT_NODE, "test-description", null);
CreateGlossaryTermResolver resolver = new CreateGlossaryTermResolver(mockClient); CreateGlossaryTermResolver resolver = new CreateGlossaryTermResolver(mockClient, mockService);
resolver.get(mockEnv).get(); resolver.get(mockEnv).get();
Mockito.verify(mockClient, Mockito.times(1)).ingestProposal( Mockito.verify(mockClient, Mockito.times(1)).ingestProposal(

View File

@ -1,10 +1,11 @@
import { Divider, message, Space, Button, Typography, Row, Col, Tooltip } from 'antd'; import { Divider, message, Space, Button, Typography, Row, Col, Tooltip } from 'antd';
import React, { useState } from 'react'; import React, { useEffect, useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { EditOutlined, LockOutlined, MailOutlined, SlackOutlined } from '@ant-design/icons'; import { EditOutlined, LockOutlined, MailOutlined, SlackOutlined } from '@ant-design/icons';
import { useHistory, useRouteMatch } from 'react-router-dom'; import { useHistory, useRouteMatch } from 'react-router-dom';
import { useUpdateCorpGroupPropertiesMutation } from '../../../graphql/group.generated'; import { useUpdateCorpGroupPropertiesMutation } from '../../../graphql/group.generated';
import { EntityRelationshipsResult, Ownership } from '../../../types.generated'; import { EntityRelationshipsResult, Ownership } from '../../../types.generated';
import { useUpdateNameMutation } from '../../../graphql/mutations.generated';
import GroupEditModal from './GroupEditModal'; import GroupEditModal from './GroupEditModal';
import CustomAvatar from '../../shared/avatar/CustomAvatar'; import CustomAvatar from '../../shared/avatar/CustomAvatar';
@ -20,6 +21,7 @@ import {
GroupsSection, GroupsSection,
} from '../shared/SidebarStyledComponents'; } from '../shared/SidebarStyledComponents';
import GroupMembersSideBarSection from './GroupMembersSideBarSection'; import GroupMembersSideBarSection from './GroupMembersSideBarSection';
import { useGetAuthenticatedUser } from '../../useGetAuthenticatedUser';
const { Paragraph } = Typography; const { Paragraph } = Typography;
@ -34,7 +36,7 @@ type SideBarData = {
groupOwnerShip: Ownership; groupOwnerShip: Ownership;
isExternalGroup: boolean; isExternalGroup: boolean;
externalGroupType: string | undefined; externalGroupType: string | undefined;
urn: string | undefined; urn: string;
}; };
type Props = { type Props = {
@ -61,10 +63,21 @@ const GroupNameHeader = styled(Row)`
min-height: 100px; min-height: 100px;
`; `;
const GroupName = styled.div` const GroupTitle = styled(Typography.Title)`
max-width: 260px; max-width: 260px;
word-wrap: break-word; word-wrap: break-word;
width: 140px; width: 140px;
&&& {
margin-bottom: 0;
word-break: break-all;
margin-left: 10px;
}
.ant-typography-edit {
font-size: 16px;
margin-left: 10px;
}
`; `;
/** /**
@ -90,7 +103,31 @@ export default function GroupInfoSidebar({ sideBarData, refetch }: Props) {
/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */
const [editGroupModal, showEditGroupModal] = useState(false); const [editGroupModal, showEditGroupModal] = useState(false);
const canEditGroup = true; // TODO; Replace this will fine-grained understanding of user permissions. const me = useGetAuthenticatedUser();
const canEditGroup = me?.platformPrivileges.manageIdentities;
const [groupTitle, setGroupTitle] = useState(name);
const [updateName] = useUpdateNameMutation();
useEffect(() => {
setGroupTitle(groupTitle);
}, [groupTitle]);
// Update Group Title
// eslint-disable-next-line @typescript-eslint/no-shadow
const handleTitleUpdate = async (name: string) => {
setGroupTitle(name);
await updateName({ variables: { input: { name, urn } } })
.then(() => {
message.success({ content: 'Name Updated', duration: 2 });
refetch();
})
.catch((e: unknown) => {
message.destroy();
if (e instanceof Error) {
message.error({ content: `Failed to update name: \n ${e.message || ''}`, duration: 3 });
}
});
};
const getEditModalData = { const getEditModalData = {
urn, urn,
@ -135,7 +172,9 @@ export default function GroupInfoSidebar({ sideBarData, refetch }: Props) {
/> />
</Col> </Col>
<Col> <Col>
<GroupName>{name}</GroupName> <GroupTitle level={3} editable={canEditGroup ? { onChange: handleTitleUpdate } : false}>
{groupTitle}
</GroupTitle>
</Col> </Col>
<Col> <Col>
{isExternalGroup && ( {isExternalGroup && (

View File

@ -1,11 +1,13 @@
import { InfoCircleOutlined } from '@ant-design/icons';
import { Divider, Popover, Tooltip, Typography } from 'antd';
import React from 'react'; import React from 'react';
import { InfoCircleOutlined } from '@ant-design/icons';
import { Divider, message, Modal, Popover, Tooltip, Typography } from 'antd';
import { blue } from '@ant-design/colors';
import styled from 'styled-components'; import styled from 'styled-components';
import moment from 'moment'; import moment from 'moment';
import { Deprecation } from '../../../../../types.generated'; import { Deprecation } from '../../../../../types.generated';
import { getLocaleTimezone } from '../../../../shared/time/timeUtils'; import { getLocaleTimezone } from '../../../../shared/time/timeUtils';
import { ANTD_GRAY } from '../../constants'; import { ANTD_GRAY } from '../../constants';
import { useBatchUpdateDeprecationMutation } from '../../../../../graphql/mutations.generated';
const DeprecatedContainer = styled.div` const DeprecatedContainer = styled.div`
width: 104px; width: 104px;
@ -55,12 +57,30 @@ const StyledInfoCircleOutlined = styled(InfoCircleOutlined)`
color: #ef5b5b; color: #ef5b5b;
`; `;
const UndeprecatedIcon = styled(InfoCircleOutlined)`
font-size: 14px;
padding-right: 6px;
`;
const IconGroup = styled.div`
font-size: 12px;
color: 'black';
&:hover {
color: ${blue[4]};
cursor: pointer;
}
`;
type Props = { type Props = {
urn: string;
deprecation: Deprecation; deprecation: Deprecation;
preview?: boolean | null; preview?: boolean | null;
refetch?: () => void;
showUndeprecate: boolean | null;
}; };
export const DeprecationPill = ({ deprecation, preview }: Props) => { export const DeprecationPill = ({ deprecation, preview, urn, refetch, showUndeprecate }: Props) => {
const [batchUpdateDeprecationMutation] = useBatchUpdateDeprecationMutation();
/** /**
* Deprecation Decommission Timestamp * Deprecation Decommission Timestamp
*/ */
@ -78,6 +98,30 @@ export const DeprecationPill = ({ deprecation, preview }: Props) => {
const hasDetails = deprecation.note !== '' || deprecation.decommissionTime !== null; const hasDetails = deprecation.note !== '' || deprecation.decommissionTime !== null;
const isDividerNeeded = deprecation.note !== '' && deprecation.decommissionTime !== null; const isDividerNeeded = deprecation.note !== '' && deprecation.decommissionTime !== null;
const batchUndeprecate = () => {
batchUpdateDeprecationMutation({
variables: {
input: {
resources: [{ resourceUrn: urn }],
deprecated: false,
},
},
})
.then(({ errors }) => {
if (!errors) {
message.success({ content: 'Marked assets as un-deprecated!', duration: 2 });
refetch?.();
}
})
.catch((e) => {
message.destroy();
message.error({
content: `Failed to mark assets as un-deprecated: \n ${e.message || ''}`,
duration: 3,
});
});
};
return ( return (
<Popover <Popover
overlayStyle={{ maxWidth: 240 }} overlayStyle={{ maxWidth: 240 }}
@ -95,6 +139,27 @@ export const DeprecationPill = ({ deprecation, preview }: Props) => {
</Tooltip> </Tooltip>
</Typography.Text> </Typography.Text>
)} )}
{isDividerNeeded && <ThinDivider />}
{showUndeprecate && (
<IconGroup
onClick={() =>
Modal.confirm({
title: `Confirm Mark as un-deprecated`,
content: `Are you sure you want to mark this asset as un-deprecated?`,
onOk() {
batchUndeprecate();
},
onCancel() {},
okText: 'Yes',
maskClosable: true,
closable: true,
})
}
>
<UndeprecatedIcon />
Mark as un-deprecated
</IconGroup>
)}
</> </>
) : ( ) : (
'No additional details' 'No additional details'

View File

@ -26,13 +26,16 @@ export default function DeprecationDropdown({ urns, disabled = false, refetch }:
}) })
.then(({ errors }) => { .then(({ errors }) => {
if (!errors) { if (!errors) {
message.success({ content: 'Marked assets as undeprecated!', duration: 2 }); message.success({ content: 'Marked assets as un-deprecated!', duration: 2 });
refetch?.(); refetch?.();
} }
}) })
.catch((e) => { .catch((e) => {
message.destroy(); message.destroy();
message.error({ content: `Failed to mark assets as undeprecated: \n ${e.message || ''}`, duration: 3 }); message.error({
content: `Failed to mark assets as un-deprecated: \n ${e.message || ''}`,
duration: 3,
});
}); });
}; };
@ -48,11 +51,11 @@ export default function DeprecationDropdown({ urns, disabled = false, refetch }:
}, },
}, },
{ {
title: 'Mark as undeprecated', title: 'Mark as un-deprecated',
onClick: () => { onClick: () => {
Modal.confirm({ Modal.confirm({
title: `Confirm Mark as undeprecated`, title: `Confirm Mark as un-deprecated`,
content: `Are you sure you want to mark these assets as undeprecated?`, content: `Are you sure you want to mark these assets as un-deprecated?`,
onOk() { onOk() {
batchUndeprecate(); batchUndeprecate();
}, },

View File

@ -125,7 +125,13 @@ export const EntityHeader = ({
<TitleWrapper> <TitleWrapper>
<EntityName isNameEditable={canEditName} /> <EntityName isNameEditable={canEditName} />
{entityData?.deprecation?.deprecated && ( {entityData?.deprecation?.deprecated && (
<DeprecationPill deprecation={entityData?.deprecation} preview={isCompact} /> <DeprecationPill
urn={urn}
deprecation={entityData?.deprecation}
showUndeprecate
preview={isCompact}
refetch={refetch}
/>
)} )}
{entityData?.health?.map((health) => ( {entityData?.health?.map((health) => (
<EntityHealthStatus <EntityHealthStatus

View File

@ -288,7 +288,9 @@ export default function DefaultPreviewCard({
</EntityTitle> </EntityTitle>
)} )}
</Link> </Link>
{deprecation?.deprecated && <DeprecationPill deprecation={deprecation} preview />} {deprecation?.deprecated && (
<DeprecationPill deprecation={deprecation} urn="" showUndeprecate={false} preview />
)}
{externalUrl && ( {externalUrl && (
<ExternalUrlContainer> <ExternalUrlContainer>
<ExternalUrlButton type="link" href={externalUrl} target="_blank"> <ExternalUrlButton type="link" href={externalUrl} target="_blank">