feat(usergroup): implement corpgroup in graphql, refactor avatars and ownership in react (#2519)

This commit is contained in:
Brian 2021-05-11 17:55:45 -07:00 committed by GitHub
parent 2811d23e45
commit d7d8870008
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 1002 additions and 270 deletions

2
.gitignore vendored
View File

@ -34,3 +34,5 @@ MANIFEST
# Mac OS
**/.DS_Store
.vscode

View File

@ -6,6 +6,7 @@ import com.linkedin.dataplatform.client.DataPlatforms;
import com.linkedin.dataset.client.Datasets;
import com.linkedin.identity.client.CorpUsers;
import com.linkedin.lineage.client.Lineages;
import com.linkedin.identity.client.CorpGroups;
import com.linkedin.metadata.restli.DefaultRestliClientFactory;
import com.linkedin.ml.client.MLModels;
import com.linkedin.restli.client.Client;
@ -38,6 +39,7 @@ public class GmsClientFactory {
Configuration.getEnvironmentVariable(GMS_SSL_PROTOCOL_VAR));
private static CorpUsers _corpUsers;
private static CorpGroups _corpGroups;
private static Datasets _datasets;
private static Dashboards _dashboards;
private static Charts _charts;
@ -63,6 +65,17 @@ public class GmsClientFactory {
return _corpUsers;
}
public static CorpGroups getCorpGroupsClient() {
if (_corpGroups == null) {
synchronized (GmsClientFactory.class) {
if (_corpGroups == null) {
_corpGroups = new CorpGroups(REST_CLIENT);
}
}
}
return _corpGroups;
}
public static Datasets getDatasetsClient() {
if (_datasets == null) {
synchronized (GmsClientFactory.class) {

View File

@ -21,13 +21,17 @@ import com.linkedin.datahub.graphql.types.LoadableType;
import com.linkedin.datahub.graphql.types.SearchableEntityType;
import com.linkedin.datahub.graphql.types.chart.ChartType;
import com.linkedin.datahub.graphql.types.corpuser.CorpUserType;
import com.linkedin.datahub.graphql.types.corpgroup.CorpGroupType;
import com.linkedin.datahub.graphql.types.dashboard.DashboardType;
import com.linkedin.datahub.graphql.types.dataplatform.DataPlatformType;
import com.linkedin.datahub.graphql.types.dataset.DatasetType;
import com.linkedin.datahub.graphql.generated.CorpUser;
import com.linkedin.datahub.graphql.generated.CorpUserInfo;
import com.linkedin.datahub.graphql.generated.CorpGroupInfo;
import com.linkedin.datahub.graphql.generated.Owner;
import com.linkedin.datahub.graphql.resolvers.AuthenticatedResolver;
import com.linkedin.datahub.graphql.resolvers.load.LoadableTypeResolver;
import com.linkedin.datahub.graphql.resolvers.load.OwnerTypeResolver;
import com.linkedin.datahub.graphql.resolvers.browse.BrowsePathsResolver;
import com.linkedin.datahub.graphql.resolvers.browse.BrowseResolver;
import com.linkedin.datahub.graphql.resolvers.search.AutoCompleteResolver;
@ -71,6 +75,7 @@ public class GmsGraphQLEngine {
public static final DatasetType DATASET_TYPE = new DatasetType(GmsClientFactory.getDatasetsClient());
public static final CorpUserType CORP_USER_TYPE = new CorpUserType(GmsClientFactory.getCorpUsersClient());
public static final CorpGroupType CORP_GROUP_TYPE = new CorpGroupType(GmsClientFactory.getCorpGroupsClient());
public static final ChartType CHART_TYPE = new ChartType(GmsClientFactory.getChartsClient());
public static final DashboardType DASHBOARD_TYPE = new DashboardType(GmsClientFactory.getDashboardsClient());
public static final DataPlatformType DATA_PLATFORM_TYPE = new DataPlatformType(GmsClientFactory.getDataPlatformsClient());
@ -92,6 +97,7 @@ public class GmsGraphQLEngine {
public static final List<EntityType<?>> ENTITY_TYPES = ImmutableList.of(
DATASET_TYPE,
CORP_USER_TYPE,
CORP_GROUP_TYPE,
DATA_PLATFORM_TYPE,
CHART_TYPE,
DASHBOARD_TYPE,
@ -115,7 +121,13 @@ public class GmsGraphQLEngine {
*/
public static final List<LoadableType<?>> LOADABLE_TYPES = Stream.concat(ENTITY_TYPES.stream(), RELATIONSHIP_TYPES.stream()).collect(Collectors.toList());
/**
* Configures the graph objects for owner
*/
public static final List<LoadableType<?>> OWNER_TYPES = ImmutableList.of(
CORP_USER_TYPE,
CORP_GROUP_TYPE
);
/**
* Configures the graph objects that can be searched.
@ -163,6 +175,7 @@ public class GmsGraphQLEngine {
configureMutationResolvers(builder);
configureDatasetResolvers(builder);
configureCorpUserResolvers(builder);
configureCorpGroupResolvers(builder);
configureDashboardResolvers(builder);
configureChartResolvers(builder);
configureTypeResolvers(builder);
@ -207,6 +220,10 @@ public class GmsGraphQLEngine {
new LoadableTypeResolver<>(
CORP_USER_TYPE,
(env) -> env.getArgument(URN_FIELD_NAME))))
.dataFetcher("corpGroup", new AuthenticatedResolver<>(
new LoadableTypeResolver<>(
CORP_GROUP_TYPE,
(env) -> env.getArgument(URN_FIELD_NAME))))
.dataFetcher("dashboard", new AuthenticatedResolver<>(
new LoadableTypeResolver<>(
DASHBOARD_TYPE,
@ -269,9 +286,9 @@ public class GmsGraphQLEngine {
)
.type("Owner", typeWiring -> typeWiring
.dataFetcher("owner", new AuthenticatedResolver<>(
new LoadableTypeResolver<>(
CORP_USER_TYPE,
(env) -> ((Owner) env.getSource()).getOwner().getUrn()))
new OwnerTypeResolver<>(
OWNER_TYPES,
(env) -> ((Owner) env.getSource()).getOwner()))
)
)
.type("RelatedDataset", typeWiring -> typeWiring
@ -285,8 +302,7 @@ public class GmsGraphQLEngine {
.dataFetcher("entity", new AuthenticatedResolver<>(
new EntityTypeResolver(
ENTITY_TYPES.stream().collect(Collectors.toList()),
(env) -> ((EntityRelationship) env.getSource()).getEntity())
)
(env) -> ((EntityRelationship) env.getSource()).getEntity()))
)
);
}
@ -304,6 +320,28 @@ public class GmsGraphQLEngine {
);
}
/**
* Configures resolvers responsible for resolving the {@link com.linkedin.datahub.graphql.generated.CorpGroup} type.
*/
private static void configureCorpGroupResolvers(final RuntimeWiring.Builder builder) {
builder.type("CorpGroupInfo", typeWiring -> typeWiring
.dataFetcher("admins", new AuthenticatedResolver<>(
new LoadableTypeBatchResolver<>(
CORP_USER_TYPE,
(env) -> ((CorpGroupInfo) env.getSource()).getAdmins().stream()
.map(CorpUser::getUrn)
.collect(Collectors.toList())))
)
.dataFetcher("members", new AuthenticatedResolver<>(
new LoadableTypeBatchResolver<>(
CORP_USER_TYPE,
(env) -> ((CorpGroupInfo) env.getSource()).getMembers().stream()
.map(CorpUser::getUrn)
.collect(Collectors.toList())))
)
);
}
private static void configureTagAssociationResolver(final RuntimeWiring.Builder builder) {
builder.type("TagAssociation", typeWiring -> typeWiring
.dataFetcher("tag", new AuthenticatedResolver<>(
@ -379,12 +417,18 @@ public class GmsGraphQLEngine {
.map(graphType -> (EntityType<?>) graphType)
.collect(Collectors.toList())
)))
.type("EntityWithRelationships", typeWiring -> typeWiring
.typeResolver(new EntityInterfaceTypeResolver(LOADABLE_TYPES.stream()
.filter(graphType -> graphType instanceof EntityType)
.map(graphType -> (EntityType<?>) graphType)
.collect(Collectors.toList())
)))
.type("EntityWithRelationships", typeWiring -> typeWiring
.typeResolver(new EntityInterfaceTypeResolver(LOADABLE_TYPES.stream()
.filter(graphType -> graphType instanceof EntityType)
.map(graphType -> (EntityType<?>) graphType)
.collect(Collectors.toList())
)))
.type("OwnerType", typeWiring -> typeWiring
.typeResolver(new EntityInterfaceTypeResolver(OWNER_TYPES.stream()
.filter(graphType -> graphType instanceof EntityType)
.map(graphType -> (EntityType<?>) graphType)
.collect(Collectors.toList())
)))
.type("PlatformSchema", typeWiring -> typeWiring
.typeResolver(new PlatformSchemaUnionTypeResolver())
)

View File

@ -0,0 +1,45 @@
package com.linkedin.datahub.graphql.resolvers.load;
import com.linkedin.datahub.graphql.generated.Entity;
import com.linkedin.datahub.graphql.generated.OwnerType;
import com.linkedin.datahub.graphql.types.LoadableType;
import graphql.schema.DataFetcher;
import graphql.schema.DataFetchingEnvironment;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.function.Function;
import org.dataloader.DataLoader;
import java.util.stream.Collectors;
import com.google.common.collect.Iterables;
/**
* Generic GraphQL resolver responsible for
*
* 1. Retrieving a single input urn.
* 2. Resolving a single {@link LoadableType}.
*
* Note that this resolver expects that {@link DataLoader}s were registered
* for the provided {@link LoadableType} under the name provided by {@link LoadableType#name()}
*
* @param <T> the generated GraphQL POJO corresponding to the resolved type.
*/
public class OwnerTypeResolver<T> implements DataFetcher<CompletableFuture<T>> {
private final List<LoadableType<?>> _loadableTypes;
private final Function<DataFetchingEnvironment, OwnerType> _urnProvider;
public OwnerTypeResolver(final List<LoadableType<?>> loadableTypes, final Function<DataFetchingEnvironment, OwnerType> urnProvider) {
_loadableTypes = loadableTypes;
_urnProvider = urnProvider;
}
@Override
public CompletableFuture<T> get(DataFetchingEnvironment environment) {
final OwnerType ownerType = _urnProvider.apply(environment);
final LoadableType<?> filteredEntity = Iterables.getOnlyElement(_loadableTypes.stream()
.filter(entity -> ownerType.getClass().isAssignableFrom(entity.objectClass()))
.collect(Collectors.toList()));
final DataLoader<String, T> loader = environment.getDataLoaderRegistry().getDataLoader(filteredEntity.name());
return loader.load(((Entity) ownerType).getUrn());
}
}

View File

@ -1,6 +1,7 @@
package com.linkedin.datahub.graphql.types.common.mappers;
import com.linkedin.datahub.graphql.generated.CorpUser;
import com.linkedin.datahub.graphql.generated.CorpGroup;
import com.linkedin.datahub.graphql.generated.Owner;
import com.linkedin.datahub.graphql.generated.OwnershipType;
import com.linkedin.datahub.graphql.types.mappers.ModelMapper;
@ -24,9 +25,15 @@ public class OwnerMapper implements ModelMapper<com.linkedin.common.Owner, Owner
public Owner apply(@Nonnull final com.linkedin.common.Owner owner) {
final Owner result = new Owner();
result.setType(Enum.valueOf(OwnershipType.class, owner.getType().toString()));
CorpUser partialOwner = new CorpUser();
partialOwner.setUrn(owner.getOwner().toString());
result.setOwner(partialOwner);
if (owner.getOwner().getEntityType().equals("corpuser")) {
CorpUser partialOwner = new CorpUser();
partialOwner.setUrn(owner.getOwner().toString());
result.setOwner(partialOwner);
} else {
CorpGroup partialOwner = new CorpGroup();
partialOwner.setUrn(owner.getOwner().toString());
result.setOwner(partialOwner);
}
if (owner.hasSource()) {
result.setSource(OwnershipSourceMapper.map(owner.getSource()));
}

View File

@ -8,7 +8,11 @@ import com.linkedin.common.OwnershipSourceType;
import com.linkedin.common.OwnershipType;
import com.linkedin.datahub.graphql.generated.OwnerUpdate;
import com.linkedin.datahub.graphql.types.corpuser.CorpUserUtils;
import com.linkedin.datahub.graphql.types.corpgroup.CorpGroupUtils;
import com.linkedin.datahub.graphql.types.mappers.ModelMapper;
import com.linkedin.common.urn.Urn;
import java.net.URISyntaxException;
public class OwnerUpdateMapper implements ModelMapper<OwnerUpdate, Owner> {
@ -21,7 +25,15 @@ public class OwnerUpdateMapper implements ModelMapper<OwnerUpdate, Owner> {
@Override
public Owner apply(@Nonnull final OwnerUpdate input) {
final Owner owner = new Owner();
owner.setOwner(CorpUserUtils.getCorpUserUrn(input.getOwner()));
try {
if (Urn.createFromString(input.getOwner()).getEntityType().equals("corpuser")) {
owner.setOwner(CorpUserUtils.getCorpUserUrn(input.getOwner()));
} else if (Urn.createFromString(input.getOwner()).getEntityType().equals("corpGroup")) {
owner.setOwner(CorpGroupUtils.getCorpGroupUrn(input.getOwner()));
}
} catch (URISyntaxException e) {
e.printStackTrace();
}
owner.setType(OwnershipType.valueOf(input.getType().toString()));
owner.setSource(new OwnershipSource().setType(OwnershipSourceType.SERVICE));
return owner;

View File

@ -0,0 +1,99 @@
package com.linkedin.datahub.graphql.types.corpgroup;
import com.linkedin.common.urn.CorpGroupUrn;
import com.linkedin.datahub.graphql.QueryContext;
import com.linkedin.datahub.graphql.generated.EntityType;
import com.linkedin.datahub.graphql.types.SearchableEntityType;
import com.linkedin.datahub.graphql.generated.AutoCompleteResults;
import com.linkedin.datahub.graphql.generated.CorpGroup;
import com.linkedin.datahub.graphql.generated.FacetFilterInput;
import com.linkedin.datahub.graphql.generated.SearchResults;
import com.linkedin.datahub.graphql.types.mappers.AutoCompleteResultsMapper;
import com.linkedin.datahub.graphql.types.corpgroup.mappers.CorpGroupMapper;
import com.linkedin.datahub.graphql.types.mappers.SearchResultsMapper;
import com.linkedin.identity.client.CorpGroups;
import com.linkedin.metadata.query.AutoCompleteResult;
import com.linkedin.restli.common.CollectionResponse;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class CorpGroupType implements SearchableEntityType<CorpGroup> {
private static final String DEFAULT_AUTO_COMPLETE_FIELD = "name";
private final CorpGroups _corpGroupsClient;
public CorpGroupType(final CorpGroups corpGroupsClient) {
_corpGroupsClient = corpGroupsClient;
}
@Override
public Class<CorpGroup> objectClass() {
return CorpGroup.class;
}
@Override
public EntityType type() {
return EntityType.CORP_GROUP;
}
@Override
public List<CorpGroup> batchLoad(final List<String> urns, final QueryContext context) {
try {
final List<CorpGroupUrn> corpGroupUrns = urns
.stream()
.map(this::getCorpGroupUrn)
.collect(Collectors.toList());
final Map<CorpGroupUrn, com.linkedin.identity.CorpGroup> corpGroupMap = _corpGroupsClient
.batchGet(new HashSet<>(corpGroupUrns));
final List<com.linkedin.identity.CorpGroup> results = new ArrayList<>();
for (CorpGroupUrn urn : corpGroupUrns) {
results.add(corpGroupMap.getOrDefault(urn, null));
}
return results.stream()
.map(gmsCorpGroup -> gmsCorpGroup == null ? null : CorpGroupMapper.map(gmsCorpGroup))
.collect(Collectors.toList());
} catch (Exception e) {
throw new RuntimeException("Failed to batch load CorpGroup", e);
}
}
@Override
public SearchResults search(@Nonnull String query,
@Nullable List<FacetFilterInput> filters,
int start,
int count,
@Nonnull final QueryContext context) throws Exception {
final CollectionResponse<com.linkedin.identity.CorpGroup> searchResult = _corpGroupsClient.search(query, Collections.emptyMap(), start, count);
return SearchResultsMapper.map(searchResult, CorpGroupMapper::map);
}
@Override
public AutoCompleteResults autoComplete(@Nonnull String query,
@Nullable String field,
@Nullable List<FacetFilterInput> filters,
int limit,
@Nonnull final QueryContext context) throws Exception {
field = field != null ? field : DEFAULT_AUTO_COMPLETE_FIELD;
final AutoCompleteResult result = _corpGroupsClient.autocomplete(query, field, Collections.emptyMap(), limit);
return AutoCompleteResultsMapper.map(result);
}
private CorpGroupUrn getCorpGroupUrn(final String urnStr) {
try {
return CorpGroupUrn.createFromString(urnStr);
} catch (URISyntaxException e) {
throw new RuntimeException(String.format("Failed to retrieve CorpGroup with urn %s, invalid urn", urnStr));
}
}
}

View File

@ -0,0 +1,21 @@
package com.linkedin.datahub.graphql.types.corpgroup;
import java.net.URISyntaxException;
import com.linkedin.common.urn.CorpGroupUrn;
public class CorpGroupUtils {
private CorpGroupUtils() { }
public static CorpGroupUrn getCorpGroupUrn(final String urnStr) {
if (urnStr == null) {
return null;
}
try {
return CorpGroupUrn.createFromString(urnStr);
} catch (URISyntaxException e) {
throw new RuntimeException(String.format("Failed to create CorpGroupUrn from string %s", urnStr), e);
}
}
}

View File

@ -0,0 +1,46 @@
package com.linkedin.datahub.graphql.types.corpgroup.mappers;
import com.linkedin.datahub.graphql.generated.CorpUser;
import com.linkedin.datahub.graphql.generated.CorpGroupInfo;
import com.linkedin.datahub.graphql.types.mappers.ModelMapper;
import javax.annotation.Nonnull;
import java.util.stream.Collectors;
/**
* Maps Pegasus {@link RecordTemplate} objects to objects conforming to the GQL schema.
*
* To be replaced by auto-generated mappers implementations
*/
public class CorpGroupInfoMapper implements ModelMapper<com.linkedin.identity.CorpGroupInfo, CorpGroupInfo> {
public static final CorpGroupInfoMapper INSTANCE = new CorpGroupInfoMapper();
public static CorpGroupInfo map(@Nonnull final com.linkedin.identity.CorpGroupInfo corpGroupInfo) {
return INSTANCE.apply(corpGroupInfo);
}
@Override
public CorpGroupInfo apply(@Nonnull final com.linkedin.identity.CorpGroupInfo info) {
final CorpGroupInfo result = new CorpGroupInfo();
result.setEmail(info.getEmail());
if (info.hasAdmins()) {
result.setAdmins(info.getAdmins().stream().map(urn -> {
final CorpUser corpUser = new CorpUser();
corpUser.setUrn(urn.toString());
return corpUser;
}).collect(Collectors.toList()));
}
if (info.hasMembers()) {
result.setMembers(info.getMembers().stream().map(urn -> {
final CorpUser corpUser = new CorpUser();
corpUser.setUrn(urn.toString());
return corpUser;
}).collect(Collectors.toList()));
}
if (info.hasGroups()) {
result.setGroups(info.getGroups().stream().map(urn -> (urn.toString())).collect(Collectors.toList()));
}
return result;
}
}

View File

@ -0,0 +1,34 @@
package com.linkedin.datahub.graphql.types.corpgroup.mappers;
import com.linkedin.common.urn.CorpGroupUrn;
import com.linkedin.datahub.graphql.generated.CorpGroup;
import com.linkedin.datahub.graphql.generated.EntityType;
import com.linkedin.datahub.graphql.types.mappers.ModelMapper;
import javax.annotation.Nonnull;
/**
* Maps Pegasus {@link RecordTemplate} objects to objects conforming to the GQL schema.
*
* To be replaced by auto-generated mappers implementations
*/
public class CorpGroupMapper implements ModelMapper<com.linkedin.identity.CorpGroup, CorpGroup> {
public static final CorpGroupMapper INSTANCE = new CorpGroupMapper();
public static CorpGroup map(@Nonnull final com.linkedin.identity.CorpGroup corpGroup) {
return INSTANCE.apply(corpGroup);
}
@Override
public CorpGroup apply(@Nonnull final com.linkedin.identity.CorpGroup corpGroup) {
final CorpGroup result = new CorpGroup();
result.setUrn(new CorpGroupUrn(corpGroup.getName()).toString());
result.setType(EntityType.CORP_GROUP);
result.setName(corpGroup.getName());
if (corpGroup.hasInfo()) {
result.setInfo(CorpGroupInfoMapper.map(corpGroup.getInfo()));
}
return result;
}
}

View File

@ -56,6 +56,10 @@ enum EntityType {
"""
CORP_USER
"""
The CorpGroup Entity
"""
CORP_GROUP
"""
The DataPlatform Entity
"""
DATA_PLATFORM
@ -100,6 +104,11 @@ type Query {
"""
corpUser(urn: String!): CorpUser
"""
Fetch a CorpGroup by primary key
"""
corpGroup(urn: String!): CorpGroup
"""
Fetch a Dashboard by primary key
"""
@ -732,18 +741,6 @@ type StringMapEntry {
value: String
}
type Ownership {
"""
List of owners of the entity
"""
owners: [Owner!]
"""
Audit stamp containing who last modified the record and when
"""
lastModified: AuditStamp!
}
enum OwnershipSourceType {
"""
Auditing system or audit logs
@ -823,23 +820,6 @@ enum OwnershipType {
STAKEHOLDER
}
type Owner {
"""
Owner object - This should be extended to support CorpGroups as well
"""
owner: CorpUser!
"""
The type of the ownership
"""
type: OwnershipType!
"""
Source information for the ownership
"""
source: OwnershipSource
}
type CorpUser implements Entity {
"""
The unique user URN
@ -951,7 +931,82 @@ type CorpUserEditableInfo {
pictureLink: String
}
type Tag implements Entity{
type CorpGroup implements Entity {
"""
The unique user URN
"""
urn: String!
"""
GMS Entity Type
"""
type: EntityType!
"""
group name e.g. wherehows-dev, ask_metadata
"""
name: String
"""
Information of the corp group
"""
info: CorpGroupInfo
}
type CorpGroupInfo {
"""
email of this group
"""
email: String!
"""
owners of this group
"""
admins: [CorpUser!]
"""
List of ldap urn in this group.
"""
members: [CorpUser!]
"""
List of groups in this group.
"""
groups: [String!]
}
union OwnerType = CorpUser | CorpGroup
type Owner {
"""
Owner object
"""
owner: OwnerType!
"""
The type of the ownership
"""
type: OwnershipType!
"""
Source information for the ownership
"""
source: OwnershipSource
}
type Ownership {
"""
List of owners of the entity
"""
owners: [Owner!]
"""
Audit stamp containing who last modified the record and when
"""
lastModified: AuditStamp!
}
type Tag implements Entity {
urn: String!
"""
GMS Entity Type

View File

@ -238,11 +238,58 @@ type CorpUser {
editableInfo: CorpUserEditableInfo
}
type CorpGroup implements Entity {
"""
The unique user URN
"""
urn: String!
"""
GMS Entity Type
"""
type: EntityType!
"""
group name e.g. wherehows-dev, ask_metadata
"""
name: String
"""
Information of the corp group
"""
info: CorpGroupInfo
}
type CorpGroupInfo {
"""
email of this group
"""
email: String!
"""
owners of this group
"""
admins: [String!]!
"""
List of ldap urn in this group.
"""
members: [String!]!
"""
List of groups in this group.
"""
groups: [String!]!
}
enum EntityType {
DATASET
USER
DATA_FLOW
DATA_JOB
CORP_USER
CORP_GROUP
}
# Search Input

View File

@ -13,6 +13,7 @@ import EntityRegistry from './app/entity/EntityRegistry';
import { DashboardEntity } from './app/entity/dashboard/DashboardEntity';
import { ChartEntity } from './app/entity/chart/ChartEntity';
import { UserEntity } from './app/entity/user/User';
import { UserGroupEntity } from './app/entity/userGroup/UserGroup';
import { DatasetEntity } from './app/entity/dataset/DatasetEntity';
import { DataFlowEntity } from './app/entity/dataFlow/DataFlowEntity';
import { DataJobEntity } from './app/entity/dataJob/DataJobEntity';
@ -88,6 +89,7 @@ const App: React.VFC = () => {
register.register(new DashboardEntity());
register.register(new ChartEntity());
register.register(new UserEntity());
register.register(new UserGroupEntity());
register.register(new TagEntity());
register.register(new DataFlowEntity());
register.register(new DataJobEntity());

View File

@ -35,15 +35,7 @@ export const ChartPreview = ({
platform={capitalizedPlatform}
qualifier={access}
tags={tags}
owners={
owners?.map((owner) => {
return {
urn: owner.owner.urn,
name: owner.owner.info?.fullName || '',
photoUrl: owner.owner.editableInfo?.pictureLink || '',
};
}) || []
}
owners={owners}
/>
);
};

View File

@ -1,8 +1,8 @@
import { Avatar, Button, Divider, Row, Space, Typography } from 'antd';
import { Button, Divider, Row, Space, Typography } from 'antd';
import React from 'react';
import { AuditStamp, ChartType, EntityType, Ownership } from '../../../../types.generated';
import { AuditStamp, ChartType, Ownership } from '../../../../types.generated';
import { useEntityRegistry } from '../../../useEntityRegistry';
import CustomAvatar from '../../../shared/avatar/CustomAvatar';
import { AvatarsGroup } from '../../../shared/avatar';
import { capitalizeFirstLetter } from '../../../shared/capitalizeFirstLetter';
import analytics, { EventType, EntityActionType } from '../../../analytics';
@ -57,16 +57,7 @@ export default function ChartHeader({
</Space>
</Row>
<Typography.Paragraph>{description}</Typography.Paragraph>
<Avatar.Group maxCount={6} size="large">
{ownership?.owners?.map((owner: any) => (
<CustomAvatar
key={owner.owner.urn}
name={owner.owner.info?.fullName}
url={`/${entityRegistry.getPathName(EntityType.CorpUser)}/${owner.owner.urn}`}
photoUrl={owner.owner.editableInfo?.pictureLink}
/>
))}
</Avatar.Group>
<AvatarsGroup owners={ownership?.owners} entityRegistry={entityRegistry} size="large" />
{lastModified && (
<Typography.Text type="secondary">
Last modified at {new Date(lastModified.time).toLocaleDateString('en-US')}

View File

@ -34,15 +34,7 @@ export const DashboardPreview = ({
logoUrl={getLogoFromPlatform(platform) || ''}
platform={capitalizedPlatform}
qualifier={access}
owners={
owners?.map((owner) => {
return {
urn: owner.owner.urn,
name: owner.owner.info?.fullName || '',
photoUrl: owner.owner.editableInfo?.pictureLink || '',
};
}) || []
}
owners={owners}
tags={tags}
/>
);

View File

@ -1,9 +1,9 @@
import { Avatar, Button, Divider, Row, Space, Typography } from 'antd';
import { Button, Divider, Row, Space, Typography } from 'antd';
import React from 'react';
import { AuditStamp, EntityType, Ownership } from '../../../../types.generated';
import { AuditStamp, Ownership } from '../../../../types.generated';
import { useEntityRegistry } from '../../../useEntityRegistry';
import CustomAvatar from '../../../shared/avatar/CustomAvatar';
import { capitalizeFirstLetter } from '../../../shared/capitalizeFirstLetter';
import { AvatarsGroup } from '../../../shared/avatar';
import analytics, { EventType, EntityActionType } from '../../../analytics';
const styles = {
@ -45,16 +45,7 @@ export default function DashboardHeader({ urn, platform, description, ownership,
</Space>
</Row>
<Typography.Paragraph>{description}</Typography.Paragraph>
<Avatar.Group maxCount={6} size="large">
{ownership?.owners?.map((owner: any) => (
<CustomAvatar
key={owner.owner.urn}
name={owner.owner.info?.fullName}
url={`/${entityRegistry.getPathName(EntityType.CorpUser)}/${owner.owner.urn}`}
photoUrl={owner.owner.editableInfo?.pictureLink}
/>
))}
</Avatar.Group>
<AvatarsGroup owners={ownership?.owners} entityRegistry={entityRegistry} size="large" />
{lastModified && (
<Typography.Text type="secondary">
Last modified at {new Date(lastModified.time).toLocaleDateString('en-US')}

View File

@ -33,15 +33,7 @@ export const Preview = ({
type="Data Pipeline"
platform={capitalizedPlatform}
logoUrl={platformLogo || ''}
owners={
owners?.map((owner) => {
return {
urn: owner.owner.urn,
name: owner.owner.info?.fullName || '',
photoUrl: owner.owner.editableInfo?.pictureLink || '',
};
}) || []
}
owners={owners}
tags={globalTags || undefined}
snippet={snippet}
/>

View File

@ -1,10 +1,9 @@
import { Avatar, Button, Divider, Row, Space, Tooltip, Typography } from 'antd';
import { Button, Divider, Row, Space, Typography } from 'antd';
import React from 'react';
import { Link } from 'react-router-dom';
import { DataFlow, EntityType } from '../../../../types.generated';
import { DataFlow } from '../../../../types.generated';
import { useEntityRegistry } from '../../../useEntityRegistry';
import defaultAvatar from '../../../../images/default_avatar.png';
import { capitalizeFirstLetter } from '../../../shared/capitalizeFirstLetter';
import { AvatarsGroup } from '../../../shared/avatar';
import analytics, { EventType, EntityActionType } from '../../../analytics';
export type Props = {
@ -36,24 +35,7 @@ export default function DataFlowHeader({ dataFlow: { urn, ownership, info, orche
</Space>
</Row>
<Typography.Paragraph>{info?.description}</Typography.Paragraph>
<Avatar.Group maxCount={6} size="large">
{ownership?.owners?.map((owner) => (
<Tooltip title={owner.owner.info?.fullName} key={owner.owner.urn}>
<Link to={`/${entityRegistry.getPathName(EntityType.CorpUser)}/${owner.owner.urn}`}>
<Avatar
style={{
color: '#f56a00',
backgroundColor: '#fde3cf',
}}
src={
(owner.owner.editableInfo && owner.owner.editableInfo.pictureLink) ||
defaultAvatar
}
/>
</Link>
</Tooltip>
))}
</Avatar.Group>
<AvatarsGroup owners={ownership?.owners} entityRegistry={entityRegistry} size="large" />
</Space>
</>
);

View File

@ -33,15 +33,7 @@ export const Preview = ({
type="Data Task"
platform={capitalizedPlatform}
logoUrl={platformLogo || ''}
owners={
owners?.map((owner) => {
return {
urn: owner.owner.urn,
name: owner.owner.info?.fullName || '',
photoUrl: owner.owner.editableInfo?.pictureLink || '',
};
}) || []
}
owners={owners}
tags={globalTags || undefined}
snippet={snippet}
/>

View File

@ -1,10 +1,9 @@
import { Avatar, Button, Divider, Row, Space, Tooltip, Typography } from 'antd';
import { Button, Divider, Row, Space, Typography } from 'antd';
import React from 'react';
import { Link } from 'react-router-dom';
import { DataJob, EntityType } from '../../../../types.generated';
import { DataJob } from '../../../../types.generated';
import { useEntityRegistry } from '../../../useEntityRegistry';
import defaultAvatar from '../../../../images/default_avatar.png';
import { capitalizeFirstLetter } from '../../../shared/capitalizeFirstLetter';
import { AvatarsGroup } from '../../../shared/avatar';
import analytics, { EventType, EntityActionType } from '../../../analytics';
export type Props = {
@ -36,24 +35,7 @@ export default function DataJobHeader({ dataJob: { urn, ownership, info, dataFlo
</Space>
</Row>
<Typography.Paragraph>{info?.description}</Typography.Paragraph>
<Avatar.Group maxCount={6} size="large">
{ownership?.owners?.map((owner) => (
<Tooltip title={owner.owner.info?.fullName} key={owner.owner.urn}>
<Link to={`/${entityRegistry.getPathName(EntityType.CorpUser)}/${owner.owner.urn}`}>
<Avatar
style={{
color: '#f56a00',
backgroundColor: '#fde3cf',
}}
src={
(owner.owner.editableInfo && owner.owner.editableInfo.pictureLink) ||
defaultAvatar
}
/>
</Link>
</Tooltip>
))}
</Avatar.Group>
<AvatarsGroup owners={ownership?.owners} entityRegistry={entityRegistry} size="large" />
</Space>
</>
);

View File

@ -37,15 +37,7 @@ export const Preview = ({
platform={capitalPlatformName}
qualifier={origin}
tags={globalTags || undefined}
owners={
owners?.map((owner) => {
return {
urn: owner.owner.urn,
name: owner.owner.info?.fullName || '',
photoUrl: owner.owner.editableInfo?.pictureLink || '',
};
}) || []
}
owners={owners}
snippet={snippet}
/>
);

View File

@ -1,10 +1,10 @@
import { Avatar, Badge, Divider, Popover, Space, Typography } from 'antd';
import { Badge, Divider, Popover, Space, Typography } from 'antd';
import { ParagraphProps } from 'antd/lib/typography/Paragraph';
import React from 'react';
import styled from 'styled-components';
import { Dataset, EntityType } from '../../../../types.generated';
import { Dataset } from '../../../../types.generated';
import { useEntityRegistry } from '../../../useEntityRegistry';
import CustomAvatar from '../../../shared/avatar/CustomAvatar';
import { AvatarsGroup } from '../../../shared/avatar';
import CompactContext from '../../../shared/CompactContext';
import { capitalizeFirstLetter } from '../../../shared/capitalizeFirstLetter';
@ -38,16 +38,7 @@ export default function DatasetHeader({ dataset: { description, ownership, depre
<Typography.Text strong>{platformName}</Typography.Text>
</Space>
<DescriptionText isCompact={isCompact}>{description}</DescriptionText>
<Avatar.Group maxCount={6} size="large">
{ownership?.owners?.map((owner) => (
<CustomAvatar
key={owner.owner.urn}
name={owner.owner.info?.fullName || undefined}
url={`/${entityRegistry.getPathName(EntityType.CorpUser)}/${owner.owner.urn}`}
photoUrl={owner.owner.editableInfo?.pictureLink || undefined}
/>
))}
</Avatar.Group>
<AvatarsGroup owners={ownership?.owners} entityRegistry={entityRegistry} size="large" />
<div>
{deprecation?.deprecated && (
<Popover

View File

@ -15,7 +15,10 @@ export const sampleDataset: Dataset = {
tags: [],
ownership: {
owners: [
{ owner: { urn: 'user:urn', type: EntityType.CorpUser, username: 'UserA' }, type: OwnershipType.Dataowner },
{
owner: { urn: 'user:urn', type: EntityType.CorpUser, username: 'UserA' },
type: OwnershipType.Dataowner,
},
],
lastModified: { time: 1 },
},
@ -53,7 +56,10 @@ export const sampleDeprecatedDataset: Dataset = {
tags: [],
ownership: {
owners: [
{ owner: { urn: 'user:urn', type: EntityType.CorpUser, username: 'UserA' }, type: OwnershipType.Dataowner },
{
owner: { urn: 'user:urn', type: EntityType.CorpUser, username: 'UserA' },
type: OwnershipType.Dataowner,
},
],
lastModified: { time: 1 },
},

View File

@ -34,18 +34,35 @@ export const Ownership: React.FC<Props> = ({ owners, lastModifiedAt, updateOwner
const ownerTableData = useMemo(
() =>
stagedOwners.map((owner, index) => ({
key: index,
urn: owner.owner.urn,
ldap: owner.owner.username,
fullName: owner.owner.info?.fullName,
role: owner.type,
pictureLink: owner.owner.editableInfo?.pictureLink,
})),
// eslint-disable-next-line consistent-return, array-callback-return
stagedOwners.map((owner, index) => {
if (owner.owner.__typename === 'CorpUser') {
return {
key: index,
urn: owner.owner.urn,
ldap: owner.owner.username,
fullName: owner.owner.info?.fullName || owner.owner.username,
role: owner.type,
pictureLink: owner.owner.editableInfo?.pictureLink,
type: EntityType.CorpUser,
};
}
if (owner.owner.__typename === 'CorpGroup') {
return {
key: index,
urn: owner.owner.urn,
ldap: owner.owner.name,
fullName: owner.owner.name,
role: owner.type,
type: EntityType.CorpGroup,
};
}
return {};
}),
[stagedOwners],
);
const isEditing = (record: any) => record.key === editingIndex;
const isEditing = (record: { key: number }) => record.key === editingIndex;
const onAdd = () => {
setEditingIndex(stagedOwners.length);
@ -53,6 +70,7 @@ export const Ownership: React.FC<Props> = ({ owners, lastModifiedAt, updateOwner
form.setFieldsValue({
ldap: '',
role: OwnershipType.Stakeholder,
type: EntityType.CorpUser,
});
const newOwner = {
@ -81,26 +99,28 @@ export const Ownership: React.FC<Props> = ({ owners, lastModifiedAt, updateOwner
updateOwnership({ owners: updatedOwners });
};
const onChangeOwnerQuery = (query: string) => {
getOwnerAutoCompleteResults({
variables: {
input: {
type: EntityType.CorpUser,
query,
field: 'ldap',
const onChangeOwnerQuery = async (query: string) => {
if (query && query !== '') {
const row = await form.validateFields();
getOwnerAutoCompleteResults({
variables: {
input: {
type: row.type,
query,
field: row.type === EntityType.CorpUser ? 'ldap' : 'name',
},
},
},
});
});
}
setOwnerQuery(query);
};
const onSave = async (record: any) => {
const row = await form.validateFields();
const updatedOwners = stagedOwners.map((owner, index) => {
if (record.key === index) {
return {
owner: `urn:li:corpuser:${row.ldap}`,
owner: `urn:li:${row.type === EntityType.CorpGroup ? 'corpGroup' : 'corpuser'}:${row.ldap}`,
type: row.role,
};
}
@ -162,9 +182,10 @@ export const Ownership: React.FC<Props> = ({ owners, lastModifiedAt, updateOwner
key={record.urn}
placement="left"
name={record.fullName}
url={`/${entityRegistry.getPathName(EntityType.CorpUser)}/${record.urn}`}
url={`/${entityRegistry.getPathName(record.type)}/${record.urn}`}
photoUrl={record.pictureLink}
style={{ marginRight: '15px' }}
isGroup={record.type === EntityType.CorpGroup}
/>
);
},
@ -194,7 +215,9 @@ export const Ownership: React.FC<Props> = ({ owners, lastModifiedAt, updateOwner
>
<Select placeholder="Select a role">
{Object.values(OwnershipType).map((value) => (
<Select.Option value={value}>{value}</Select.Option>
<Select.Option value={value} key={value}>
{value}
</Select.Option>
))}
</Select>
</Form.Item>
@ -203,6 +226,39 @@ export const Ownership: React.FC<Props> = ({ owners, lastModifiedAt, updateOwner
);
},
},
{
title: 'Type',
dataIndex: 'type',
render: (type: EntityType, record: any) => {
return isEditing(record) ? (
<Form.Item
name="type"
style={{
margin: 0,
width: '50%',
}}
rules={[
{
required: true,
type: 'string',
message: `Please select a type!`,
},
]}
>
<Select placeholder="Select a type" defaultValue={EntityType.CorpUser}>
<Select.Option value={EntityType.CorpUser} key={EntityType.CorpUser}>
{EntityType.CorpUser}
</Select.Option>
<Select.Option value={EntityType.CorpGroup} key={EntityType.CorpGroup}>
{EntityType.CorpGroup}
</Select.Option>
</Select>
</Form.Item>
) : (
<Tag>{type}</Tag>
);
},
},
{
title: '',
key: 'action',

View File

@ -1,5 +1,5 @@
import { grey } from '@ant-design/colors';
import { Alert, Avatar, Button, Card, Typography } from 'antd';
import { Alert, Button, Card, Typography } from 'antd';
import React from 'react';
import { useHistory, useParams } from 'react-router';
import styled from 'styled-components';
@ -9,7 +9,7 @@ import { EntityType } from '../../../types.generated';
import { useGetAllEntitySearchResults } from '../../../utils/customGraphQL/useGetAllEntitySearchResults';
import { navigateToSearchUrl } from '../../search/utils/navigateToSearchUrl';
import { Message } from '../../shared/Message';
import CustomAvatar from '../../shared/avatar/CustomAvatar';
import { AvatarsGroup } from '../../shared/avatar';
import { useEntityRegistry } from '../../useEntityRegistry';
const PageContainer = styled.div`
@ -119,19 +119,11 @@ export default function TagProfile() {
<div>
<CreatedByLabel>Created by</CreatedByLabel>
</div>
<Avatar.Group maxCount={6} size="large">
{data?.tag?.ownership?.owners?.map((owner) => (
<div data-testid={`avatar-tag-${owner.owner.urn}`} key={owner.owner.urn}>
<CustomAvatar
name={owner.owner.info?.fullName || undefined}
url={`/${entityRegistry.getPathName(EntityType.CorpUser)}/${
owner.owner.urn
}`}
photoUrl={owner.owner?.editableInfo?.pictureLink || undefined}
/>
</div>
))}
</Avatar.Group>
<AvatarsGroup
owners={data?.tag?.ownership?.owners}
entityRegistry={entityRegistry}
size="large"
/>
</div>
</div>
<StatsBox>

View File

@ -43,13 +43,6 @@ describe('TagProfile', () => {
expect(getByTestId('avatar-tag-urn:li:corpuser:3')).toBeInTheDocument();
expect(getByTestId('avatar-tag-urn:li:corpuser:1')).toBeInTheDocument();
expect(getByTestId('avatar-tag-urn:li:corpuser:1').querySelector('a').href).toEqual(
'http://localhost/user/urn:li:corpuser:1',
);
expect(getByTestId('avatar-tag-urn:li:corpuser:3').querySelector('a').href).toEqual(
'http://localhost/user/urn:li:corpuser:3',
);
});
it('renders stats', async () => {

View File

@ -11,6 +11,7 @@ type Props = {
skills?: string[] | null;
teams?: string[] | null;
email?: string | null;
isGroup?: boolean;
};
const Row = styled.div`
@ -30,11 +31,16 @@ const Skills = styled.div`
margin-right: 32px;
`;
export default function UserHeader({ profileSrc, name, title, skills, teams, email }: Props) {
export default function UserHeader({ profileSrc, name, title, skills, teams, email, isGroup = false }: Props) {
return (
<Row>
<AvatarWrapper>
<CustomAvatar size={100} photoUrl={profileSrc || undefined} name={name || undefined} />
<CustomAvatar
size={100}
photoUrl={profileSrc || undefined}
name={name || undefined}
isGroup={isGroup}
/>
</AvatarWrapper>
<div>
<Typography.Title level={3}>{name}</Typography.Title>

View File

@ -0,0 +1,55 @@
import { UserOutlined } from '@ant-design/icons';
import * as React from 'react';
import { CorpGroup, EntityType, SearchResult } from '../../../types.generated';
import { Entity, IconStyleType, PreviewType } from '../Entity';
import { Preview } from './preview/Preview';
import UserGroupProfile from './UserGroupProfile';
/**
* Definition of the DataHub CorpGroup entity.
*/
export class UserGroupEntity implements Entity<CorpGroup> {
type: EntityType = EntityType.CorpGroup;
// TODO: update icons for UserGroup
icon = (fontSize: number, styleType: IconStyleType) => {
if (styleType === IconStyleType.TAB_VIEW) {
return <UserOutlined style={{ fontSize }} />;
}
if (styleType === IconStyleType.HIGHLIGHT) {
return <UserOutlined style={{ fontSize, color: 'rgb(144 163 236)' }} />;
}
return (
<UserOutlined
style={{
fontSize,
color: '#BFBFBF',
}}
/>
);
};
isSearchEnabled = () => false;
isBrowseEnabled = () => false;
isLineageEnabled = () => false;
getAutoCompleteFieldName = () => 'name';
getPathName: () => string = () => 'userGroup';
getCollectionName: () => string = () => 'UserGroups';
renderProfile: (urn: string) => JSX.Element = (_) => <UserGroupProfile />;
renderPreview = (_: PreviewType, data: CorpGroup) => (
<Preview urn={data.urn} name={data.name || data.urn || ''} title={data.name || data.urn || ''} />
);
renderSearch = (result: SearchResult) => {
return this.renderPreview(PreviewType.SEARCH, result.entity as CorpGroup);
};
}

View File

@ -0,0 +1,52 @@
import { Divider, Alert } from 'antd';
import React from 'react';
import styled from 'styled-components';
import UserHeader from '../user/UserHeader';
import useUserParams from '../user/routingUtils/useUserParams';
import { useGetUserGroupQuery } from '../../../graphql/user.generated';
import { useGetAllEntitySearchResults } from '../../../utils/customGraphQL/useGetAllEntitySearchResults';
import { Message } from '../../shared/Message';
const PageContainer = styled.div`
padding: 32px 100px;
`;
const messageStyle = { marginTop: '10%' };
/**
* Responsible for reading & writing users.
*/
export default function UserGroupProfile() {
const { urn } = useUserParams();
const { loading, error, data } = useGetUserGroupQuery({ variables: { urn } });
const name = data?.corpGroup?.name;
const ownershipResult = useGetAllEntitySearchResults({
query: `owners:${name}`,
});
const contentLoading =
Object.keys(ownershipResult).some((type) => {
return ownershipResult[type].loading;
}) || loading;
if (error || (!loading && !error && !data)) {
return <Alert type="error" message={error?.message || 'Entity failed to load'} />;
}
return (
<PageContainer>
{contentLoading && <Message type="loading" content="Loading..." style={messageStyle} />}
<UserHeader
name={data?.corpGroup?.name}
title={data?.corpGroup?.name}
email={data?.corpGroup?.info?.email}
teams={data?.corpGroup?.info?.groups}
isGroup
/>
<Divider />
</PageContainer>
);
}

View File

@ -0,0 +1,42 @@
import React from 'react';
import { Space, Typography } from 'antd';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import { EntityType } from '../../../../types.generated';
import { useEntityRegistry } from '../../../useEntityRegistry';
import CustomAvatar from '../../../shared/avatar/CustomAvatar';
const NameText = styled(Typography.Title)`
margin: 0;
color: #0073b1;
`;
const TitleText = styled(Typography.Title)`
color: rgba(0, 0, 0, 0.45);
`;
export const Preview = ({
urn,
name,
title,
photoUrl,
}: {
urn: string;
name: string;
title?: string;
photoUrl?: string;
}): JSX.Element => {
const entityRegistry = useEntityRegistry();
return (
<Link to={`/${entityRegistry.getPathName(EntityType.CorpGroup)}/${urn}`}>
<Space size={28}>
<CustomAvatar size={60} photoUrl={photoUrl} name={name} isGroup />
<Space direction="vertical" size={4}>
<NameText level={3}>{name}</NameText>
<TitleText>{title}</TitleText>
</Space>
</Space>
</Link>
);
};

View File

@ -1,10 +1,10 @@
import { Avatar, Divider, Image, Row, Space, Tag, Typography } from 'antd';
import { Divider, Image, Row, Space, Tag, Typography } from 'antd';
import React from 'react';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import { EntityType, GlobalTags } from '../../types.generated';
import { GlobalTags, Owner } from '../../types.generated';
import { useEntityRegistry } from '../useEntityRegistry';
import CustomAvatar from '../shared/avatar/CustomAvatar';
import AvatarsGroup from '../shared/avatar/AvatarsGroup';
import TagGroup from '../shared/tags/TagGroup';
interface Props {
@ -16,7 +16,7 @@ interface Props {
platform?: string;
qualifier?: string | null;
tags?: GlobalTags;
owners?: Array<{ urn: string; name?: string; photoUrl?: string }>;
owners?: Array<Owner> | null;
snippet?: React.ReactNode;
}
@ -56,6 +56,7 @@ export default function DefaultPreviewCard({
snippet,
}: Props) {
const entityRegistry = useEntityRegistry();
return (
<Row style={styles.row} justify="space-between">
<Space direction="vertical" align="start" size={28} style={styles.leftColumn}>
@ -86,16 +87,7 @@ export default function DefaultPreviewCard({
<Space direction="vertical" align="end" size={36} style={styles.rightColumn}>
<Space direction="vertical" size={12}>
<Typography.Text strong>{owners && owners.length > 0 ? 'Owned By' : ''}</Typography.Text>
<Avatar.Group maxCount={4}>
{owners?.map((owner) => (
<CustomAvatar
key={owner.urn}
name={owner.name}
url={`/${entityRegistry.getPathName(EntityType.CorpUser)}/${owner.urn}`}
photoUrl={owner.photoUrl}
/>
))}
</Avatar.Group>
<AvatarsGroup owners={owners} entityRegistry={entityRegistry} maxCount={4} />
</Space>
<TagGroup editableTags={tags} maxShow={3} />
</Space>

View File

@ -0,0 +1,41 @@
import { Avatar } from 'antd';
import { AvatarSize } from 'antd/lib/avatar/SizeContext';
import React from 'react';
import { EntityType, Owner } from '../../../types.generated';
import CustomAvatar from './CustomAvatar';
import EntityRegistry from '../../entity/EntityRegistry';
type Props = {
owners?: Array<Owner> | null;
entityRegistry: EntityRegistry;
maxCount?: number;
size?: AvatarSize;
};
export default function AvatarsGroup({ owners, entityRegistry, maxCount = 6, size = 'default' }: Props) {
return (
<Avatar.Group maxCount={maxCount} size={size}>
{(owners || [])?.map((owner) => (
<div data-testid={`avatar-tag-${owner.owner.urn}`} key={owner.owner.urn}>
{owner.owner.__typename === 'CorpUser' ? (
<CustomAvatar
name={owner.owner.info?.fullName || owner.owner.info?.firstName || owner.owner.info?.email}
url={`/${entityRegistry.getPathName(owner.owner.type)}/${owner.owner.urn}`}
photoUrl={owner.owner?.editableInfo?.pictureLink || undefined}
/>
) : (
owner.owner.__typename === 'CorpGroup' && (
<CustomAvatar
name={owner.owner.name || owner.owner.info?.email}
url={`/${entityRegistry.getPathName(owner.owner.type || EntityType.CorpGroup)}/${
owner.owner.urn
}`}
isGroup
/>
)
)}
</div>
))}
</Avatar.Group>
);
}

View File

@ -6,9 +6,10 @@ import styled from 'styled-components';
import defaultAvatar from '../../../images/default_avatar.png';
const AvatarStyled = styled(Avatar)<{ size?: number }>`
const AvatarStyled = styled(Avatar)<{ size?: number; isGroup?: boolean }>`
color: #fff;
background-color: #ccc;
background-color: ${(props) =>
props.isGroup ? '#ccc' : '#ccc'}; // TODO: make it different style for corpGroup vs corpUser
text-align: center;
font-size: ${(props) => (props.size ? `${Math.max(props.size / 2.0, 14)}px` : '14px')} !important;
&& > span {
@ -24,27 +25,42 @@ type Props = {
style?: React.CSSProperties;
placement?: TooltipPlacement;
size?: number;
isGroup?: boolean;
};
export default function CustomAvatar({ url, photoUrl, useDefaultAvatar, name, style, placement, size }: Props) {
export default function CustomAvatar({
url,
photoUrl,
useDefaultAvatar,
name,
style,
placement,
size,
isGroup = false,
}: Props) {
const avatarWithInitial = name ? (
<AvatarStyled style={style} size={size}>
<AvatarStyled style={style} size={size} isGroup={isGroup}>
{name.charAt(0).toUpperCase()}
</AvatarStyled>
) : (
<AvatarStyled src={defaultAvatar} style={style} size={size} />
<AvatarStyled src={defaultAvatar} style={style} size={size} isGroup={isGroup} />
);
const avatarWithDefault = useDefaultAvatar ? (
<AvatarStyled src={defaultAvatar} style={style} size={size} />
<AvatarStyled src={defaultAvatar} style={style} size={size} isGroup={isGroup} />
) : (
avatarWithInitial
);
const avatar = photoUrl ? <AvatarStyled src={photoUrl} style={style} size={size} /> : avatarWithDefault;
const avatar =
photoUrl && photoUrl !== '' ? (
<AvatarStyled src={photoUrl} style={style} size={size} isGroup={isGroup} />
) : (
avatarWithDefault
);
if (!name) {
return url ? <Link to={url}>{avatar}</Link> : avatar;
}
return (
<Tooltip title={name} placement={placement}>
<Tooltip title={isGroup ? `${name} - Group` : name} placement={placement}>
{url ? <Link to={url}>{avatar}</Link> : avatar}
</Tooltip>
);

View File

@ -0,0 +1,5 @@
import AvatarsGroupComp from './AvatarsGroup';
import CustomAvatarComp from './CustomAvatar';
export const AvatarsGroup = AvatarsGroupComp;
export const CustomAvatar = CustomAvatarComp;

View File

@ -1,29 +1,3 @@
fragment ownershipFields on Ownership {
owners {
owner {
urn
type
username
info {
active
displayName
title
email
firstName
lastName
fullName
}
editableInfo {
pictureLink
}
}
type
}
lastModified {
time
}
}
fragment globalTagsFields on GlobalTags {
tags {
tag {
@ -34,6 +8,80 @@ fragment globalTagsFields on GlobalTags {
}
}
fragment ownershipFields on Ownership {
owners {
owner {
... on CorpUser {
urn
type
username
info {
active
displayName
title
email
firstName
lastName
fullName
}
editableInfo {
pictureLink
}
}
... on CorpGroup {
urn
type
name
info {
email
admins {
urn
username
info {
active
displayName
title
email
firstName
lastName
fullName
}
editableInfo {
pictureLink
teams
skills
}
}
members {
urn
username
info {
active
displayName
title
email
firstName
lastName
fullName
}
editableInfo {
pictureLink
teams
skills
}
}
groups
}
}
}
type
}
lastModified {
time
}
}
fragment nonRecursiveDatasetFields on Dataset {
urn
name

View File

@ -21,3 +21,51 @@ query getUser($urn: String!) {
}
}
}
query getUserGroup($urn: String!) {
corpGroup(urn: $urn) {
urn
type
name
info {
email
admins {
urn
username
info {
active
displayName
title
email
firstName
lastName
fullName
}
editableInfo {
pictureLink
teams
skills
}
}
members {
urn
username
info {
active
displayName
title
email
firstName
lastName
fullName
}
editableInfo {
pictureLink
teams
skills
}
}
groups
}
}
}

View File

@ -6,6 +6,7 @@ import { DatasetEntity } from '../../app/entity/dataset/DatasetEntity';
import { DataFlowEntity } from '../../app/entity/dataFlow/DataFlowEntity';
import { DataJobEntity } from '../../app/entity/dataJob/DataJobEntity';
import { UserEntity } from '../../app/entity/user/User';
import { UserGroupEntity } from '../../app/entity/userGroup/UserGroup';
import EntityRegistry from '../../app/entity/EntityRegistry';
import { EntityRegistryContext } from '../../entityRegistryContext';
import { TagEntity } from '../../app/entity/tag/Tag';
@ -21,6 +22,7 @@ export function getTestEntityRegistry() {
const entityRegistry = new EntityRegistry();
entityRegistry.register(new DatasetEntity());
entityRegistry.register(new UserEntity());
entityRegistry.register(new UserGroupEntity());
entityRegistry.register(new TagEntity());
entityRegistry.register(new DataFlowEntity());
entityRegistry.register(new DataJobEntity());

View File

@ -63,6 +63,44 @@
},
"proposedDelta": null
},
{
"auditHeader": null,
"proposedSnapshot": {
"com.linkedin.pegasus2avro.metadata.snapshot.CorpGroupSnapshot": {
"urn": "urn:li:corpGroup:jdoe",
"aspects": [
{
"com.linkedin.pegasus2avro.identity.CorpGroupInfo": {
"email": "jdoe@linkedin.com",
"admins": ["urn:li:corpuser:jdoe", "urn:li:corpuser:datahub"],
"members": ["urn:li:corpuser:jdoe", "urn:li:corpuser:datahub"],
"groups": []
}
}
]
}
},
"proposedDelta": null
},
{
"auditHeader": null,
"proposedSnapshot": {
"com.linkedin.pegasus2avro.metadata.snapshot.CorpGroupSnapshot": {
"urn": "urn:li:corpGroup:bfoo",
"aspects": [
{
"com.linkedin.pegasus2avro.identity.CorpGroupInfo": {
"email": "bfoo@linkedin.com",
"admins": ["urn:li:corpuser:jdoe", "urn:li:corpuser:datahub"],
"members": ["urn:li:corpuser:jdoe", "urn:li:corpuser:datahub"],
"groups": ["urn:li:corpGroup:jdoe"]
}
}
]
}
},
"proposedDelta": null
},
{
"auditHeader": null,
"proposedSnapshot": {
@ -1177,6 +1215,22 @@
"com.linkedin.pegasus2avro.metadata.snapshot.DashboardSnapshot": {
"urn": "urn:li:dashboard:(looker,baz)",
"aspects": [
{
"com.linkedin.pegasus2avro.common.Ownership": {
"owners": [
{
"owner": "urn:li:corpGroup:bfoo",
"type": "DATAOWNER",
"source": null
}
],
"lastModified": {
"time": 1581407189000,
"actor": "urn:li:corpuser:jdoe",
"impersonator": null
}
}
},
{
"com.linkedin.pegasus2avro.dashboard.DashboardInfo": {
"title": "Baz Dashboard",