diff --git a/build.gradle b/build.gradle index be4d7ee8a562b..3df3ffe6abfbb 100644 --- a/build.gradle +++ b/build.gradle @@ -57,7 +57,7 @@ buildscript { ext.hazelcastVersion = '5.3.6' ext.ebeanVersion = '15.5.2' ext.googleJavaFormatVersion = '1.18.1' - ext.openLineageVersion = '1.24.2' + ext.openLineageVersion = '1.25.0' ext.logbackClassicJava8 = '1.2.12' ext.docker_registry = 'acryldata' diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java index d1da55268a50d..079a20619d1ea 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java @@ -67,6 +67,7 @@ import com.linkedin.datahub.graphql.generated.EntityPath; import com.linkedin.datahub.graphql.generated.EntityRelationship; import com.linkedin.datahub.graphql.generated.EntityRelationshipLegacy; +import com.linkedin.datahub.graphql.generated.FacetMetadata; import com.linkedin.datahub.graphql.generated.ForeignKeyConstraint; import com.linkedin.datahub.graphql.generated.FormActorAssignment; import com.linkedin.datahub.graphql.generated.FreshnessContract; @@ -1474,6 +1475,19 @@ private void configureGenericEntityResolvers(final RuntimeWiring.Builder builder "entity", new EntityTypeResolver( entityTypes, (env) -> ((BrowsePathEntry) env.getSource()).getEntity()))) + .type( + "FacetMetadata", + typeWiring -> + typeWiring.dataFetcher( + "entity", + new EntityTypeResolver( + entityTypes, + (env) -> { + FacetMetadata facetMetadata = env.getSource(); + return facetMetadata.getEntity() != null + ? facetMetadata.getEntity() + : null; + }))) .type( "LineageRelationship", typeWiring -> diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/authorization/AuthorizationUtils.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/authorization/AuthorizationUtils.java index ca60acaa80538..c25d6af75fe76 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/authorization/AuthorizationUtils.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/authorization/AuthorizationUtils.java @@ -339,6 +339,11 @@ public static boolean canManageStructuredProperties(@Nonnull QueryContext contex context.getOperationContext(), PoliciesConfig.MANAGE_STRUCTURED_PROPERTIES_PRIVILEGE); } + public static boolean canViewStructuredPropertiesPage(@Nonnull QueryContext context) { + return AuthUtil.isAuthorized( + context.getOperationContext(), PoliciesConfig.VIEW_STRUCTURED_PROPERTIES_PAGE_PRIVILEGE); + } + public static boolean canManageForms(@Nonnull QueryContext context) { return AuthUtil.isAuthorized( context.getOperationContext(), PoliciesConfig.MANAGE_DOCUMENTATION_FORMS_PRIVILEGE); diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/MeResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/MeResolver.java index b1101ae3ee865..8297392e642d5 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/MeResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/MeResolver.java @@ -93,6 +93,10 @@ public CompletableFuture get(DataFetchingEnvironment environm BusinessAttributeAuthorizationUtils.canCreateBusinessAttribute(context)); platformPrivileges.setManageBusinessAttributes( BusinessAttributeAuthorizationUtils.canManageBusinessAttribute(context)); + platformPrivileges.setManageStructuredProperties( + AuthorizationUtils.canManageStructuredProperties(context)); + platformPrivileges.setViewStructuredPropertiesPage( + AuthorizationUtils.canViewStructuredPropertiesPage(context)); // Construct and return authenticated user object. final AuthenticatedUser authUser = new AuthenticatedUser(); authUser.setCorpUser(corpUser); diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/config/AppConfigResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/config/AppConfigResolver.java index 259d05c631557..3647eb55b2583 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/config/AppConfigResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/config/AppConfigResolver.java @@ -188,6 +188,7 @@ public CompletableFuture get(final DataFetchingEnvironment environmen .setDataContractsEnabled(_featureFlags.isDataContractsEnabled()) .setEditableDatasetNameEnabled(_featureFlags.isEditableDatasetNameEnabled()) .setShowSeparateSiblings(_featureFlags.isShowSeparateSiblings()) + .setShowManageStructuredProperties(_featureFlags.isShowManageStructuredProperties()) .build(); appConfig.setFeatureFlags(featureFlagsConfig); diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/AggregateAcrossEntitiesResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/AggregateAcrossEntitiesResolver.java index 29b71d95ad974..31ed2de7a6d51 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/AggregateAcrossEntitiesResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/AggregateAcrossEntitiesResolver.java @@ -66,11 +66,17 @@ public CompletableFuture get(DataFetchingEnvironment environme final Filter inputFilter = ResolverUtils.buildFilter(null, input.getOrFilters()); - final SearchFlags searchFlags = mapInputFlags(context, input.getSearchFlags()); + final SearchFlags searchFlags = + input.getSearchFlags() != null + ? mapInputFlags(context, input.getSearchFlags()) + : new SearchFlags(); final List facets = input.getFacets() != null && input.getFacets().size() > 0 ? input.getFacets() : null; + // do not include default facets if we're requesting any facets specifically + searchFlags.setIncludeDefaultFacets(facets == null || facets.size() <= 0); + List finalEntities = maybeResolvedView != null ? SearchUtils.intersectEntityTypes( diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/SearchAcrossEntitiesResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/SearchAcrossEntitiesResolver.java index d103704146d39..29bc3a82a1649 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/SearchAcrossEntitiesResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/SearchAcrossEntitiesResolver.java @@ -2,19 +2,28 @@ import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.bindArgument; import static com.linkedin.datahub.graphql.resolvers.search.SearchUtils.*; +import static com.linkedin.datahub.graphql.resolvers.search.SearchUtils.getEntityNames; +import com.google.common.collect.ImmutableList; import com.linkedin.common.urn.UrnUtils; import com.linkedin.datahub.graphql.QueryContext; import com.linkedin.datahub.graphql.concurrency.GraphQLConcurrencyUtils; +import com.linkedin.datahub.graphql.generated.EntityType; import com.linkedin.datahub.graphql.generated.SearchAcrossEntitiesInput; import com.linkedin.datahub.graphql.generated.SearchResults; import com.linkedin.datahub.graphql.resolvers.ResolverUtils; import com.linkedin.datahub.graphql.types.mappers.UrnSearchResultsMapper; import com.linkedin.entity.client.EntityClient; import com.linkedin.metadata.query.SearchFlags; +import com.linkedin.metadata.query.filter.Condition; +import com.linkedin.metadata.query.filter.ConjunctiveCriterion; +import com.linkedin.metadata.query.filter.ConjunctiveCriterionArray; +import com.linkedin.metadata.query.filter.CriterionArray; import com.linkedin.metadata.query.filter.Filter; import com.linkedin.metadata.query.filter.SortCriterion; +import com.linkedin.metadata.search.SearchResult; import com.linkedin.metadata.service.ViewService; +import com.linkedin.metadata.utils.CriterionUtils; import com.linkedin.view.DataHubViewInfo; import graphql.schema.DataFetcher; import graphql.schema.DataFetchingEnvironment; @@ -64,24 +73,7 @@ public CompletableFuture get(DataFetchingEnvironment environment) ResolverUtils.buildFilter(input.getFilters(), input.getOrFilters()); SearchFlags searchFlags = mapInputFlags(context, input.getSearchFlags()); - List sortCriteria; - if (input.getSortInput() != null) { - if (input.getSortInput().getSortCriteria() != null) { - sortCriteria = - input.getSortInput().getSortCriteria().stream() - .map(SearchUtils::mapSortCriterion) - .collect(Collectors.toList()); - } else { - sortCriteria = - input.getSortInput().getSortCriterion() != null - ? Collections.singletonList( - mapSortCriterion(input.getSortInput().getSortCriterion())) - : Collections.emptyList(); - } - - } else { - sortCriteria = Collections.emptyList(); - } + List sortCriteria = SearchUtils.getSortCriteria(input.getSortInput()); try { log.debug( @@ -101,6 +93,14 @@ public CompletableFuture get(DataFetchingEnvironment environment) return SearchUtils.createEmptySearchResults(start, count); } + boolean shouldIncludeStructuredPropertyFacets = + input.getSearchFlags() != null + && input.getSearchFlags().getIncludeStructuredPropertyFacets() != null + ? input.getSearchFlags().getIncludeStructuredPropertyFacets() + : false; + List structuredPropertyFacets = + shouldIncludeStructuredPropertyFacets ? getStructuredPropertyFacets(context) : null; + return UrnSearchResultsMapper.map( context, _entityClient.searchAcrossEntities( @@ -113,7 +113,8 @@ public CompletableFuture get(DataFetchingEnvironment environment) : baseFilter, start, count, - sortCriteria)); + sortCriteria, + structuredPropertyFacets)); } catch (Exception e) { log.error( "Failed to execute search for multiple entities: entity types {}, query {}, filters: {}, start: {}, count: {}", @@ -133,4 +134,45 @@ public CompletableFuture get(DataFetchingEnvironment environment) this.getClass().getSimpleName(), "get"); } + + private List getStructuredPropertyFacets(final QueryContext context) { + try { + SearchFlags searchFlags = new SearchFlags().setSkipCache(true); + SearchResult result = + _entityClient.searchAcrossEntities( + context.getOperationContext().withSearchFlags(flags -> searchFlags), + getEntityNames(ImmutableList.of(EntityType.STRUCTURED_PROPERTY)), + "*", + createStructuredPropertyFilter(), + 0, + 100, + Collections.emptyList(), + null); + return result.getEntities().stream() + .map(entity -> String.format("structuredProperties.%s", entity.getEntity().getId())) + .collect(Collectors.toList()); + } catch (Exception e) { + log.error("Failed to get structured property facets to filter on", e); + return Collections.emptyList(); + } + } + + private Filter createStructuredPropertyFilter() { + return new Filter() + .setOr( + new ConjunctiveCriterionArray( + ImmutableList.of( + new ConjunctiveCriterion() + .setAnd( + new CriterionArray( + ImmutableList.of( + CriterionUtils.buildCriterion( + "filterStatus", Condition.EQUAL, "ENABLED")))), + new ConjunctiveCriterion() + .setAnd( + new CriterionArray( + ImmutableList.of( + CriterionUtils.buildCriterion( + "showInSearchFilters", Condition.EQUAL, "true"))))))); + } } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/SearchUtils.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/SearchUtils.java index 04777c3fcdb4e..a01b3aaec9c98 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/SearchUtils.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/SearchUtils.java @@ -22,6 +22,7 @@ import com.linkedin.datahub.graphql.generated.EntityType; import com.linkedin.datahub.graphql.generated.FacetFilterInput; import com.linkedin.datahub.graphql.generated.SearchResults; +import com.linkedin.datahub.graphql.generated.SearchSortInput; import com.linkedin.datahub.graphql.types.common.mappers.SearchFlagsInputMapper; import com.linkedin.datahub.graphql.types.entitytype.EntityTypeMapper; import com.linkedin.metadata.query.SearchFlags; @@ -326,4 +327,25 @@ public static SearchResults createEmptySearchResults(final int start, final int result.setFacets(new ArrayList<>()); return result; } + + public static List getSortCriteria(@Nullable final SearchSortInput sortInput) { + List sortCriteria; + if (sortInput != null) { + if (sortInput.getSortCriteria() != null) { + sortCriteria = + sortInput.getSortCriteria().stream() + .map(SearchUtils::mapSortCriterion) + .collect(Collectors.toList()); + } else { + sortCriteria = + sortInput.getSortCriterion() != null + ? Collections.singletonList(mapSortCriterion(sortInput.getSortCriterion())) + : new ArrayList<>(); + } + } else { + sortCriteria = new ArrayList<>(); + } + + return sortCriteria; + } } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/CreateStructuredPropertyResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/CreateStructuredPropertyResolver.java index 328f63b893d06..7d232748f0d93 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/CreateStructuredPropertyResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/CreateStructuredPropertyResolver.java @@ -1,7 +1,8 @@ package com.linkedin.datahub.graphql.resolvers.structuredproperties; import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.bindArgument; -import static com.linkedin.metadata.Constants.STRUCTURED_PROPERTY_ENTITY_NAME; +import static com.linkedin.datahub.graphql.resolvers.mutate.MutationUtils.buildMetadataChangeProposalWithUrn; +import static com.linkedin.metadata.Constants.*; import com.linkedin.common.urn.Urn; import com.linkedin.data.template.SetMode; @@ -12,20 +13,24 @@ import com.linkedin.datahub.graphql.exception.AuthorizationException; import com.linkedin.datahub.graphql.generated.CreateStructuredPropertyInput; import com.linkedin.datahub.graphql.generated.StructuredPropertyEntity; +import com.linkedin.datahub.graphql.generated.StructuredPropertySettingsInput; import com.linkedin.datahub.graphql.types.structuredproperty.StructuredPropertyMapper; import com.linkedin.entity.EntityResponse; import com.linkedin.entity.client.EntityClient; import com.linkedin.metadata.aspect.patch.builder.StructuredPropertyDefinitionPatchBuilder; +import com.linkedin.metadata.models.StructuredPropertyUtils; import com.linkedin.metadata.utils.EntityKeyUtils; import com.linkedin.mxe.MetadataChangeProposal; import com.linkedin.structured.PrimitivePropertyValue; import com.linkedin.structured.PropertyCardinality; import com.linkedin.structured.PropertyValue; import com.linkedin.structured.StructuredPropertyKey; +import com.linkedin.structured.StructuredPropertySettings; import graphql.schema.DataFetcher; import graphql.schema.DataFetchingEnvironment; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; -import java.util.UUID; import java.util.concurrent.CompletableFuture; import javax.annotation.Nonnull; @@ -54,40 +59,28 @@ public CompletableFuture get(final DataFetchingEnviron "Unable to create structured property. Please contact your admin."); } final StructuredPropertyKey key = new StructuredPropertyKey(); - final String id = input.getId() != null ? input.getId() : UUID.randomUUID().toString(); + final String id = + StructuredPropertyUtils.getPropertyId(input.getId(), input.getQualifiedName()); key.setId(id); final Urn propertyUrn = EntityKeyUtils.convertEntityKeyToUrn(key, STRUCTURED_PROPERTY_ENTITY_NAME); - StructuredPropertyDefinitionPatchBuilder builder = - new StructuredPropertyDefinitionPatchBuilder().urn(propertyUrn); - - builder.setQualifiedName(input.getQualifiedName()); - builder.setValueType(input.getValueType()); - input.getEntityTypes().forEach(builder::addEntityType); - if (input.getDisplayName() != null) { - builder.setDisplayName(input.getDisplayName()); - } - if (input.getDescription() != null) { - builder.setDescription(input.getDescription()); - } - if (input.getImmutable() != null) { - builder.setImmutable(input.getImmutable()); - } - if (input.getTypeQualifier() != null) { - buildTypeQualifier(input, builder); - } - if (input.getAllowedValues() != null) { - buildAllowedValues(input, builder); + + if (_entityClient.exists(context.getOperationContext(), propertyUrn)) { + throw new IllegalArgumentException( + "A structured property already exists with this urn"); } - if (input.getCardinality() != null) { - builder.setCardinality( - PropertyCardinality.valueOf(input.getCardinality().toString())); + + List mcps = new ArrayList<>(); + + // first, create the property definition itself + mcps.add(createPropertyDefinition(context, propertyUrn, id, input)); + + // then add the settings aspect if we're adding any settings inputs + if (input.getSettings() != null) { + mcps.add(createPropertySettings(context, propertyUrn, input.getSettings())); } - builder.setCreated(context.getOperationContext().getAuditStamp()); - builder.setLastModified(context.getOperationContext().getAuditStamp()); - MetadataChangeProposal mcp = builder.build(); - _entityClient.ingestProposal(context.getOperationContext(), mcp, false); + _entityClient.batchIngestProposals(context.getOperationContext(), mcps, false); EntityResponse response = _entityClient.getV2( @@ -103,6 +96,72 @@ public CompletableFuture get(final DataFetchingEnviron }); } + private MetadataChangeProposal createPropertySettings( + @Nonnull final QueryContext context, + @Nonnull final Urn propertyUrn, + final StructuredPropertySettingsInput settingsInput) + throws Exception { + StructuredPropertySettings settings = new StructuredPropertySettings(); + + if (settingsInput.getIsHidden() != null) { + settings.setIsHidden(settingsInput.getIsHidden()); + } + if (settingsInput.getShowInSearchFilters() != null) { + settings.setShowInSearchFilters(settingsInput.getShowInSearchFilters()); + } + if (settingsInput.getShowInAssetSummary() != null) { + settings.setShowInAssetSummary(settingsInput.getShowInAssetSummary()); + } + if (settingsInput.getShowAsAssetBadge() != null) { + settings.setShowAsAssetBadge(settingsInput.getShowAsAssetBadge()); + } + if (settingsInput.getShowInColumnsTable() != null) { + settings.setShowInColumnsTable(settingsInput.getShowInColumnsTable()); + } + settings.setLastModified(context.getOperationContext().getAuditStamp()); + + StructuredPropertyUtils.validatePropertySettings(settings, true); + + return buildMetadataChangeProposalWithUrn( + propertyUrn, STRUCTURED_PROPERTY_SETTINGS_ASPECT_NAME, settings); + } + + private MetadataChangeProposal createPropertyDefinition( + @Nonnull final QueryContext context, + @Nonnull final Urn propertyUrn, + @Nonnull final String id, + final CreateStructuredPropertyInput input) + throws Exception { + StructuredPropertyDefinitionPatchBuilder builder = + new StructuredPropertyDefinitionPatchBuilder().urn(propertyUrn); + + builder.setQualifiedName(id); + builder.setValueType(input.getValueType()); + input.getEntityTypes().forEach(builder::addEntityType); + if (input.getDisplayName() != null) { + builder.setDisplayName(input.getDisplayName()); + } + if (input.getDescription() != null) { + builder.setDescription(input.getDescription()); + } + if (input.getImmutable() != null) { + builder.setImmutable(input.getImmutable()); + } + if (input.getTypeQualifier() != null) { + buildTypeQualifier(input, builder); + } + if (input.getAllowedValues() != null) { + buildAllowedValues(input, builder); + } + if (input.getCardinality() != null) { + builder.setCardinality(PropertyCardinality.valueOf(input.getCardinality().toString())); + } + builder.setCreated(context.getOperationContext().getAuditStamp()); + builder.setLastModified(context.getOperationContext().getAuditStamp()); + + return builder.build(); + } + private void buildTypeQualifier( @Nonnull final CreateStructuredPropertyInput input, @Nonnull final StructuredPropertyDefinitionPatchBuilder builder) { diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/DeleteStructuredPropertyResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/DeleteStructuredPropertyResolver.java index e7d59494654fd..58f8d340fcc07 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/DeleteStructuredPropertyResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/DeleteStructuredPropertyResolver.java @@ -6,6 +6,7 @@ import com.linkedin.common.urn.UrnUtils; import com.linkedin.datahub.graphql.QueryContext; import com.linkedin.datahub.graphql.authorization.AuthorizationUtils; +import com.linkedin.datahub.graphql.concurrency.GraphQLConcurrencyUtils; import com.linkedin.datahub.graphql.exception.AuthorizationException; import com.linkedin.datahub.graphql.generated.DeleteStructuredPropertyInput; import com.linkedin.entity.client.EntityClient; @@ -42,6 +43,23 @@ public CompletableFuture get(final DataFetchingEnvironment environment) "Unable to delete structured property. Please contact your admin."); } _entityClient.deleteEntity(context.getOperationContext(), propertyUrn); + // Asynchronously Delete all references to the entity (to return quickly) + GraphQLConcurrencyUtils.supplyAsync( + () -> { + try { + _entityClient.deleteEntityReferences( + context.getOperationContext(), propertyUrn); + } catch (Exception e) { + log.error( + String.format( + "Caught exception while attempting to clear all entity references for Structured Property with urn %s", + propertyUrn), + e); + } + return null; + }, + this.getClass().getSimpleName(), + "get"); return true; } catch (Exception e) { throw new RuntimeException( diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/RemoveStructuredPropertiesResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/RemoveStructuredPropertiesResolver.java index ea8c6dac36a4a..313e0a16d8916 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/RemoveStructuredPropertiesResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/RemoveStructuredPropertiesResolver.java @@ -93,7 +93,7 @@ public CompletableFuture get(final DataFetchingEnviron "Unable to update structured property. Please contact your admin."); } final Urn propertyUrn = UrnUtils.getUrn(input.getUrn()); - StructuredPropertyDefinition existingDefinition = + final EntityResponse entityResponse = getExistingStructuredProperty(context, propertyUrn); - StructuredPropertyDefinitionPatchBuilder builder = - new StructuredPropertyDefinitionPatchBuilder().urn(propertyUrn); - if (input.getDisplayName() != null) { - builder.setDisplayName(input.getDisplayName()); - } - if (input.getDescription() != null) { - builder.setDescription(input.getDescription()); - } - if (input.getImmutable() != null) { - builder.setImmutable(input.getImmutable()); - } - if (input.getTypeQualifier() != null) { - buildTypeQualifier(input, builder, existingDefinition); - } - if (input.getNewAllowedValues() != null) { - buildAllowedValues(input, builder); - } - if (input.getSetCardinalityAsMultiple() != null) { - builder.setCardinality(PropertyCardinality.MULTIPLE); + List mcps = new ArrayList<>(); + + // first update the definition aspect if we need to + MetadataChangeProposal definitionMcp = + updateDefinition(input, context, propertyUrn, entityResponse); + if (definitionMcp != null) { + mcps.add(definitionMcp); } - if (input.getNewEntityTypes() != null) { - input.getNewEntityTypes().forEach(builder::addEntityType); + + // then update the settings aspect if we need to + if (input.getSettings() != null) { + mcps.add(updateSettings(context, input.getSettings(), propertyUrn, entityResponse)); } - builder.setLastModified(context.getOperationContext().getAuditStamp()); - MetadataChangeProposal mcp = builder.build(); - _entityClient.ingestProposal(context.getOperationContext(), mcp, false); + _entityClient.batchIngestProposals(context.getOperationContext(), mcps, false); EntityResponse response = _entityClient.getV2( @@ -102,6 +95,120 @@ public CompletableFuture get(final DataFetchingEnviron }); } + private boolean hasSettingsChanged( + StructuredPropertySettings existingSettings, StructuredPropertySettingsInput settingsInput) { + if (settingsInput.getIsHidden() != null + && !existingSettings.isIsHidden().equals(settingsInput.getIsHidden())) { + return true; + } + if (settingsInput.getShowInSearchFilters() != null + && !existingSettings + .isShowInSearchFilters() + .equals(settingsInput.getShowInSearchFilters())) { + return true; + } + if (settingsInput.getShowInAssetSummary() != null + && !existingSettings.isShowInAssetSummary().equals(settingsInput.getShowInAssetSummary())) { + return true; + } + if (settingsInput.getShowAsAssetBadge() != null + && !existingSettings.isShowAsAssetBadge().equals(settingsInput.getShowAsAssetBadge())) { + return true; + } + if (settingsInput.getShowInColumnsTable() != null + && !existingSettings.isShowInColumnsTable().equals(settingsInput.getShowInColumnsTable())) { + return true; + } + return false; + } + + private MetadataChangeProposal updateSettings( + @Nonnull final QueryContext context, + @Nonnull final StructuredPropertySettingsInput settingsInput, + @Nonnull final Urn propertyUrn, + @Nonnull final EntityResponse entityResponse) + throws Exception { + StructuredPropertySettings existingSettings = + getExistingStructuredPropertySettings(entityResponse); + // check if settings has changed to determine if we should update the timestamp + boolean hasChanged = hasSettingsChanged(existingSettings, settingsInput); + if (hasChanged) { + existingSettings.setLastModified(context.getOperationContext().getAuditStamp()); + } + + if (settingsInput.getIsHidden() != null) { + existingSettings.setIsHidden(settingsInput.getIsHidden()); + } + if (settingsInput.getShowInSearchFilters() != null) { + existingSettings.setShowInSearchFilters(settingsInput.getShowInSearchFilters()); + } + if (settingsInput.getShowInAssetSummary() != null) { + existingSettings.setShowInAssetSummary(settingsInput.getShowInAssetSummary()); + } + if (settingsInput.getShowAsAssetBadge() != null) { + existingSettings.setShowAsAssetBadge(settingsInput.getShowAsAssetBadge()); + } + if (settingsInput.getShowInColumnsTable() != null) { + existingSettings.setShowInColumnsTable(settingsInput.getShowInColumnsTable()); + } + + StructuredPropertyUtils.validatePropertySettings(existingSettings, true); + + return buildMetadataChangeProposalWithUrn( + propertyUrn, STRUCTURED_PROPERTY_SETTINGS_ASPECT_NAME, existingSettings); + } + + private MetadataChangeProposal updateDefinition( + @Nonnull final UpdateStructuredPropertyInput input, + @Nonnull final QueryContext context, + @Nonnull final Urn propertyUrn, + @Nonnull final EntityResponse entityResponse) + throws Exception { + StructuredPropertyDefinition existingDefinition = + getExistingStructuredPropertyDefinition(entityResponse); + StructuredPropertyDefinitionPatchBuilder builder = + new StructuredPropertyDefinitionPatchBuilder().urn(propertyUrn); + + boolean hasUpdatedDefinition = false; + + if (input.getDisplayName() != null) { + builder.setDisplayName(input.getDisplayName()); + hasUpdatedDefinition = true; + } + if (input.getDescription() != null) { + builder.setDescription(input.getDescription()); + hasUpdatedDefinition = true; + } + if (input.getImmutable() != null) { + builder.setImmutable(input.getImmutable()); + hasUpdatedDefinition = true; + } + if (input.getTypeQualifier() != null) { + buildTypeQualifier(input, builder, existingDefinition); + hasUpdatedDefinition = true; + } + if (input.getNewAllowedValues() != null) { + buildAllowedValues(input, builder); + hasUpdatedDefinition = true; + } + if (input.getSetCardinalityAsMultiple() != null + && input.getSetCardinalityAsMultiple().equals(true)) { + builder.setCardinality(PropertyCardinality.MULTIPLE); + hasUpdatedDefinition = true; + } + if (input.getNewEntityTypes() != null) { + input.getNewEntityTypes().forEach(builder::addEntityType); + hasUpdatedDefinition = true; + } + + if (hasUpdatedDefinition) { + builder.setLastModified(context.getOperationContext().getAuditStamp()); + + return builder.build(); + } + return null; + } + private void buildTypeQualifier( @Nonnull final UpdateStructuredPropertyInput input, @Nonnull final StructuredPropertyDefinitionPatchBuilder builder, @@ -141,17 +248,40 @@ private void buildAllowedValues( }); } - private StructuredPropertyDefinition getExistingStructuredProperty( + private EntityResponse getExistingStructuredProperty( @Nonnull final QueryContext context, @Nonnull final Urn propertyUrn) throws Exception { - EntityResponse response = - _entityClient.getV2( - context.getOperationContext(), STRUCTURED_PROPERTY_ENTITY_NAME, propertyUrn, null); + return _entityClient.getV2( + context.getOperationContext(), STRUCTURED_PROPERTY_ENTITY_NAME, propertyUrn, null); + } + private StructuredPropertyDefinition getExistingStructuredPropertyDefinition( + EntityResponse response) throws Exception { if (response != null && response.getAspects().containsKey(STRUCTURED_PROPERTY_DEFINITION_ASPECT_NAME)) { return new StructuredPropertyDefinition( - response.getAspects().get(STRUCTURED_PROPERTY_DEFINITION_ASPECT_NAME).getValue().data()); + response + .getAspects() + .get(STRUCTURED_PROPERTY_DEFINITION_ASPECT_NAME) + .getValue() + .data() + .copy()); } - return null; + throw new IllegalArgumentException( + "Attempting to update a structured property with no definition aspect."); + } + + private StructuredPropertySettings getExistingStructuredPropertySettings(EntityResponse response) + throws Exception { + if (response != null + && response.getAspects().containsKey(STRUCTURED_PROPERTY_SETTINGS_ASPECT_NAME)) { + return new StructuredPropertySettings( + response + .getAspects() + .get(STRUCTURED_PROPERTY_SETTINGS_ASPECT_NAME) + .getValue() + .data() + .copy()); + } + return new StructuredPropertySettings(); } } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/UpsertStructuredPropertiesResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/UpsertStructuredPropertiesResolver.java index 770c8a0d749c3..6c1d7949332fb 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/UpsertStructuredPropertiesResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/UpsertStructuredPropertiesResolver.java @@ -103,7 +103,7 @@ public CompletableFuture chart.setStructuredProperties( - StructuredPropertiesMapper.map(context, new StructuredProperties(dataMap))))); + StructuredPropertiesMapper.map( + context, new StructuredProperties(dataMap), entityUrn)))); mappingHelper.mapToResult( FORMS_ASPECT_NAME, ((entity, dataMap) -> diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/container/mappers/ContainerMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/container/mappers/ContainerMapper.java index 02357b3ddc349..7ac00c46475bc 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/container/mappers/ContainerMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/container/mappers/ContainerMapper.java @@ -161,7 +161,9 @@ public static Container map( if (envelopedStructuredProps != null) { result.setStructuredProperties( StructuredPropertiesMapper.map( - context, new StructuredProperties(envelopedStructuredProps.getValue().data()))); + context, + new StructuredProperties(envelopedStructuredProps.getValue().data()), + entityUrn)); } final EnvelopedAspect envelopedForms = aspects.get(FORMS_ASPECT_NAME); diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpgroup/mappers/CorpGroupMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpgroup/mappers/CorpGroupMapper.java index 6246cf64bbf7f..010816431f54d 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpgroup/mappers/CorpGroupMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpgroup/mappers/CorpGroupMapper.java @@ -59,7 +59,8 @@ public CorpGroup apply( STRUCTURED_PROPERTIES_ASPECT_NAME, ((entity, dataMap) -> entity.setStructuredProperties( - StructuredPropertiesMapper.map(context, new StructuredProperties(dataMap))))); + StructuredPropertiesMapper.map( + context, new StructuredProperties(dataMap), entityUrn)))); mappingHelper.mapToResult( FORMS_ASPECT_NAME, ((entity, dataMap) -> diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpuser/mappers/CorpUserMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpuser/mappers/CorpUserMapper.java index 4fa278983399b..a94b555daebdf 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpuser/mappers/CorpUserMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpuser/mappers/CorpUserMapper.java @@ -88,7 +88,8 @@ public CorpUser apply( STRUCTURED_PROPERTIES_ASPECT_NAME, ((entity, dataMap) -> entity.setStructuredProperties( - StructuredPropertiesMapper.map(context, new StructuredProperties(dataMap))))); + StructuredPropertiesMapper.map( + context, new StructuredProperties(dataMap), entityUrn)))); mappingHelper.mapToResult( FORMS_ASPECT_NAME, ((entity, dataMap) -> diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dashboard/mappers/DashboardMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dashboard/mappers/DashboardMapper.java index 4fa52b1136564..fd1c7a5db2a79 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dashboard/mappers/DashboardMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dashboard/mappers/DashboardMapper.java @@ -142,7 +142,8 @@ public Dashboard apply( STRUCTURED_PROPERTIES_ASPECT_NAME, ((dashboard, dataMap) -> dashboard.setStructuredProperties( - StructuredPropertiesMapper.map(context, new StructuredProperties(dataMap))))); + StructuredPropertiesMapper.map( + context, new StructuredProperties(dataMap), entityUrn)))); mappingHelper.mapToResult( FORMS_ASPECT_NAME, ((entity, dataMap) -> diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataflow/mappers/DataFlowMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataflow/mappers/DataFlowMapper.java index 9e2612f60abda..44bc6a99eae4b 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataflow/mappers/DataFlowMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataflow/mappers/DataFlowMapper.java @@ -114,7 +114,8 @@ public DataFlow apply( STRUCTURED_PROPERTIES_ASPECT_NAME, ((entity, dataMap) -> entity.setStructuredProperties( - StructuredPropertiesMapper.map(context, new StructuredProperties(dataMap))))); + StructuredPropertiesMapper.map( + context, new StructuredProperties(dataMap), entityUrn)))); mappingHelper.mapToResult( FORMS_ASPECT_NAME, ((entity, dataMap) -> diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/datajob/mappers/DataJobMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/datajob/mappers/DataJobMapper.java index d7da875bc2a29..772871d77f217 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/datajob/mappers/DataJobMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/datajob/mappers/DataJobMapper.java @@ -135,7 +135,8 @@ public DataJob apply( result.setSubTypes(SubTypesMapper.map(context, new SubTypes(data))); } else if (STRUCTURED_PROPERTIES_ASPECT_NAME.equals(name)) { result.setStructuredProperties( - StructuredPropertiesMapper.map(context, new StructuredProperties(data))); + StructuredPropertiesMapper.map( + context, new StructuredProperties(data), entityUrn)); } else if (FORMS_ASPECT_NAME.equals(name)) { result.setForms(FormsMapper.map(new Forms(data), entityUrn.toString())); } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataproduct/mappers/DataProductMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataproduct/mappers/DataProductMapper.java index 08637dbfd01ed..8693ec97f1a2e 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataproduct/mappers/DataProductMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataproduct/mappers/DataProductMapper.java @@ -92,7 +92,8 @@ public DataProduct apply( STRUCTURED_PROPERTIES_ASPECT_NAME, ((entity, dataMap) -> entity.setStructuredProperties( - StructuredPropertiesMapper.map(context, new StructuredProperties(dataMap))))); + StructuredPropertiesMapper.map( + context, new StructuredProperties(dataMap), entityUrn)))); mappingHelper.mapToResult( FORMS_ASPECT_NAME, ((entity, dataMap) -> diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataset/mappers/DatasetMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataset/mappers/DatasetMapper.java index 0869463ba73ac..e411014c23c89 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataset/mappers/DatasetMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataset/mappers/DatasetMapper.java @@ -173,7 +173,8 @@ public Dataset apply( STRUCTURED_PROPERTIES_ASPECT_NAME, ((entity, dataMap) -> entity.setStructuredProperties( - StructuredPropertiesMapper.map(context, new StructuredProperties(dataMap))))); + StructuredPropertiesMapper.map( + context, new StructuredProperties(dataMap), entityUrn)))); mappingHelper.mapToResult( FORMS_ASPECT_NAME, ((dataset, dataMap) -> diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/domain/DomainMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/domain/DomainMapper.java index 7d05e0862a96d..ffcb94a0b7e29 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/domain/DomainMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/domain/DomainMapper.java @@ -71,7 +71,9 @@ public static Domain map(@Nullable QueryContext context, final EntityResponse en if (envelopedStructuredProps != null) { result.setStructuredProperties( StructuredPropertiesMapper.map( - context, new StructuredProperties(envelopedStructuredProps.getValue().data()))); + context, + new StructuredProperties(envelopedStructuredProps.getValue().data()), + entityUrn)); } final EnvelopedAspect envelopedForms = aspects.get(FORMS_ASPECT_NAME); diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/glossary/mappers/GlossaryNodeMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/glossary/mappers/GlossaryNodeMapper.java index 4912d18614f41..a694b62999080 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/glossary/mappers/GlossaryNodeMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/glossary/mappers/GlossaryNodeMapper.java @@ -59,7 +59,8 @@ public GlossaryNode apply( STRUCTURED_PROPERTIES_ASPECT_NAME, ((entity, dataMap) -> entity.setStructuredProperties( - StructuredPropertiesMapper.map(context, new StructuredProperties(dataMap))))); + StructuredPropertiesMapper.map( + context, new StructuredProperties(dataMap), entityUrn)))); mappingHelper.mapToResult( FORMS_ASPECT_NAME, ((entity, dataMap) -> diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/glossary/mappers/GlossaryTermMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/glossary/mappers/GlossaryTermMapper.java index 1274646f45ec4..e309ffad84df5 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/glossary/mappers/GlossaryTermMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/glossary/mappers/GlossaryTermMapper.java @@ -90,7 +90,8 @@ public GlossaryTerm apply( STRUCTURED_PROPERTIES_ASPECT_NAME, ((entity, dataMap) -> entity.setStructuredProperties( - StructuredPropertiesMapper.map(context, new StructuredProperties(dataMap))))); + StructuredPropertiesMapper.map( + context, new StructuredProperties(dataMap), entityUrn)))); mappingHelper.mapToResult( FORMS_ASPECT_NAME, ((entity, dataMap) -> diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mappers/MapperUtils.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mappers/MapperUtils.java index 0d69e62c621a6..8fe58df2d2ede 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mappers/MapperUtils.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mappers/MapperUtils.java @@ -70,6 +70,9 @@ public static FacetMetadata mapFacet( aggregationFacets.stream() .map(facet -> facet.equals("entity") || facet.contains("_entityType")) .collect(Collectors.toList()); + if (aggregationMetadata.getEntity() != null) { + facetMetadata.setEntity(UrnToEntityMapper.map(context, aggregationMetadata.getEntity())); + } facetMetadata.setField(aggregationMetadata.getName()); facetMetadata.setDisplayName( Optional.ofNullable(aggregationMetadata.getDisplayName()) diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLFeatureMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLFeatureMapper.java index a4f3aa7a0e226..d5eb1a15624dc 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLFeatureMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLFeatureMapper.java @@ -115,7 +115,8 @@ public MLFeature apply( STRUCTURED_PROPERTIES_ASPECT_NAME, ((mlFeature, dataMap) -> mlFeature.setStructuredProperties( - StructuredPropertiesMapper.map(context, new StructuredProperties(dataMap))))); + StructuredPropertiesMapper.map( + context, new StructuredProperties(dataMap), entityUrn)))); mappingHelper.mapToResult( FORMS_ASPECT_NAME, ((entity, dataMap) -> diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLFeatureTableMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLFeatureTableMapper.java index 30bf4dda1cf4f..51d3004d97a61 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLFeatureTableMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLFeatureTableMapper.java @@ -117,7 +117,8 @@ public MLFeatureTable apply( STRUCTURED_PROPERTIES_ASPECT_NAME, ((mlFeatureTable, dataMap) -> mlFeatureTable.setStructuredProperties( - StructuredPropertiesMapper.map(context, new StructuredProperties(dataMap))))); + StructuredPropertiesMapper.map( + context, new StructuredProperties(dataMap), entityUrn)))); mappingHelper.mapToResult( FORMS_ASPECT_NAME, ((entity, dataMap) -> diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLModelGroupMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLModelGroupMapper.java index 7e99040e44c82..6e3da1c153392 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLModelGroupMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLModelGroupMapper.java @@ -112,7 +112,8 @@ public MLModelGroup apply( STRUCTURED_PROPERTIES_ASPECT_NAME, ((mlModelGroup, dataMap) -> mlModelGroup.setStructuredProperties( - StructuredPropertiesMapper.map(context, new StructuredProperties(dataMap))))); + StructuredPropertiesMapper.map( + context, new StructuredProperties(dataMap), entityUrn)))); mappingHelper.mapToResult( FORMS_ASPECT_NAME, ((entity, dataMap) -> diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLModelMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLModelMapper.java index a3bc5c663c89a..7102fd4aed974 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLModelMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLModelMapper.java @@ -174,7 +174,8 @@ public MLModel apply( STRUCTURED_PROPERTIES_ASPECT_NAME, ((dataset, dataMap) -> dataset.setStructuredProperties( - StructuredPropertiesMapper.map(context, new StructuredProperties(dataMap))))); + StructuredPropertiesMapper.map( + context, new StructuredProperties(dataMap), entityUrn)))); mappingHelper.mapToResult( FORMS_ASPECT_NAME, ((entity, dataMap) -> diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLPrimaryKeyMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLPrimaryKeyMapper.java index 36784f96ea30e..c446c892cb223 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLPrimaryKeyMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLPrimaryKeyMapper.java @@ -112,7 +112,8 @@ public MLPrimaryKey apply( STRUCTURED_PROPERTIES_ASPECT_NAME, ((entity, dataMap) -> entity.setStructuredProperties( - StructuredPropertiesMapper.map(context, new StructuredProperties(dataMap))))); + StructuredPropertiesMapper.map( + context, new StructuredProperties(dataMap), entityUrn)))); mappingHelper.mapToResult( FORMS_ASPECT_NAME, ((entity, dataMap) -> diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/schemafield/SchemaFieldMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/schemafield/SchemaFieldMapper.java index b1f27357d4550..30eac54aede9b 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/schemafield/SchemaFieldMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/schemafield/SchemaFieldMapper.java @@ -41,7 +41,8 @@ public SchemaFieldEntity apply( STRUCTURED_PROPERTIES_ASPECT_NAME, ((schemaField, dataMap) -> schemaField.setStructuredProperties( - StructuredPropertiesMapper.map(context, new StructuredProperties(dataMap))))); + StructuredPropertiesMapper.map( + context, new StructuredProperties(dataMap), entityUrn)))); mappingHelper.mapToResult( BUSINESS_ATTRIBUTE_ASPECT, (((schemaField, dataMap) -> diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/structuredproperty/StructuredPropertiesMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/structuredproperty/StructuredPropertiesMapper.java index dc1ff7ca32971..4f155903c055b 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/structuredproperty/StructuredPropertiesMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/structuredproperty/StructuredPropertiesMapper.java @@ -25,23 +25,29 @@ public class StructuredPropertiesMapper { public static final StructuredPropertiesMapper INSTANCE = new StructuredPropertiesMapper(); public static com.linkedin.datahub.graphql.generated.StructuredProperties map( - @Nullable QueryContext context, @Nonnull final StructuredProperties structuredProperties) { - return INSTANCE.apply(context, structuredProperties); + @Nullable QueryContext context, + @Nonnull final StructuredProperties structuredProperties, + @Nonnull final Urn entityUrn) { + return INSTANCE.apply(context, structuredProperties, entityUrn); } public com.linkedin.datahub.graphql.generated.StructuredProperties apply( - @Nullable QueryContext context, @Nonnull final StructuredProperties structuredProperties) { + @Nullable QueryContext context, + @Nonnull final StructuredProperties structuredProperties, + @Nonnull final Urn entityUrn) { com.linkedin.datahub.graphql.generated.StructuredProperties result = new com.linkedin.datahub.graphql.generated.StructuredProperties(); result.setProperties( structuredProperties.getProperties().stream() - .map(p -> mapStructuredProperty(context, p)) + .map(p -> mapStructuredProperty(context, p, entityUrn)) .collect(Collectors.toList())); return result; } private StructuredPropertiesEntry mapStructuredProperty( - @Nullable QueryContext context, StructuredPropertyValueAssignment valueAssignment) { + @Nullable QueryContext context, + StructuredPropertyValueAssignment valueAssignment, + @Nonnull final Urn entityUrn) { StructuredPropertiesEntry entry = new StructuredPropertiesEntry(); entry.setStructuredProperty(createStructuredPropertyEntity(valueAssignment)); final List values = new ArrayList<>(); @@ -58,6 +64,7 @@ private StructuredPropertiesEntry mapStructuredProperty( }); entry.setValues(values); entry.setValueEntities(entities); + entry.setAssociatedUrn(entityUrn.toString()); return entry; } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/structuredproperty/StructuredPropertyMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/structuredproperty/StructuredPropertyMapper.java index c539c65118ac6..5dc73d9ad0938 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/structuredproperty/StructuredPropertyMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/structuredproperty/StructuredPropertyMapper.java @@ -17,6 +17,7 @@ import com.linkedin.datahub.graphql.generated.StringValue; import com.linkedin.datahub.graphql.generated.StructuredPropertyDefinition; import com.linkedin.datahub.graphql.generated.StructuredPropertyEntity; +import com.linkedin.datahub.graphql.generated.StructuredPropertySettings; import com.linkedin.datahub.graphql.generated.TypeQualifier; import com.linkedin.datahub.graphql.types.common.mappers.util.MappingHelper; import com.linkedin.datahub.graphql.types.mappers.MapperUtils; @@ -55,6 +56,8 @@ public StructuredPropertyEntity apply( MappingHelper mappingHelper = new MappingHelper<>(aspectMap, result); mappingHelper.mapToResult( STRUCTURED_PROPERTY_DEFINITION_ASPECT_NAME, (this::mapStructuredPropertyDefinition)); + mappingHelper.mapToResult( + STRUCTURED_PROPERTY_SETTINGS_ASPECT_NAME, (this::mapStructuredPropertySettings)); return mappingHelper.getResult(); } @@ -112,6 +115,21 @@ private List mapAllowedValues(@Nonnull PropertyValueArray gmsValue return allowedValues; } + private void mapStructuredPropertySettings( + @Nonnull StructuredPropertyEntity extendedProperty, @Nonnull DataMap dataMap) { + com.linkedin.structured.StructuredPropertySettings gmsSettings = + new com.linkedin.structured.StructuredPropertySettings(dataMap); + StructuredPropertySettings settings = new StructuredPropertySettings(); + + settings.setIsHidden(gmsSettings.isIsHidden()); + settings.setShowInSearchFilters(gmsSettings.isShowInSearchFilters()); + settings.setShowInAssetSummary(gmsSettings.isShowInAssetSummary()); + settings.setShowAsAssetBadge(gmsSettings.isShowAsAssetBadge()); + settings.setShowInColumnsTable(gmsSettings.isShowInColumnsTable()); + + extendedProperty.setSettings(settings); + } + private DataTypeEntity createDataTypeEntity(final Urn dataTypeUrn) { final DataTypeEntity dataType = new DataTypeEntity(); dataType.setUrn(dataTypeUrn.toString()); diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/structuredproperty/StructuredPropertyType.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/structuredproperty/StructuredPropertyType.java index 22e161d320f21..e451e96a3e84d 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/structuredproperty/StructuredPropertyType.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/structuredproperty/StructuredPropertyType.java @@ -27,7 +27,8 @@ public class StructuredPropertyType implements com.linkedin.datahub.graphql.types.EntityType { public static final Set ASPECTS_TO_FETCH = - ImmutableSet.of(STRUCTURED_PROPERTY_DEFINITION_ASPECT_NAME); + ImmutableSet.of( + STRUCTURED_PROPERTY_DEFINITION_ASPECT_NAME, STRUCTURED_PROPERTY_SETTINGS_ASPECT_NAME); private final EntityClient _entityClient; @Override diff --git a/datahub-graphql-core/src/main/resources/app.graphql b/datahub-graphql-core/src/main/resources/app.graphql index 262d2384d84ad..2868890368723 100644 --- a/datahub-graphql-core/src/main/resources/app.graphql +++ b/datahub-graphql-core/src/main/resources/app.graphql @@ -156,6 +156,15 @@ type PlatformPrivileges { """ manageBusinessAttributes: Boolean! + """ + Whether the user can create, edit, and delete structured properties. + """ + manageStructuredProperties: Boolean! + + """ + Whether the user can view the manage structured properties page. + """ + viewStructuredPropertiesPage: Boolean! } """ @@ -517,6 +526,11 @@ type FeatureFlagsConfig { If turned on, all siblings will be separated with no way to get to a "combined" sibling view """ showSeparateSiblings: Boolean! + + """ + If turned on, show the manage structured properties tab in the govern dropdown + """ + showManageStructuredProperties: Boolean! } """ diff --git a/datahub-graphql-core/src/main/resources/properties.graphql b/datahub-graphql-core/src/main/resources/properties.graphql index 292381d064f36..ff20caa50bf03 100644 --- a/datahub-graphql-core/src/main/resources/properties.graphql +++ b/datahub-graphql-core/src/main/resources/properties.graphql @@ -49,6 +49,11 @@ type StructuredPropertyEntity implements Entity { """ definition: StructuredPropertyDefinition! + """ + Definition of this structured property including its name + """ + settings: StructuredPropertySettings + """ Granular API for querying edges extending from this entity """ @@ -117,6 +122,36 @@ type StructuredPropertyDefinition { lastModified: ResolvedAuditStamp } +""" +Settings specific to a structured property entity +""" +type StructuredPropertySettings { + """ + Whether or not this asset should be hidden in the main application + """ + isHidden: Boolean! + + """ + Whether or not this asset should be displayed as a search filter + """ + showInSearchFilters: Boolean! + + """ + Whether or not this asset should be displayed in the asset sidebar + """ + showInAssetSummary: Boolean! + + """ + Whether or not this asset should be displayed as an asset badge on other asset's headers + """ + showAsAssetBadge: Boolean! + + """ + Whether or not this asset should be displayed as a column in the schema field table in a Dataset's "Columns" tab. + """ + showInColumnsTable: Boolean! +} + """ An entry for an allowed value for a structured property """ @@ -202,6 +237,11 @@ type StructuredPropertiesEntry { The optional entities associated with the values if the values are entity urns """ valueEntities: [Entity] + + """ + The urn of the entity this property came from for tracking purposes e.g. when sibling nodes are merged together + """ + associatedUrn: String! } """ @@ -330,8 +370,9 @@ input CreateStructuredPropertyInput { """ The unique fully qualified name of this structured property, dot delimited. + This will be required to match the ID of this structured property. """ - qualifiedName: String! + qualifiedName: String """ The optional display name for this property @@ -375,6 +416,11 @@ input CreateStructuredPropertyInput { For example: ["urn:li:entityType:datahub.dataset"] """ entityTypes: [String!]! + + """ + Settings for this structured property + """ + settings: StructuredPropertySettingsInput } """ @@ -455,6 +501,11 @@ input UpdateStructuredPropertyInput { For backwards compatibility, this is append only. """ newEntityTypes: [String!] + + """ + Settings for this structured property + """ + settings: StructuredPropertySettingsInput } """ @@ -477,3 +528,34 @@ input DeleteStructuredPropertyInput { """ urn: String! } + +""" +Settings for a structured property +""" +input StructuredPropertySettingsInput { + """ + Whether or not this asset should be hidden in the main application + """ + isHidden: Boolean + + """ + Whether or not this asset should be displayed as a search filter + """ + showInSearchFilters: Boolean + + """ + Whether or not this asset should be displayed in the asset sidebar + """ + showInAssetSummary: Boolean + + """ + Whether or not this asset should be displayed as an asset badge on other asset's headers + """ + showAsAssetBadge: Boolean + + """ + Whether or not this asset should be displayed as a column in the schema field table in a Dataset's "Columns" tab. + """ + showInColumnsTable: Boolean +} + diff --git a/datahub-graphql-core/src/main/resources/search.graphql b/datahub-graphql-core/src/main/resources/search.graphql index d0f669f05f959..82bfb9ee26fc4 100644 --- a/datahub-graphql-core/src/main/resources/search.graphql +++ b/datahub-graphql-core/src/main/resources/search.graphql @@ -167,6 +167,11 @@ input SearchFlags { fields to include for custom Highlighting """ customHighlightingFields: [String!] + + """ + Whether or not to fetch and request for structured property facets when doing a search + """ + includeStructuredPropertyFacets: Boolean } """ @@ -872,6 +877,11 @@ type FacetMetadata { """ displayName: String + """ + Entity corresponding to the facet + """ + entity: Entity + """ Aggregated search result counts by value of the field """ diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/search/SearchAcrossEntitiesResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/search/SearchAcrossEntitiesResolverTest.java index 42768b8a2de21..89d218683e33e 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/search/SearchAcrossEntitiesResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/search/SearchAcrossEntitiesResolverTest.java @@ -471,7 +471,8 @@ private static EntityClient initMockEntityClient( Mockito.eq(filter), Mockito.eq(start), Mockito.eq(limit), - Mockito.eq(Collections.emptyList()))) + Mockito.eq(Collections.emptyList()), + Mockito.eq(null))) .thenReturn(result); return client; } @@ -496,7 +497,8 @@ private static void verifyMockEntityClient( Mockito.eq(filter), Mockito.eq(start), Mockito.eq(limit), - Mockito.eq(Collections.emptyList())); + Mockito.eq(Collections.emptyList()), + Mockito.eq(null)); } private static void verifyMockViewService(ViewService mockService, Urn viewUrn) { diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/CreateStructuredPropertyResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/CreateStructuredPropertyResolverTest.java index 72cdb78542e41..fec2251f92b63 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/CreateStructuredPropertyResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/CreateStructuredPropertyResolverTest.java @@ -10,11 +10,11 @@ import com.linkedin.datahub.graphql.QueryContext; import com.linkedin.datahub.graphql.generated.CreateStructuredPropertyInput; import com.linkedin.datahub.graphql.generated.StructuredPropertyEntity; +import com.linkedin.datahub.graphql.generated.StructuredPropertySettingsInput; import com.linkedin.entity.EntityResponse; import com.linkedin.entity.EnvelopedAspectMap; import com.linkedin.entity.client.EntityClient; import com.linkedin.metadata.Constants; -import com.linkedin.mxe.MetadataChangeProposal; import com.linkedin.r2.RemoteInvocationException; import graphql.schema.DataFetchingEnvironment; import java.util.ArrayList; @@ -36,7 +36,8 @@ public class CreateStructuredPropertyResolverTest { null, null, null, - new ArrayList<>()); + new ArrayList<>(), + null); @Test public void testGetSuccess() throws Exception { @@ -56,7 +57,40 @@ public void testGetSuccess() throws Exception { // Validate that we called ingest Mockito.verify(mockEntityClient, Mockito.times(1)) - .ingestProposal(any(), any(MetadataChangeProposal.class), Mockito.eq(false)); + .batchIngestProposals(any(), Mockito.anyList(), Mockito.eq(false)); + } + + @Test + public void testGetMismatchIdAndQualifiedName() throws Exception { + EntityClient mockEntityClient = initMockEntityClient(true); + CreateStructuredPropertyResolver resolver = + new CreateStructuredPropertyResolver(mockEntityClient); + + CreateStructuredPropertyInput testInput = + new CreateStructuredPropertyInput( + "mismatched", + "io.acryl.test", + "Display Name", + "description", + true, + null, + null, + null, + null, + new ArrayList<>(), + null); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(testInput); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + + // Validate ingest is not called + Mockito.verify(mockEntityClient, Mockito.times(0)) + .batchIngestProposals(any(), Mockito.anyList(), Mockito.eq(false)); } @Test @@ -75,7 +109,7 @@ public void testGetUnauthorized() throws Exception { // Validate that we did NOT call ingest Mockito.verify(mockEntityClient, Mockito.times(0)) - .ingestProposal(any(), any(MetadataChangeProposal.class), Mockito.eq(false)); + .batchIngestProposals(any(), Mockito.anyList(), Mockito.eq(false)); } @Test @@ -94,7 +128,83 @@ public void testGetFailure() throws Exception { // Validate that ingest was called, but that caused a failure Mockito.verify(mockEntityClient, Mockito.times(1)) - .ingestProposal(any(), any(MetadataChangeProposal.class), Mockito.eq(false)); + .batchIngestProposals(any(), Mockito.anyList(), Mockito.eq(false)); + } + + @Test + public void testGetInvalidSettingsInput() throws Exception { + EntityClient mockEntityClient = initMockEntityClient(true); + CreateStructuredPropertyResolver resolver = + new CreateStructuredPropertyResolver(mockEntityClient); + + // if isHidden is true, other fields should not be true + StructuredPropertySettingsInput settingsInput = new StructuredPropertySettingsInput(); + settingsInput.setIsHidden(true); + settingsInput.setShowAsAssetBadge(true); + + CreateStructuredPropertyInput testInput = + new CreateStructuredPropertyInput( + null, + "io.acryl.test", + "Display Name", + "description", + true, + null, + null, + null, + null, + new ArrayList<>(), + settingsInput); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(testInput); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + + // Validate ingest is not called + Mockito.verify(mockEntityClient, Mockito.times(0)) + .batchIngestProposals(any(), Mockito.anyList(), Mockito.eq(false)); + } + + @Test + public void testGetSuccessWithSettings() throws Exception { + EntityClient mockEntityClient = initMockEntityClient(true); + CreateStructuredPropertyResolver resolver = + new CreateStructuredPropertyResolver(mockEntityClient); + + StructuredPropertySettingsInput settingsInput = new StructuredPropertySettingsInput(); + settingsInput.setShowAsAssetBadge(true); + + CreateStructuredPropertyInput testInput = + new CreateStructuredPropertyInput( + null, + "io.acryl.test", + "Display Name", + "description", + true, + null, + null, + null, + null, + new ArrayList<>(), + settingsInput); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(testInput); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + StructuredPropertyEntity prop = resolver.get(mockEnv).get(); + + assertEquals(prop.getUrn(), TEST_STRUCTURED_PROPERTY_URN); + + // Validate that we called ingest + Mockito.verify(mockEntityClient, Mockito.times(1)) + .batchIngestProposals(any(), Mockito.anyList(), Mockito.eq(false)); } private EntityClient initMockEntityClient(boolean shouldSucceed) throws Exception { diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/DeleteStructuredPropertyResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/DeleteStructuredPropertyResolverTest.java new file mode 100644 index 0000000000000..7ecec25708f2d --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/DeleteStructuredPropertyResolverTest.java @@ -0,0 +1,91 @@ +package com.linkedin.datahub.graphql.resolvers.structuredproperties; + +import static com.linkedin.datahub.graphql.TestUtils.getMockAllowContext; +import static com.linkedin.datahub.graphql.TestUtils.getMockDenyContext; +import static org.mockito.ArgumentMatchers.any; +import static org.testng.Assert.assertThrows; +import static org.testng.Assert.assertTrue; + +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.DeleteStructuredPropertyInput; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.r2.RemoteInvocationException; +import graphql.schema.DataFetchingEnvironment; +import java.util.concurrent.CompletionException; +import org.mockito.Mockito; +import org.testng.annotations.Test; + +public class DeleteStructuredPropertyResolverTest { + private static final String TEST_PROP_URN = "urn:li:structuredProperty:test"; + + private static final DeleteStructuredPropertyInput TEST_INPUT = + new DeleteStructuredPropertyInput(TEST_PROP_URN); + + @Test + public void testGetSuccess() throws Exception { + EntityClient mockEntityClient = initMockEntityClient(true); + DeleteStructuredPropertyResolver resolver = + new DeleteStructuredPropertyResolver(mockEntityClient); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + Boolean success = resolver.get(mockEnv).get(); + assertTrue(success); + + // Validate that we called delete + Mockito.verify(mockEntityClient, Mockito.times(1)) + .deleteEntity(any(), Mockito.eq(UrnUtils.getUrn(TEST_PROP_URN))); + } + + @Test + public void testGetUnauthorized() throws Exception { + EntityClient mockEntityClient = initMockEntityClient(true); + DeleteStructuredPropertyResolver resolver = + new DeleteStructuredPropertyResolver(mockEntityClient); + + // Execute resolver + QueryContext mockContext = getMockDenyContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + + // Validate that we did NOT call delete + Mockito.verify(mockEntityClient, Mockito.times(0)) + .deleteEntity(any(), Mockito.eq(UrnUtils.getUrn(TEST_PROP_URN))); + } + + @Test + public void testGetFailure() throws Exception { + EntityClient mockEntityClient = initMockEntityClient(false); + DeleteStructuredPropertyResolver resolver = + new DeleteStructuredPropertyResolver(mockEntityClient); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + + // Validate that deleteEntity was called, but since it's the thing that failed it was called + // once still + Mockito.verify(mockEntityClient, Mockito.times(1)) + .deleteEntity(any(), Mockito.eq(UrnUtils.getUrn(TEST_PROP_URN))); + } + + private EntityClient initMockEntityClient(boolean shouldSucceed) throws Exception { + EntityClient client = Mockito.mock(EntityClient.class); + if (!shouldSucceed) { + Mockito.doThrow(new RemoteInvocationException()).when(client).deleteEntity(any(), any()); + } + return client; + } +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/StructuredPropertyUtilsTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/StructuredPropertyUtilsTest.java new file mode 100644 index 0000000000000..0e9d064b3c7af --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/StructuredPropertyUtilsTest.java @@ -0,0 +1,42 @@ +package com.linkedin.datahub.graphql.resolvers.structuredproperties; + +import static org.testng.Assert.*; + +import com.linkedin.metadata.models.StructuredPropertyUtils; +import java.util.UUID; +import org.testng.annotations.Test; + +public class StructuredPropertyUtilsTest { + + @Test + public void testGetIdMismatchedInput() throws Exception { + assertThrows( + IllegalArgumentException.class, + () -> StructuredPropertyUtils.getPropertyId("test1", "test2")); + } + + @Test + public void testGetIdConsistentInput() throws Exception { + assertEquals(StructuredPropertyUtils.getPropertyId("test1", "test1"), "test1"); + } + + @Test + public void testGetIdNullQualifiedName() throws Exception { + assertEquals(StructuredPropertyUtils.getPropertyId("test1", null), "test1"); + } + + @Test + public void testGetIdNullId() throws Exception { + assertEquals(StructuredPropertyUtils.getPropertyId(null, "test1"), "test1"); + } + + @Test + public void testGetIdNullForBoth() throws Exception { + try { + String id = StructuredPropertyUtils.getPropertyId(null, null); + UUID.fromString(id); + } catch (Exception e) { + fail("ID produced is not a UUID"); + } + } +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/UpdateStructuredPropertyResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/UpdateStructuredPropertyResolverTest.java index b818bcfb7d7f4..2b0e7fd83b7ce 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/UpdateStructuredPropertyResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/UpdateStructuredPropertyResolverTest.java @@ -2,20 +2,25 @@ import static com.linkedin.datahub.graphql.TestUtils.getMockAllowContext; import static com.linkedin.datahub.graphql.TestUtils.getMockDenyContext; +import static com.linkedin.metadata.Constants.STRUCTURED_PROPERTY_DEFINITION_ASPECT_NAME; import static org.mockito.ArgumentMatchers.any; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertThrows; +import com.linkedin.common.UrnArray; import com.linkedin.common.urn.UrnUtils; import com.linkedin.datahub.graphql.QueryContext; import com.linkedin.datahub.graphql.generated.StructuredPropertyEntity; +import com.linkedin.datahub.graphql.generated.StructuredPropertySettingsInput; import com.linkedin.datahub.graphql.generated.UpdateStructuredPropertyInput; +import com.linkedin.entity.Aspect; import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.EnvelopedAspect; import com.linkedin.entity.EnvelopedAspectMap; import com.linkedin.entity.client.EntityClient; import com.linkedin.metadata.Constants; -import com.linkedin.mxe.MetadataChangeProposal; import com.linkedin.r2.RemoteInvocationException; +import com.linkedin.structured.StructuredPropertyDefinition; import graphql.schema.DataFetchingEnvironment; import java.util.concurrent.CompletionException; import org.mockito.Mockito; @@ -33,6 +38,7 @@ public class UpdateStructuredPropertyResolverTest { null, null, null, + null, null); @Test @@ -53,7 +59,7 @@ public void testGetSuccess() throws Exception { // Validate that we called ingest Mockito.verify(mockEntityClient, Mockito.times(1)) - .ingestProposal(any(), any(MetadataChangeProposal.class), Mockito.eq(false)); + .batchIngestProposals(any(), Mockito.anyList(), Mockito.eq(false)); } @Test @@ -72,7 +78,7 @@ public void testGetUnauthorized() throws Exception { // Validate that we did NOT call ingest Mockito.verify(mockEntityClient, Mockito.times(0)) - .ingestProposal(any(), any(MetadataChangeProposal.class), Mockito.eq(false)); + .batchIngestProposals(any(), Mockito.anyList(), Mockito.eq(false)); } @Test @@ -91,7 +97,80 @@ public void testGetFailure() throws Exception { // Validate that ingest was not called since there was a get failure before ingesting Mockito.verify(mockEntityClient, Mockito.times(0)) - .ingestProposal(any(), any(MetadataChangeProposal.class), Mockito.eq(false)); + .batchIngestProposals(any(), Mockito.anyList(), Mockito.eq(false)); + } + + @Test + public void testGetInvalidSettingsInput() throws Exception { + EntityClient mockEntityClient = initMockEntityClient(true); + UpdateStructuredPropertyResolver resolver = + new UpdateStructuredPropertyResolver(mockEntityClient); + + // if isHidden is true, other fields should not be true + StructuredPropertySettingsInput settingsInput = new StructuredPropertySettingsInput(); + settingsInput.setIsHidden(true); + settingsInput.setShowInSearchFilters(true); + + final UpdateStructuredPropertyInput testInput = + new UpdateStructuredPropertyInput( + TEST_STRUCTURED_PROPERTY_URN, + "New Display Name", + "new description", + true, + null, + null, + null, + null, + settingsInput); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(testInput); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + + // Validate that ingest was not called since there was a get failure before ingesting + Mockito.verify(mockEntityClient, Mockito.times(0)) + .batchIngestProposals(any(), Mockito.anyList(), Mockito.eq(false)); + } + + @Test + public void testGetValidSettingsInput() throws Exception { + EntityClient mockEntityClient = initMockEntityClient(true); + UpdateStructuredPropertyResolver resolver = + new UpdateStructuredPropertyResolver(mockEntityClient); + + // if isHidden is true, other fields should not be true + StructuredPropertySettingsInput settingsInput = new StructuredPropertySettingsInput(); + settingsInput.setIsHidden(true); + + final UpdateStructuredPropertyInput testInput = + new UpdateStructuredPropertyInput( + TEST_STRUCTURED_PROPERTY_URN, + "New Display Name", + "new description", + true, + null, + null, + null, + null, + settingsInput); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(testInput); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + StructuredPropertyEntity prop = resolver.get(mockEnv).get(); + + assertEquals(prop.getUrn(), TEST_STRUCTURED_PROPERTY_URN); + + // Validate that we called ingest + Mockito.verify(mockEntityClient, Mockito.times(1)) + .batchIngestProposals(any(), Mockito.anyList(), Mockito.eq(false)); } private EntityClient initMockEntityClient(boolean shouldSucceed) throws Exception { @@ -99,7 +178,11 @@ private EntityClient initMockEntityClient(boolean shouldSucceed) throws Exceptio EntityResponse response = new EntityResponse(); response.setEntityName(Constants.STRUCTURED_PROPERTY_ENTITY_NAME); response.setUrn(UrnUtils.getUrn(TEST_STRUCTURED_PROPERTY_URN)); - response.setAspects(new EnvelopedAspectMap()); + final EnvelopedAspectMap aspectMap = new EnvelopedAspectMap(); + aspectMap.put( + STRUCTURED_PROPERTY_DEFINITION_ASPECT_NAME, + new EnvelopedAspect().setValue(new Aspect(createDefinition().data()))); + response.setAspects(aspectMap); if (shouldSucceed) { Mockito.when( client.getV2( @@ -120,4 +203,13 @@ private EntityClient initMockEntityClient(boolean shouldSucceed) throws Exceptio return client; } + + private StructuredPropertyDefinition createDefinition() { + StructuredPropertyDefinition definition = new StructuredPropertyDefinition(); + definition.setDisplayName("test"); + definition.setQualifiedName("test"); + definition.setValueType(UrnUtils.getUrn("urn:li:dataType:datahub.string")); + definition.setEntityTypes(new UrnArray()); + return definition; + } } diff --git a/datahub-web-react/src/Mocks.tsx b/datahub-web-react/src/Mocks.tsx index 329d6250e576a..73a789030ce6f 100644 --- a/datahub-web-react/src/Mocks.tsx +++ b/datahub-web-react/src/Mocks.tsx @@ -2204,7 +2204,7 @@ export const mocks = [ count: 10, filters: [], orFilters: [], - searchFlags: { getSuggestions: true }, + searchFlags: { getSuggestions: true, includeStructuredPropertyFacets: true }, }, }, }, @@ -2244,6 +2244,7 @@ export const mocks = [ field: 'origin', displayName: 'origin', aggregations: [{ value: 'PROD', count: 3, entity: null }], + entity: null, }, { field: '_entityType', @@ -2252,6 +2253,7 @@ export const mocks = [ { count: 37, entity: null, value: 'DATASET', __typename: 'AggregationMetadata' }, { count: 7, entity: null, value: 'CHART', __typename: 'AggregationMetadata' }, ], + entity: null, }, { field: 'platform', @@ -2261,6 +2263,7 @@ export const mocks = [ { value: 'MySQL', count: 1, entity: null }, { value: 'Kafka', count: 1, entity: null }, ], + entity: null, }, ], suggestions: [], @@ -2290,7 +2293,7 @@ export const mocks = [ ], }, ], - searchFlags: { getSuggestions: true }, + searchFlags: { getSuggestions: true, includeStructuredPropertyFacets: true }, }, }, }, @@ -2325,6 +2328,7 @@ export const mocks = [ entity: null, }, ], + entity: null, }, { field: '_entityType', @@ -2333,6 +2337,7 @@ export const mocks = [ { count: 37, entity: null, value: 'DATASET', __typename: 'AggregationMetadata' }, { count: 7, entity: null, value: 'CHART', __typename: 'AggregationMetadata' }, ], + entity: null, }, { __typename: 'FacetMetadata', @@ -2343,6 +2348,7 @@ export const mocks = [ { value: 'mysql', count: 1, entity: null }, { value: 'kafka', count: 1, entity: null }, ], + entity: null, }, ], suggestions: [], @@ -2393,6 +2399,7 @@ export const mocks = [ entity: null, }, ], + entity: null, }, { field: '_entityType', @@ -2401,6 +2408,7 @@ export const mocks = [ { count: 37, entity: null, value: 'DATASET', __typename: 'AggregationMetadata' }, { count: 7, entity: null, value: 'CHART', __typename: 'AggregationMetadata' }, ], + entity: null, }, { field: 'platform', @@ -2410,6 +2418,7 @@ export const mocks = [ { value: 'mysql', count: 1, entity: null }, { value: 'kafka', count: 1, entity: null }, ], + entity: null, }, ], }, @@ -2464,7 +2473,7 @@ export const mocks = [ ], }, ], - searchFlags: { getSuggestions: true }, + searchFlags: { getSuggestions: true, includeStructuredPropertyFacets: true }, }, }, }, @@ -2501,6 +2510,7 @@ export const mocks = [ entity: null, }, ], + entity: null, }, { __typename: 'FacetMetadata', @@ -2510,6 +2520,7 @@ export const mocks = [ { count: 37, entity: null, value: 'DATASET', __typename: 'AggregationMetadata' }, { count: 7, entity: null, value: 'CHART', __typename: 'AggregationMetadata' }, ], + entity: null, }, { __typename: 'FacetMetadata', @@ -2520,6 +2531,7 @@ export const mocks = [ { value: 'mysql', count: 1, entity: null, __typename: 'AggregationMetadata' }, { value: 'kafka', count: 1, entity: null, __typename: 'AggregationMetadata' }, ], + entity: null, }, ], }, @@ -2669,6 +2681,7 @@ export const mocks = [ entity: null, }, ], + entity: null, }, { field: '_entityType', @@ -2677,6 +2690,7 @@ export const mocks = [ { count: 37, entity: null, value: 'DATASET', __typename: 'AggregationMetadata' }, { count: 7, entity: null, value: 'CHART', __typename: 'AggregationMetadata' }, ], + entity: null, }, { field: 'platform', @@ -2686,6 +2700,7 @@ export const mocks = [ { value: 'mysql', count: 1, entity: null }, { value: 'kafka', count: 1, entity: null }, ], + entity: null, }, ], }, @@ -2743,6 +2758,7 @@ export const mocks = [ entity: null, }, ], + entity: null, }, { field: '_entityType', @@ -2751,6 +2767,7 @@ export const mocks = [ { count: 37, entity: null, value: 'DATASET', __typename: 'AggregationMetadata' }, { count: 7, entity: null, value: 'CHART', __typename: 'AggregationMetadata' }, ], + entity: null, }, { field: 'platform', @@ -2760,6 +2777,7 @@ export const mocks = [ { value: 'mysql', count: 1, entity: null }, { value: 'kafka', count: 1, entity: null }, ], + entity: null, }, ], }, @@ -2809,6 +2827,7 @@ export const mocks = [ entity: null, }, ], + entity: null, }, { field: 'platform', @@ -2822,6 +2841,7 @@ export const mocks = [ { value: 'mysql', count: 1, entity: null }, { value: 'kafka', count: 1, entity: null }, ], + entity: null, }, ], }, @@ -2953,6 +2973,7 @@ export const mocks = [ entity: null, }, ], + entity: null, }, { field: 'platform', @@ -2966,6 +2987,7 @@ export const mocks = [ { value: 'mysql', count: 1, entity: null }, { value: 'kafka', count: 1, entity: null }, ], + entity: null, }, ], }, @@ -3013,7 +3035,7 @@ export const mocks = [ ], }, ], - searchFlags: { getSuggestions: true }, + searchFlags: { getSuggestions: true, includeStructuredPropertyFacets: true }, }, }, }, @@ -3050,6 +3072,7 @@ export const mocks = [ entity: null, }, ], + entity: null, }, // { // displayName: 'Domain', @@ -3071,6 +3094,7 @@ export const mocks = [ { count: 37, entity: null, value: 'DATASET', __typename: 'AggregationMetadata' }, { count: 7, entity: null, value: 'CHART', __typename: 'AggregationMetadata' }, ], + entity: null, }, { __typename: 'FacetMetadata', @@ -3096,6 +3120,7 @@ export const mocks = [ entity: null, }, ], + entity: null, }, ], }, @@ -3181,7 +3206,7 @@ export const mocks = [ ], }, ], - searchFlags: { getSuggestions: true }, + searchFlags: { getSuggestions: true, includeStructuredPropertyFacets: true }, }, }, }, @@ -3215,6 +3240,7 @@ export const mocks = [ entity: null, }, ], + entity: null, }, { field: 'platform', @@ -3228,6 +3254,7 @@ export const mocks = [ { value: 'mysql', count: 1, entity: null }, { value: 'kafka', count: 1, entity: null }, ], + entity: null, }, ], }, @@ -3256,7 +3283,7 @@ export const mocks = [ ], }, ], - searchFlags: { getSuggestions: true }, + searchFlags: { getSuggestions: true, includeStructuredPropertyFacets: true }, }, }, }, @@ -3290,6 +3317,7 @@ export const mocks = [ entity: null, }, ], + entity: null, }, { field: '_entityType', @@ -3298,6 +3326,7 @@ export const mocks = [ { count: 37, entity: null, value: 'DATASET', __typename: 'AggregationMetadata' }, { count: 7, entity: null, value: 'CHART', __typename: 'AggregationMetadata' }, ], + entity: null, }, { field: 'platform', @@ -3307,6 +3336,7 @@ export const mocks = [ { value: 'mysql', count: 1, entity: null }, { value: 'kafka', count: 1, entity: null }, ], + entity: null, }, ], }, @@ -3335,7 +3365,7 @@ export const mocks = [ ], }, ], - searchFlags: { getSuggestions: true }, + searchFlags: { getSuggestions: true, includeStructuredPropertyFacets: true }, }, }, }, @@ -3377,6 +3407,7 @@ export const mocks = [ entity: null, }, ], + entity: null, }, { field: '_entityType', @@ -3385,6 +3416,7 @@ export const mocks = [ { count: 37, entity: null, value: 'DATASET', __typename: 'AggregationMetadata' }, { count: 7, entity: null, value: 'CHART', __typename: 'AggregationMetadata' }, ], + entity: null, }, { field: 'platform', @@ -3394,6 +3426,7 @@ export const mocks = [ { value: 'mysql', count: 1, entity: null }, { value: 'kafka', count: 1, entity: null }, ], + entity: null, }, ], }, @@ -3428,7 +3461,7 @@ export const mocks = [ ], }, ], - searchFlags: { getSuggestions: true }, + searchFlags: { getSuggestions: true, includeStructuredPropertyFacets: true }, }, }, }, @@ -3465,6 +3498,7 @@ export const mocks = [ entity: null, }, ], + entity: null, }, { __typename: 'FacetMetadata', @@ -3474,6 +3508,7 @@ export const mocks = [ { count: 37, entity: null, value: 'DATASET', __typename: 'AggregationMetadata' }, { count: 7, entity: null, value: 'CHART', __typename: 'AggregationMetadata' }, ], + entity: null, }, { __typename: 'FacetMetadata', @@ -3484,6 +3519,7 @@ export const mocks = [ { value: 'mysql', count: 1, entity: null, __typename: 'AggregationMetadata' }, { value: 'kafka', count: 1, entity: null, __typename: 'AggregationMetadata' }, ], + entity: null, }, ], }, @@ -3518,7 +3554,7 @@ export const mocks = [ ], }, ], - searchFlags: { getSuggestions: true }, + searchFlags: { getSuggestions: true, includeStructuredPropertyFacets: true }, }, }, }, @@ -3555,6 +3591,7 @@ export const mocks = [ __typename: 'AggregationMetadata', }, ], + entity: null, }, { __typename: 'FacetMetadata', @@ -3564,6 +3601,7 @@ export const mocks = [ { count: 37, entity: null, value: 'DATASET', __typename: 'AggregationMetadata' }, { count: 7, entity: null, value: 'CHART', __typename: 'AggregationMetadata' }, ], + entity: null, }, { __typename: 'FacetMetadata', @@ -3574,6 +3612,7 @@ export const mocks = [ { value: 'mysql', count: 1, entity: null, __typename: 'AggregationMetadata' }, { value: 'kafka', count: 1, entity: null, __typename: 'AggregationMetadata' }, ], + entity: null, }, ], }, @@ -3635,6 +3674,8 @@ export const mocks = [ manageGlobalAnnouncements: true, createBusinessAttributes: true, manageBusinessAttributes: true, + manageStructuredProperties: true, + viewStructuredPropertiesPage: true, }, }, }, @@ -3722,7 +3763,7 @@ export const mocks = [ count: 10, filters: [], orFilters: [], - searchFlags: { getSuggestions: true }, + searchFlags: { getSuggestions: true, includeStructuredPropertyFacets: true }, }, }, }, @@ -3821,6 +3862,7 @@ export const mocks = [ entity: null, }, ], + entity: null, }, { field: '_entityType', @@ -3829,6 +3871,7 @@ export const mocks = [ { count: 37, entity: null, value: 'DATASET', __typename: 'AggregationMetadata' }, { count: 7, entity: null, value: 'CHART', __typename: 'AggregationMetadata' }, ], + entity: null, }, { field: 'platform', @@ -3838,6 +3881,7 @@ export const mocks = [ { value: 'mysql', count: 1, entity: null }, { value: 'kafka', count: 1, entity: null }, ], + entity: null, }, ], }, @@ -3912,4 +3956,6 @@ export const platformPrivileges: PlatformPrivileges = { manageGlobalAnnouncements: true, createBusinessAttributes: true, manageBusinessAttributes: true, + manageStructuredProperties: true, + viewStructuredPropertiesPage: true, }; diff --git a/datahub-web-react/src/alchemy-components/components/SearchBar/SearchBar.stories.tsx b/datahub-web-react/src/alchemy-components/components/SearchBar/SearchBar.stories.tsx new file mode 100644 index 0000000000000..9ae34356a71d6 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/SearchBar/SearchBar.stories.tsx @@ -0,0 +1,99 @@ +import React, { useState } from 'react'; +import { BADGE } from '@geometricpanda/storybook-addon-badges'; +import type { Meta, StoryObj } from '@storybook/react'; +import { GridList } from '@components/.docs/mdx-components'; +import { SearchBar, searchBarDefaults } from './SearchBar'; +import { SearchBarProps } from './types'; + +const meta = { + title: 'Components / Search Bar', + component: SearchBar, + + // Display Properties + parameters: { + layout: 'centered', + badges: [BADGE.STABLE, 'readyForDesignReview'], + docs: { + subtitle: 'A component that is used to get search bar', + }, + }, + + // Component-level argTypes + argTypes: { + placeholder: { + description: 'Placeholder of search bar.', + table: { + defaultValue: { summary: searchBarDefaults.placeholder }, + }, + control: { + type: 'text', + }, + }, + value: { + description: 'Value of the search bar.', + table: { + defaultValue: { summary: searchBarDefaults.value }, + }, + control: false, + }, + width: { + description: 'Width of the search bar.', + table: { + defaultValue: { summary: searchBarDefaults.width }, + }, + control: { + type: 'text', + }, + }, + allowClear: { + description: 'Whether clear button should be present.', + table: { + defaultValue: { summary: searchBarDefaults.allowClear?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + onChange: { + description: 'On change function for the search bar.', + }, + }, + + // Define defaults + args: { + placeholder: searchBarDefaults.placeholder, + value: searchBarDefaults.value, + allowClear: searchBarDefaults.allowClear, + width: searchBarDefaults.width, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const SandboxWrapper = (props: SearchBarProps) => { + const [value, setValue] = useState(''); + + const handleChange = (newValue: string) => { + setValue(newValue); + }; + + return ; +}; + +export const sandbox: Story = { + tags: ['dev'], + render: (props) => { + return ; + }, +}; + +export const customWidths = () => ( + + + + + + +); diff --git a/datahub-web-react/src/alchemy-components/components/SearchBar/SearchBar.tsx b/datahub-web-react/src/alchemy-components/components/SearchBar/SearchBar.tsx new file mode 100644 index 0000000000000..f39f761058d8c --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/SearchBar/SearchBar.tsx @@ -0,0 +1,30 @@ +import { SearchOutlined } from '@ant-design/icons'; +import React from 'react'; +import { StyledSearchBar } from './components'; +import { SearchBarProps } from './types'; + +export const searchBarDefaults: SearchBarProps = { + placeholder: 'Search..', + value: '', + width: '272px', + allowClear: true, +}; + +export const SearchBar = ({ + placeholder = searchBarDefaults.placeholder, + value = searchBarDefaults.value, + width = searchBarDefaults.width, + allowClear = searchBarDefaults.allowClear, + onChange, +}: SearchBarProps) => { + return ( + onChange?.(e.target.value)} + value={value} + prefix={} + allowClear={allowClear} + $width={width} + /> + ); +}; diff --git a/datahub-web-react/src/alchemy-components/components/SearchBar/components.ts b/datahub-web-react/src/alchemy-components/components/SearchBar/components.ts new file mode 100644 index 0000000000000..7045801ddf092 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/SearchBar/components.ts @@ -0,0 +1,33 @@ +import { colors, typography } from '@src/alchemy-components/theme'; +import { Input } from 'antd'; +import styled from 'styled-components'; + +export const StyledSearchBar = styled(Input)<{ $width?: string }>` + height: 40px; + width: ${(props) => props.$width}; + display: flex; + align-items: center; + border-radius: 8px; + + input { + color: ${colors.gray[600]}; + font-size: ${typography.fontSizes.md} !important; + } + + .ant-input-prefix { + width: 20px; + color: ${colors.gray[1800]}; + + svg { + height: 16px; + width: 16px; + } + } + + &:hover, + &:focus, + &:focus-within { + border-color: ${colors.violet[300]} !important; + box-shadow: none !important; + } +`; diff --git a/datahub-web-react/src/alchemy-components/components/SearchBar/index.ts b/datahub-web-react/src/alchemy-components/components/SearchBar/index.ts new file mode 100644 index 0000000000000..8c1933163b29f --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/SearchBar/index.ts @@ -0,0 +1 @@ +export { SearchBar } from './SearchBar'; diff --git a/datahub-web-react/src/alchemy-components/components/SearchBar/types.ts b/datahub-web-react/src/alchemy-components/components/SearchBar/types.ts new file mode 100644 index 0000000000000..04ac218dc2cc6 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/SearchBar/types.ts @@ -0,0 +1,7 @@ +export interface SearchBarProps { + placeholder?: string; + value?: string; + width?: string; + onChange?: (value: string) => void; + allowClear?: boolean; +} diff --git a/datahub-web-react/src/alchemy-components/index.ts b/datahub-web-react/src/alchemy-components/index.ts index 8ef4f73f4408f..7e40d343e884d 100644 --- a/datahub-web-react/src/alchemy-components/index.ts +++ b/datahub-web-react/src/alchemy-components/index.ts @@ -15,6 +15,7 @@ export * from './components/LineChart'; export * from './components/PageTitle'; export * from './components/Pills'; export * from './components/Popover'; +export * from './components/SearchBar'; export * from './components/Select'; export * from './components/Switch'; export * from './components/Table'; diff --git a/datahub-web-react/src/app/SearchRoutes.tsx b/datahub-web-react/src/app/SearchRoutes.tsx index 3343260c72bcf..024b6e0add15a 100644 --- a/datahub-web-react/src/app/SearchRoutes.tsx +++ b/datahub-web-react/src/app/SearchRoutes.tsx @@ -11,23 +11,36 @@ import { AnalyticsPage } from './analyticsDashboard/components/AnalyticsPage'; import { ManageIngestionPage } from './ingest/ManageIngestionPage'; import GlossaryRoutes from './glossary/GlossaryRoutes'; import { SettingsPage } from './settings/SettingsPage'; +import { useUserContext } from './context/useUserContext'; import DomainRoutes from './domain/DomainRoutes'; -import { useBusinessAttributesFlag, useIsAppConfigContextLoaded, useIsNestedDomainsEnabled } from './useAppConfig'; +import { + useAppConfig, + useBusinessAttributesFlag, + useIsAppConfigContextLoaded, + useIsNestedDomainsEnabled, +} from './useAppConfig'; import { ManageDomainsPage } from './domain/ManageDomainsPage'; import { BusinessAttributes } from './businessAttribute/BusinessAttributes'; +import StructuredProperties from './govern/structuredProperties/StructuredProperties'; /** * Container for all searchable page routes */ export const SearchRoutes = (): JSX.Element => { const entityRegistry = useEntityRegistry(); + const me = useUserContext(); const isNestedDomainsEnabled = useIsNestedDomainsEnabled(); const entities = isNestedDomainsEnabled ? entityRegistry.getEntitiesForSearchRoutes() : entityRegistry.getNonGlossaryEntities(); + const { config } = useAppConfig(); const businessAttributesFlag = useBusinessAttributesFlag(); const appConfigContextLoaded = useIsAppConfigContextLoaded(); + const showStructuredProperties = + config?.featureFlags?.showManageStructuredProperties && + (me.platformPrivileges?.manageStructuredProperties || me.platformPrivileges?.viewStructuredPropertiesPage); + return ( @@ -53,6 +66,9 @@ export const SearchRoutes = (): JSX.Element => { } /> } /> } /> + {showStructuredProperties && ( + } /> + )} { diff --git a/datahub-web-react/src/app/analytics/event.ts b/datahub-web-react/src/app/analytics/event.ts index c3a57830b8c50..9152f2fb8eedb 100644 --- a/datahub-web-react/src/app/analytics/event.ts +++ b/datahub-web-react/src/app/analytics/event.ts @@ -1,4 +1,12 @@ -import { DataHubViewType, EntityType, RecommendationRenderType, ScenarioType } from '../../types.generated'; +import { + AllowedValue, + DataHubViewType, + EntityType, + PropertyCardinality, + PropertyValueInput, + RecommendationRenderType, + ScenarioType, +} from '../../types.generated'; import { EmbedLookupNotFoundReason } from '../embed/lookup/constants'; import { Direction } from '../lineage/types'; import { FilterMode } from '../search/utils/constants'; @@ -82,6 +90,14 @@ export enum EventType { EmbedProfileViewInDataHubEvent, EmbedLookupNotFoundEvent, CreateBusinessAttributeEvent, + CreateStructuredPropertyClickEvent, + CreateStructuredPropertyEvent, + EditStructuredPropertyEvent, + DeleteStructuredPropertyEvent, + ViewStructuredPropertyEvent, + ApplyStructuredPropertyEvent, + UpdateStructuredPropertyOnAssetEvent, + RemoveStructuredPropertyEvent, } /** @@ -640,6 +656,64 @@ export interface CreateBusinessAttributeEvent extends BaseEvent { name: string; } +export interface CreateStructuredPropertyClickEvent extends BaseEvent { + type: EventType.CreateStructuredPropertyClickEvent; +} + +interface StructuredPropertyEvent extends BaseEvent { + propertyType: string; + appliesTo: string[]; + qualifiedName?: string; + allowedAssetTypes?: string[]; + allowedValues?: AllowedValue[]; + cardinality?: PropertyCardinality; + showInFilters?: boolean; + isHidden: boolean; + showInSearchFilters: boolean; + showAsAssetBadge: boolean; + showInAssetSummary: boolean; + showInColumnsTable: boolean; +} + +export interface CreateStructuredPropertyEvent extends StructuredPropertyEvent { + type: EventType.CreateStructuredPropertyEvent; +} + +export interface EditStructuredPropertyEvent extends StructuredPropertyEvent { + type: EventType.EditStructuredPropertyEvent; + propertyUrn: string; +} + +export interface DeleteStructuredPropertyEvent extends StructuredPropertyEvent { + type: EventType.DeleteStructuredPropertyEvent; + propertyUrn: string; +} + +export interface ViewStructuredPropertyEvent extends BaseEvent { + type: EventType.ViewStructuredPropertyEvent; + propertyUrn: string; +} + +interface StructuredPropertyOnAssetEvent extends BaseEvent { + propertyUrn: string; + propertyType: string; + assetUrn: string; + assetType: EntityType; +} +export interface ApplyStructuredPropertyEvent extends StructuredPropertyOnAssetEvent { + type: EventType.ApplyStructuredPropertyEvent; + values: PropertyValueInput[]; +} + +export interface UpdateStructuredPropertyOnAssetEvent extends StructuredPropertyOnAssetEvent { + type: EventType.UpdateStructuredPropertyOnAssetEvent; + values: PropertyValueInput[]; +} + +export interface RemoveStructuredPropertyEvent extends StructuredPropertyOnAssetEvent { + type: EventType.RemoveStructuredPropertyEvent; +} + /** * Event consisting of a union of specific event types. */ @@ -718,4 +792,12 @@ export type Event = | EmbedProfileViewEvent | EmbedProfileViewInDataHubEvent | EmbedLookupNotFoundEvent - | CreateBusinessAttributeEvent; + | CreateBusinessAttributeEvent + | CreateStructuredPropertyClickEvent + | CreateStructuredPropertyEvent + | EditStructuredPropertyEvent + | DeleteStructuredPropertyEvent + | ViewStructuredPropertyEvent + | ApplyStructuredPropertyEvent + | UpdateStructuredPropertyOnAssetEvent + | RemoveStructuredPropertyEvent; diff --git a/datahub-web-react/src/app/buildEntityRegistry.ts b/datahub-web-react/src/app/buildEntityRegistry.ts index 0b70986672be5..181ec7d328a58 100644 --- a/datahub-web-react/src/app/buildEntityRegistry.ts +++ b/datahub-web-react/src/app/buildEntityRegistry.ts @@ -24,6 +24,7 @@ import { RoleEntity } from './entity/Access/RoleEntity'; import { RestrictedEntity } from './entity/restricted/RestrictedEntity'; import { BusinessAttributeEntity } from './entity/businessAttribute/BusinessAttributeEntity'; import { SchemaFieldPropertiesEntity } from './entity/schemaField/SchemaFieldPropertiesEntity'; +import { StructuredPropertyEntity } from './entity/structuredProperty/StructuredPropertyEntity'; export default function buildEntityRegistry() { const registry = new EntityRegistry(); @@ -52,5 +53,6 @@ export default function buildEntityRegistry() { registry.register(new RestrictedEntity()); registry.register(new BusinessAttributeEntity()); registry.register(new SchemaFieldPropertiesEntity()); + registry.register(new StructuredPropertyEntity()); return registry; } diff --git a/datahub-web-react/src/app/entity/Access/RoleEntity.tsx b/datahub-web-react/src/app/entity/Access/RoleEntity.tsx index ab609b04f104a..58a1ba8dd793b 100644 --- a/datahub-web-react/src/app/entity/Access/RoleEntity.tsx +++ b/datahub-web-react/src/app/entity/Access/RoleEntity.tsx @@ -88,4 +88,8 @@ export class RoleEntity implements Entity { supportedCapabilities = () => { return new Set([EntityCapabilityType.OWNERS]); }; + + getGraphName = () => { + return 'roleEntity'; + }; } diff --git a/datahub-web-react/src/app/entity/Entity.tsx b/datahub-web-react/src/app/entity/Entity.tsx index 490f23330c594..c56c97454a1d5 100644 --- a/datahub-web-react/src/app/entity/Entity.tsx +++ b/datahub-web-react/src/app/entity/Entity.tsx @@ -172,6 +172,11 @@ export interface Entity { */ getGenericEntityProperties: (data: T) => GenericEntityProperties | null; + /** + * Returns the graph name of the entity, as it appears in the GMS entity registry + */ + getGraphName: () => string; + /** * Returns the supported features for the entity */ diff --git a/datahub-web-react/src/app/entity/EntityRegistry.tsx b/datahub-web-react/src/app/entity/EntityRegistry.tsx index 00e7385ff5784..827f0e6692442 100644 --- a/datahub-web-react/src/app/entity/EntityRegistry.tsx +++ b/datahub-web-react/src/app/entity/EntityRegistry.tsx @@ -7,6 +7,7 @@ import { Entity, EntityCapabilityType, IconStyleType, PreviewType } from './Enti import { GLOSSARY_ENTITY_TYPES } from './shared/constants'; import { EntitySidebarSection, GenericEntityProperties } from './shared/types'; import { dictToQueryStringParams, getFineGrainedLineageWithSiblings, urlEncodeUrn } from './shared/utils'; +import PreviewContext from './shared/PreviewContext'; function validatedGet(key: K, map: Map): V { if (map.has(key)) { @@ -142,13 +143,24 @@ export default class EntityRegistry { renderPreview(entityType: EntityType, type: PreviewType, data: T): JSX.Element { const entity = validatedGet(entityType, this.entityTypeToEntity); - return entity.renderPreview(type, data); + const genericEntityData = entity.getGenericEntityProperties(data); + return ( + + {entity.renderPreview(type, data)} + + ); } renderSearchResult(type: EntityType, searchResult: SearchResult): JSX.Element { const entity = validatedGet(type, this.entityTypeToEntity); + const genericEntityData = entity.getGenericEntityProperties(searchResult.entity); + return ( - {entity.renderSearch(searchResult)} + + + {entity.renderSearch(searchResult)} + + ); } @@ -205,6 +217,7 @@ export default class EntityRegistry { schemaMetadata: genericEntityProperties?.schemaMetadata, inputFields: genericEntityProperties?.inputFields, canEditLineage: genericEntityProperties?.privileges?.canEditLineage, + structuredProperties: genericEntityProperties?.structuredProperties, } as FetchedEntity) || undefined ); } @@ -239,6 +252,10 @@ export default class EntityRegistry { getCustomCardUrlPath(type: EntityType): string | undefined { const entity = validatedGet(type, this.entityTypeToEntity); - return entity.getCustomCardUrlPath?.(); + return entity.getCustomCardUrlPath?.() as string | undefined; + } + + getGraphNameFromType(type: EntityType): string { + return validatedGet(type, this.entityTypeToEntity).getGraphName(); } } diff --git a/datahub-web-react/src/app/entity/businessAttribute/BusinessAttributeEntity.tsx b/datahub-web-react/src/app/entity/businessAttribute/BusinessAttributeEntity.tsx index b827a3c37d6a5..442aaf735575a 100644 --- a/datahub-web-react/src/app/entity/businessAttribute/BusinessAttributeEntity.tsx +++ b/datahub-web-react/src/app/entity/businessAttribute/BusinessAttributeEntity.tsx @@ -59,6 +59,8 @@ export class BusinessAttributeEntity implements Entity { getCollectionName = () => 'Business Attributes'; + getGraphName = () => 'businessAttribute'; + getCustomCardUrlPath = () => PageRoutes.BUSINESS_ATTRIBUTE; isBrowseEnabled = () => false; diff --git a/datahub-web-react/src/app/entity/chart/ChartEntity.tsx b/datahub-web-react/src/app/entity/chart/ChartEntity.tsx index 913d502972fe1..70fe8a5e7c7c2 100644 --- a/datahub-web-react/src/app/entity/chart/ChartEntity.tsx +++ b/datahub-web-react/src/app/entity/chart/ChartEntity.tsx @@ -29,6 +29,7 @@ import { MatchedFieldList } from '../../search/matches/MatchedFieldList'; import { matchedInputFieldRenderer } from '../../search/matches/matchedInputFieldRenderer'; import { IncidentTab } from '../shared/tabs/Incident/IncidentTab'; import { ChartQueryTab } from './ChartQueryTab'; +import SidebarStructuredPropsSection from '../shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection'; /** * Definition of the DataHub Chart entity. @@ -69,6 +70,8 @@ export class ChartEntity implements Entity { getAutoCompleteFieldName = () => 'title'; + getGraphName = () => 'chart'; + getPathName = () => 'chart'; getEntityName = () => 'Chart'; @@ -97,6 +100,9 @@ export class ChartEntity implements Entity { { component: DataProductSection, }, + { + component: SidebarStructuredPropsSection, + }, ]; renderProfile = (urn: string) => ( diff --git a/datahub-web-react/src/app/entity/container/ContainerEntity.tsx b/datahub-web-react/src/app/entity/container/ContainerEntity.tsx index 89f9122c6287f..557f52146e77a 100644 --- a/datahub-web-react/src/app/entity/container/ContainerEntity.tsx +++ b/datahub-web-react/src/app/entity/container/ContainerEntity.tsx @@ -19,6 +19,7 @@ import { getDataProduct } from '../shared/utils'; import EmbeddedProfile from '../shared/embed/EmbeddedProfile'; import AccessManagement from '../shared/tabs/Dataset/AccessManagement/AccessManagement'; import { useAppConfig } from '../../useAppConfig'; +import SidebarStructuredPropsSection from '../shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection'; /** * Definition of the DataHub Container entity. @@ -59,6 +60,8 @@ export class ContainerEntity implements Entity { getAutoCompleteFieldName = () => 'name'; + getGraphName = () => 'container'; + getPathName = () => 'container'; getEntityName = () => 'Container'; @@ -131,6 +134,9 @@ export class ContainerEntity implements Entity { { component: DataProductSection, }, + { + component: SidebarStructuredPropsSection, + }, // TODO: Add back once entity-level recommendations are complete. // { // component: SidebarRecommendationsSection, diff --git a/datahub-web-react/src/app/entity/dashboard/DashboardEntity.tsx b/datahub-web-react/src/app/entity/dashboard/DashboardEntity.tsx index 9564cbc18198e..7d0275f60435a 100644 --- a/datahub-web-react/src/app/entity/dashboard/DashboardEntity.tsx +++ b/datahub-web-react/src/app/entity/dashboard/DashboardEntity.tsx @@ -32,6 +32,7 @@ import { LOOKER_URN } from '../../ingest/source/builder/constants'; import { MatchedFieldList } from '../../search/matches/MatchedFieldList'; import { matchedInputFieldRenderer } from '../../search/matches/matchedInputFieldRenderer'; import { IncidentTab } from '../shared/tabs/Incident/IncidentTab'; +import SidebarStructuredPropsSection from '../shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection'; /** * Definition of the DataHub Dashboard entity. @@ -103,6 +104,9 @@ export class DashboardEntity implements Entity { { component: DataProductSection, }, + { + component: SidebarStructuredPropsSection, + }, ]; renderProfile = (urn: string) => ( @@ -291,6 +295,8 @@ export class DashboardEntity implements Entity { ]); }; + getGraphName = () => this.getPathName(); + renderEmbeddedProfile = (urn: string) => ( { getAutoCompleteFieldName = () => 'name'; + getGraphName = () => 'dataFlow'; + getPathName = () => 'pipelines'; getEntityName = () => 'Pipeline'; @@ -121,6 +124,9 @@ export class DataFlowEntity implements Entity { { component: DataProductSection, }, + { + component: SidebarStructuredPropsSection, + }, ]; getOverridePropertiesFromEntity = (dataFlow?: DataFlow | null): GenericEntityProperties => { diff --git a/datahub-web-react/src/app/entity/dataJob/DataJobEntity.tsx b/datahub-web-react/src/app/entity/dataJob/DataJobEntity.tsx index fe1a906371e9d..503acf7652dfa 100644 --- a/datahub-web-react/src/app/entity/dataJob/DataJobEntity.tsx +++ b/datahub-web-react/src/app/entity/dataJob/DataJobEntity.tsx @@ -22,6 +22,7 @@ import { capitalizeFirstLetterOnly } from '../../shared/textUtil'; import DataProductSection from '../shared/containers/profile/sidebar/DataProduct/DataProductSection'; import { getDataProduct } from '../shared/utils'; import { IncidentTab } from '../shared/tabs/Incident/IncidentTab'; +import SidebarStructuredPropsSection from '../shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection'; const getDataJobPlatformName = (data?: DataJob): string => { return ( @@ -64,6 +65,8 @@ export class DataJobEntity implements Entity { getAutoCompleteFieldName = () => 'name'; + getGraphName = () => 'dataJob'; + getPathName = () => 'tasks'; getEntityName = () => 'Task'; @@ -141,6 +144,9 @@ export class DataJobEntity implements Entity { { component: DataProductSection, }, + { + component: SidebarStructuredPropsSection, + }, ]; getOverridePropertiesFromEntity = (dataJob?: DataJob | null): GenericEntityProperties => { diff --git a/datahub-web-react/src/app/entity/dataPlatform/DataPlatformEntity.tsx b/datahub-web-react/src/app/entity/dataPlatform/DataPlatformEntity.tsx index 6687ec9f914c1..89cbaf3cbeaa1 100644 --- a/datahub-web-react/src/app/entity/dataPlatform/DataPlatformEntity.tsx +++ b/datahub-web-react/src/app/entity/dataPlatform/DataPlatformEntity.tsx @@ -71,4 +71,8 @@ export class DataPlatformEntity implements Entity { supportedCapabilities = () => { return new Set([]); }; + + getGraphName = () => { + return 'dataPlatform'; + }; } diff --git a/datahub-web-react/src/app/entity/dataPlatformInstance/DataPlatformInstanceEntity.tsx b/datahub-web-react/src/app/entity/dataPlatformInstance/DataPlatformInstanceEntity.tsx index a542e1b52f510..d0db687ffed92 100644 --- a/datahub-web-react/src/app/entity/dataPlatformInstance/DataPlatformInstanceEntity.tsx +++ b/datahub-web-react/src/app/entity/dataPlatformInstance/DataPlatformInstanceEntity.tsx @@ -58,4 +58,8 @@ export class DataPlatformInstanceEntity implements Entity supportedCapabilities = () => { return new Set([]); }; + + getGraphName = () => { + return 'dataPlatformInstance'; + }; } diff --git a/datahub-web-react/src/app/entity/dataProduct/DataProductEntity.tsx b/datahub-web-react/src/app/entity/dataProduct/DataProductEntity.tsx index 6b31de84f85bb..b7912268eb2e3 100644 --- a/datahub-web-react/src/app/entity/dataProduct/DataProductEntity.tsx +++ b/datahub-web-react/src/app/entity/dataProduct/DataProductEntity.tsx @@ -17,6 +17,7 @@ import { DataProductEntitiesTab } from './DataProductEntitiesTab'; import { EntityActionItem } from '../shared/entity/EntityActions'; import { EntityMenuItems } from '../shared/EntityDropdown/EntityDropdown'; import { PropertiesTab } from '../shared/tabs/Properties/PropertiesTab'; +import SidebarStructuredPropsSection from '../shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection'; /** * Definition of the DataHub Data Product entity. @@ -123,6 +124,9 @@ export class DataProductEntity implements Entity { updateOnly: true, }, }, + { + component: SidebarStructuredPropsSection, + }, ]; renderPreview = (_: PreviewType, data: DataProduct) => { @@ -191,4 +195,8 @@ export class DataProductEntity implements Entity { EntityCapabilityType.DOMAINS, ]); }; + + getGraphName = () => { + return 'dataProduct'; + }; } diff --git a/datahub-web-react/src/app/entity/dataset/DatasetEntity.tsx b/datahub-web-react/src/app/entity/dataset/DatasetEntity.tsx index 21ae085832cb3..35ed3ffcc4c53 100644 --- a/datahub-web-react/src/app/entity/dataset/DatasetEntity.tsx +++ b/datahub-web-react/src/app/entity/dataset/DatasetEntity.tsx @@ -37,6 +37,7 @@ import { matchedFieldPathsRenderer } from '../../search/matches/matchedFieldPath import { getLastUpdatedMs } from './shared/utils'; import { IncidentTab } from '../shared/tabs/Incident/IncidentTab'; import { GovernanceTab } from '../shared/tabs/Dataset/Governance/GovernanceTab'; +import SidebarStructuredPropsSection from '../shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection'; const SUBTYPES = { VIEW: 'view', @@ -85,6 +86,8 @@ export class DatasetEntity implements Entity { getPathName = () => 'dataset'; + getGraphName = () => 'dataset'; + getEntityName = () => 'Dataset'; getCollectionName = () => 'Datasets'; @@ -258,7 +261,11 @@ export class DatasetEntity implements Entity { }, { component: DataProductSection, - }, // TODO: Add back once entity-level recommendations are complete. + }, + { + component: SidebarStructuredPropsSection, + }, + // TODO: Add back once entity-level recommendations are complete. // { // component: SidebarRecommendationsSection, // }, diff --git a/datahub-web-react/src/app/entity/dataset/profile/schema/components/StructuredPropValues.tsx b/datahub-web-react/src/app/entity/dataset/profile/schema/components/StructuredPropValues.tsx new file mode 100644 index 0000000000000..4cba36b9375db --- /dev/null +++ b/datahub-web-react/src/app/entity/dataset/profile/schema/components/StructuredPropValues.tsx @@ -0,0 +1,69 @@ +import StructuredPropertyValue from '@src/app/entity/shared/tabs/Properties/StructuredPropertyValue'; +import { mapStructuredPropertyToPropertyRow } from '@src/app/entity/shared/tabs/Properties/useStructuredProperties'; +import { useEntityRegistry } from '@src/app/useEntityRegistry'; +import { SchemaFieldEntity, SearchResult, StdDataType } from '@src/types.generated'; +import { Tooltip } from 'antd'; +import React from 'react'; +import styled from 'styled-components'; + +const ValuesContainer = styled.span` + max-width: 120px; + display: flex; +`; + +const MoreIndicator = styled.span` + float: right; +`; + +interface Props { + schemaFieldEntity: SchemaFieldEntity; + propColumn: SearchResult | undefined; +} + +const StructuredPropValues = ({ schemaFieldEntity, propColumn }: Props) => { + const entityRegistry = useEntityRegistry(); + + const property = schemaFieldEntity.structuredProperties?.properties?.find( + (prop) => prop.structuredProperty.urn === propColumn?.entity.urn, + ); + const propRow = property ? mapStructuredPropertyToPropertyRow(property) : undefined; + const values = propRow?.values; + const isRichText = propRow?.dataType?.info.type === StdDataType.RichText; + + const hasMoreValues = values && values.length > 2; + const displayedValues = hasMoreValues ? values.slice(0, 1) : values; + const tooltipContent = values?.map((value) => { + const title = value.entity + ? entityRegistry.getDisplayName(value.entity.type, value.entity) + : value.value?.toString(); + return
{title}
; + }); + + return ( + <> + {values && ( + <> + {displayedValues?.map((val) => { + return ( + + + + ); + })} + {hasMoreValues && ( + + ... + + )} + + )} + + ); +}; + +export default StructuredPropValues; diff --git a/datahub-web-react/src/app/entity/domain/DomainEntity.tsx b/datahub-web-react/src/app/entity/domain/DomainEntity.tsx index 2b67c88a6ff23..0f25f3ce565f0 100644 --- a/datahub-web-react/src/app/entity/domain/DomainEntity.tsx +++ b/datahub-web-react/src/app/entity/domain/DomainEntity.tsx @@ -15,6 +15,7 @@ import DataProductsTab from './DataProductsTab/DataProductsTab'; import { EntityProfileTab } from '../shared/constants'; import DomainIcon from '../../domain/DomainIcon'; import { PropertiesTab } from '../shared/tabs/Properties/PropertiesTab'; +import SidebarStructuredPropsSection from '../shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection'; /** * Definition of the DataHub Domain entity. @@ -60,6 +61,8 @@ export class DomainEntity implements Entity { getAutoCompleteFieldName = () => 'name'; + getGraphName = () => 'domain'; + getPathName = () => 'domain'; getEntityName = () => 'Domain'; @@ -110,6 +113,9 @@ export class DomainEntity implements Entity { { component: SidebarOwnerSection, }, + { + component: SidebarStructuredPropsSection, + }, ]; renderPreview = (_: PreviewType, data: Domain) => { diff --git a/datahub-web-react/src/app/entity/ermodelrelationships/ERModelRelationshipEntity.tsx b/datahub-web-react/src/app/entity/ermodelrelationships/ERModelRelationshipEntity.tsx index aece3db1312af..3eb950cb0e7ac 100644 --- a/datahub-web-react/src/app/entity/ermodelrelationships/ERModelRelationshipEntity.tsx +++ b/datahub-web-react/src/app/entity/ermodelrelationships/ERModelRelationshipEntity.tsx @@ -58,6 +58,8 @@ export class ERModelRelationshipEntity implements Entity { getEntityName = () => 'ER-Model-Relationship'; + getGraphName = () => 'erModelRelationship'; + renderProfile = (urn: string) => ( { type: EntityType = EntityType.GlossaryNode; @@ -100,6 +101,9 @@ class GlossaryNodeEntity implements Entity { { component: SidebarOwnerSection, }, + { + component: SidebarStructuredPropsSection, + }, ]; displayName = (data: GlossaryNode) => { @@ -147,6 +151,8 @@ class GlossaryNodeEntity implements Entity { EntityCapabilityType.SOFT_DELETE, ]); }; + + getGraphName = () => this.getPathName(); } export default GlossaryNodeEntity; diff --git a/datahub-web-react/src/app/entity/glossaryTerm/GlossaryTermEntity.tsx b/datahub-web-react/src/app/entity/glossaryTerm/GlossaryTermEntity.tsx index 8bbc0a693b223..439cba2ea6923 100644 --- a/datahub-web-react/src/app/entity/glossaryTerm/GlossaryTermEntity.tsx +++ b/datahub-web-react/src/app/entity/glossaryTerm/GlossaryTermEntity.tsx @@ -18,6 +18,7 @@ import { EntityMenuItems } from '../shared/EntityDropdown/EntityDropdown'; import { EntityActionItem } from '../shared/entity/EntityActions'; import { SidebarDomainSection } from '../shared/containers/profile/sidebar/Domain/SidebarDomainSection'; import { PageRoutes } from '../../../conf/Global'; +import SidebarStructuredPropsSection from '../shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection'; /** * Definition of the DataHub Dataset entity. @@ -129,6 +130,9 @@ export class GlossaryTermEntity implements Entity { hideOwnerType: true, }, }, + { + component: SidebarStructuredPropsSection, + }, ]; getOverridePropertiesFromEntity = (glossaryTerm?: GlossaryTerm | null): GenericEntityProperties => { @@ -179,4 +183,6 @@ export class GlossaryTermEntity implements Entity { EntityCapabilityType.SOFT_DELETE, ]); }; + + getGraphName = () => this.getPathName(); } diff --git a/datahub-web-react/src/app/entity/group/Group.tsx b/datahub-web-react/src/app/entity/group/Group.tsx index cd9cf1ca6eec4..763db856f33ac 100644 --- a/datahub-web-react/src/app/entity/group/Group.tsx +++ b/datahub-web-react/src/app/entity/group/Group.tsx @@ -40,6 +40,8 @@ export class GroupEntity implements Entity { getAutoCompleteFieldName = () => 'name'; + getGraphName: () => string = () => 'corpGroup'; + getPathName: () => string = () => 'group'; getEntityName = () => 'Group'; diff --git a/datahub-web-react/src/app/entity/mlFeature/MLFeatureEntity.tsx b/datahub-web-react/src/app/entity/mlFeature/MLFeatureEntity.tsx index 2f2786b1c0d96..51b66c8c2a41d 100644 --- a/datahub-web-react/src/app/entity/mlFeature/MLFeatureEntity.tsx +++ b/datahub-web-react/src/app/entity/mlFeature/MLFeatureEntity.tsx @@ -18,6 +18,7 @@ import { EntityMenuItems } from '../shared/EntityDropdown/EntityDropdown'; import DataProductSection from '../shared/containers/profile/sidebar/DataProduct/DataProductSection'; import { getDataProduct } from '../shared/utils'; import { PropertiesTab } from '../shared/tabs/Properties/PropertiesTab'; +import SidebarStructuredPropsSection from '../shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection'; /** * Definition of the DataHub MLFeature entity. @@ -52,6 +53,8 @@ export class MLFeatureEntity implements Entity { getAutoCompleteFieldName = () => 'name'; + getGraphName = () => 'mlFeature'; + getPathName = () => 'features'; getEntityName = () => 'Feature'; @@ -120,6 +123,9 @@ export class MLFeatureEntity implements Entity { { component: DataProductSection, }, + { + component: SidebarStructuredPropsSection, + }, ]; renderPreview = (_: PreviewType, data: MlFeature) => { diff --git a/datahub-web-react/src/app/entity/mlFeatureTable/MLFeatureTableEntity.tsx b/datahub-web-react/src/app/entity/mlFeatureTable/MLFeatureTableEntity.tsx index 595c73fbc3cb6..56d4622311fb1 100644 --- a/datahub-web-react/src/app/entity/mlFeatureTable/MLFeatureTableEntity.tsx +++ b/datahub-web-react/src/app/entity/mlFeatureTable/MLFeatureTableEntity.tsx @@ -19,6 +19,7 @@ import { EntityMenuItems } from '../shared/EntityDropdown/EntityDropdown'; import { capitalizeFirstLetterOnly } from '../../shared/textUtil'; import DataProductSection from '../shared/containers/profile/sidebar/DataProduct/DataProductSection'; import { getDataProduct } from '../shared/utils'; +import SidebarStructuredPropsSection from '../shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection'; /** * Definition of the DataHub MLFeatureTable entity. @@ -53,6 +54,8 @@ export class MLFeatureTableEntity implements Entity { getAutoCompleteFieldName = () => 'name'; + getGraphName = () => 'mlFeatureTable'; + getPathName = () => 'featureTables'; getEntityName = () => 'Feature Table'; @@ -88,6 +91,9 @@ export class MLFeatureTableEntity implements Entity { { component: DataProductSection, }, + { + component: SidebarStructuredPropsSection, + }, ]; renderProfile = (urn: string) => ( diff --git a/datahub-web-react/src/app/entity/mlModel/MLModelEntity.tsx b/datahub-web-react/src/app/entity/mlModel/MLModelEntity.tsx index d4d0b37da9ec9..b77f6a19436a5 100644 --- a/datahub-web-react/src/app/entity/mlModel/MLModelEntity.tsx +++ b/datahub-web-react/src/app/entity/mlModel/MLModelEntity.tsx @@ -18,6 +18,7 @@ import { DocumentationTab } from '../shared/tabs/Documentation/DocumentationTab' import MlModelFeaturesTab from './profile/MlModelFeaturesTab'; import { EntityMenuItems } from '../shared/EntityDropdown/EntityDropdown'; import DataProductSection from '../shared/containers/profile/sidebar/DataProduct/DataProductSection'; +import SidebarStructuredPropsSection from '../shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection'; /** * Definition of the DataHub MlModel entity. @@ -52,6 +53,8 @@ export class MLModelEntity implements Entity { getAutoCompleteFieldName = () => 'name'; + getGraphName = () => 'mlModel'; + getPathName = () => 'mlModels'; getEntityName = () => 'ML Model'; @@ -89,6 +92,9 @@ export class MLModelEntity implements Entity { { component: DataProductSection, }, + { + component: SidebarStructuredPropsSection, + }, ]; renderProfile = (urn: string) => ( diff --git a/datahub-web-react/src/app/entity/mlModelGroup/MLModelGroupEntity.tsx b/datahub-web-react/src/app/entity/mlModelGroup/MLModelGroupEntity.tsx index 5896c1864cc43..5c820007fd1e2 100644 --- a/datahub-web-react/src/app/entity/mlModelGroup/MLModelGroupEntity.tsx +++ b/datahub-web-react/src/app/entity/mlModelGroup/MLModelGroupEntity.tsx @@ -16,6 +16,7 @@ import { DocumentationTab } from '../shared/tabs/Documentation/DocumentationTab' import { EntityMenuItems } from '../shared/EntityDropdown/EntityDropdown'; import DataProductSection from '../shared/containers/profile/sidebar/DataProduct/DataProductSection'; import { PropertiesTab } from '../shared/tabs/Properties/PropertiesTab'; +import SidebarStructuredPropsSection from '../shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection'; /** * Definition of the DataHub MlModelGroup entity. @@ -50,6 +51,8 @@ export class MLModelGroupEntity implements Entity { getAutoCompleteFieldName = () => 'name'; + getGraphName = () => 'mlModelGroup'; + getPathName = () => 'mlModelGroup'; getEntityName = () => 'ML Group'; @@ -85,6 +88,9 @@ export class MLModelGroupEntity implements Entity { { component: DataProductSection, }, + { + component: SidebarStructuredPropsSection, + }, ]; renderProfile = (urn: string) => ( diff --git a/datahub-web-react/src/app/entity/mlPrimaryKey/MLPrimaryKeyEntity.tsx b/datahub-web-react/src/app/entity/mlPrimaryKey/MLPrimaryKeyEntity.tsx index 60c7531a4f57c..d72fabc17ecf6 100644 --- a/datahub-web-react/src/app/entity/mlPrimaryKey/MLPrimaryKeyEntity.tsx +++ b/datahub-web-react/src/app/entity/mlPrimaryKey/MLPrimaryKeyEntity.tsx @@ -17,6 +17,7 @@ import { LineageTab } from '../shared/tabs/Lineage/LineageTab'; import DataProductSection from '../shared/containers/profile/sidebar/DataProduct/DataProductSection'; import { getDataProduct } from '../shared/utils'; import { PropertiesTab } from '../shared/tabs/Properties/PropertiesTab'; +import SidebarStructuredPropsSection from '../shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection'; /** * Definition of the DataHub MLPrimaryKey entity. @@ -51,6 +52,8 @@ export class MLPrimaryKeyEntity implements Entity { getAutoCompleteFieldName = () => 'name'; + getGraphName = () => 'mlPrimaryKey'; + getPathName = () => 'mlPrimaryKeys'; getEntityName = () => 'ML Primary Key'; @@ -118,6 +121,9 @@ export class MLPrimaryKeyEntity implements Entity { { component: DataProductSection, }, + { + component: SidebarStructuredPropsSection, + }, ]; renderPreview = (_: PreviewType, data: MlPrimaryKey) => { diff --git a/datahub-web-react/src/app/entity/schemaField/SchemaFieldPropertiesEntity.tsx b/datahub-web-react/src/app/entity/schemaField/SchemaFieldPropertiesEntity.tsx index 88743012ddbc8..2c59c476195d0 100644 --- a/datahub-web-react/src/app/entity/schemaField/SchemaFieldPropertiesEntity.tsx +++ b/datahub-web-react/src/app/entity/schemaField/SchemaFieldPropertiesEntity.tsx @@ -44,6 +44,8 @@ export class SchemaFieldPropertiesEntity implements Entity { // Currently unused. renderProfile = (_: string) => <>; + getGraphName = () => 'schemaField'; + renderPreview = (previewType: PreviewType, data: SchemaFieldEntity) => { const parent = data.parent as Dataset; return ( diff --git a/datahub-web-react/src/app/entity/shared/PreviewContext.tsx b/datahub-web-react/src/app/entity/shared/PreviewContext.tsx new file mode 100644 index 0000000000000..889a6726f3c04 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/PreviewContext.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { GenericEntityProperties } from './types'; + +const PreviewContext = React.createContext(null); +export default PreviewContext; + +export function usePreviewData() { + return React.useContext(PreviewContext); +} diff --git a/datahub-web-react/src/app/entity/shared/containers/profile/header/EntityHeader.tsx b/datahub-web-react/src/app/entity/shared/containers/profile/header/EntityHeader.tsx index 12fa9131f33c7..9e8dc83c32302 100644 --- a/datahub-web-react/src/app/entity/shared/containers/profile/header/EntityHeader.tsx +++ b/datahub-web-react/src/app/entity/shared/containers/profile/header/EntityHeader.tsx @@ -18,6 +18,7 @@ import { useUserContext } from '../../../../../context/useUserContext'; import { useEntityRegistry } from '../../../../../useEntityRegistry'; import EntityHeaderLoadingSection from './EntityHeaderLoadingSection'; import { useIsEditableDatasetNameEnabled } from '../../../../../useAppConfig'; +import StructuredPropertyBadge from './StructuredPropertyBadge'; const TitleWrapper = styled.div` display: flex; @@ -132,6 +133,7 @@ export const EntityHeader = ({ headerDropdownItems, headerActionItems, isNameEdi baseUrl={entityRegistry.getEntityUrl(entityType, urn)} /> )} + { + const badgeStructuredProperty = structuredProperties?.properties?.find(filterForAssetBadge); + + const propRow = badgeStructuredProperty ? mapStructuredPropertyToPropertyRow(badgeStructuredProperty) : undefined; + + if (!badgeStructuredProperty) return null; + + const propertyValue = propRow?.values[0].value; + const relatedDescription = propRow?.structuredProperty.definition.allowedValues?.find( + (v) => getStructuredPropertyValue(v.value) === propertyValue, + )?.description; + + const BadgeTooltip = () => { + return ( + + + {getDisplayName(badgeStructuredProperty.structuredProperty)} + + + + Value + + {propRow?.values[0].value} + + {relatedDescription && ( + + + Description + + {relatedDescription} + + )} + + ); + }; + + return ( + } + color={colors.white} + overlayInnerStyle={{ width: 250, padding: 16 }} + > + + + + + ); +}; + +export default StructuredPropertyBadge; diff --git a/datahub-web-react/src/app/entity/shared/containers/profile/header/utils.ts b/datahub-web-react/src/app/entity/shared/containers/profile/header/utils.ts new file mode 100644 index 0000000000000..6ab469725b51a --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/containers/profile/header/utils.ts @@ -0,0 +1,29 @@ +import EntityRegistry from '@src/app/entity/EntityRegistry'; +import { EntityType, StructuredPropertiesEntry } from '../../../../../../types.generated'; +import { capitalizeFirstLetterOnly } from '../../../../../shared/textUtil'; +import { GenericEntityProperties } from '../../../types'; + +export function getDisplayedEntityType( + entityData: GenericEntityProperties | null, + entityRegistry: EntityRegistry, + entityType: EntityType, +) { + return ( + entityData?.entityTypeOverride || + capitalizeFirstLetterOnly(entityData?.subTypes?.typeNames?.[0]) || + entityRegistry.getEntityName(entityType) || + '' + ); +} + +export function getEntityPlatforms(entityType: EntityType | null, entityData: GenericEntityProperties | null) { + const platform = entityType === EntityType.SchemaField ? entityData?.parent?.platform : entityData?.platform; + const platforms = + entityType === EntityType.SchemaField ? entityData?.parent?.siblingPlatforms : entityData?.siblingPlatforms; + + return { platform, platforms }; +} + +export function filterForAssetBadge(prop: StructuredPropertiesEntry) { + return prop.structuredProperty.settings?.showAsAssetBadge && !prop.structuredProperty.settings?.isHidden; +} diff --git a/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/DataProduct/DataProductSection.tsx b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/DataProduct/DataProductSection.tsx index 1d489e88b5050..ec65b31968d83 100644 --- a/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/DataProduct/DataProductSection.tsx +++ b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/DataProduct/DataProductSection.tsx @@ -67,7 +67,7 @@ export default function DataProductSection({ readOnly }: Props) { }; return ( - <> +
{dataProduct && ( )} - +
); } diff --git a/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/SidebarHeader.tsx b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/SidebarHeader.tsx index 0ee3fcb90e575..a5c7cce93a42a 100644 --- a/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/SidebarHeader.tsx +++ b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/SidebarHeader.tsx @@ -15,14 +15,15 @@ const HeaderContainer = styled.div` type Props = { title: string; + titleComponent?: React.ReactNode; actions?: React.ReactNode; children?: React.ReactNode; }; -export const SidebarHeader = ({ title, actions, children }: Props) => { +export const SidebarHeader = ({ title, titleComponent, actions, children }: Props) => { return ( - {title} + {titleComponent || {title}} {actions &&
{actions}
} {children}
diff --git a/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection.tsx b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection.tsx new file mode 100644 index 0000000000000..ea257ca2ade31 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection.tsx @@ -0,0 +1,146 @@ +import { useUserContext } from '@src/app/context/useUserContext'; +import { useEntityData } from '@src/app/entity/shared/EntityContext'; +import { StyledList } from '@src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/FieldProperties'; +import { EditColumn } from '@src/app/entity/shared/tabs/Properties/Edit/EditColumn'; +import StructuredPropertyValue from '@src/app/entity/shared/tabs/Properties/StructuredPropertyValue'; +import { PropertyRow } from '@src/app/entity/shared/tabs/Properties/types'; +import EmptySectionText from '@src/app/shared/sidebar/EmptySectionText'; +import { + getDisplayName, + getEntityTypesPropertyFilter, + getNotHiddenPropertyFilter, + getPropertyRowFromSearchResult, +} from '@src/app/govern/structuredProperties/utils'; +import { useEntityRegistry } from '@src/app/useEntityRegistry'; +import { useGetSearchResultsForMultipleQuery } from '@src/graphql/search.generated'; +import { EntityType, SchemaField, SearchResult, StdDataType, StructuredPropertyEntity } from '@src/types.generated'; +import { + SHOW_IN_ASSET_SUMMARY_PROPERTY_FILTER_NAME, + SHOW_IN_COLUMNS_TABLE_PROPERTY_FILTER_NAME, +} from '@src/app/search/utils/constants'; +import { + SectionHeader, + StyledDivider, +} from '@src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/components'; +import { useGetEntityWithSchema } from '@src/app/entity/shared/tabs/Dataset/Schema/useGetEntitySchema'; +import React from 'react'; +import { SidebarHeader } from '../SidebarHeader'; + +interface Props { + properties?: { + schemaField?: SchemaField; + schemaColumnProperties?: SearchResult[]; + }; +} + +const SidebarStructuredPropsSection = ({ properties }: Props) => { + const schemaField = properties?.schemaField; + const schemaColumnProperties = properties?.schemaColumnProperties; + const { entityData, entityType } = useEntityData(); + const me = useUserContext(); + const entityRegistry = useEntityRegistry(); + const { refetch: refetchSchema } = useGetEntityWithSchema(true); + + const currentProperties = schemaField + ? schemaField?.schemaFieldEntity?.structuredProperties + : entityData?.structuredProperties; + + const inputs = { + types: [EntityType.StructuredProperty], + query: '', + start: 0, + count: 50, + searchFlags: { skipCache: true }, + orFilters: [ + { + and: [ + getEntityTypesPropertyFilter(entityRegistry, !!schemaField, entityType), + getNotHiddenPropertyFilter(), + { + field: schemaField + ? SHOW_IN_COLUMNS_TABLE_PROPERTY_FILTER_NAME + : SHOW_IN_ASSET_SUMMARY_PROPERTY_FILTER_NAME, + values: ['true'], + }, + ], + }, + ], + }; + + // Execute search + const { data } = useGetSearchResultsForMultipleQuery({ + variables: { + input: inputs, + }, + skip: !!schemaColumnProperties, + fetchPolicy: 'cache-first', + }); + + const entityTypeProperties = schemaColumnProperties || data?.searchAcrossEntities?.searchResults; + + const canEditProperties = me.platformPrivileges?.manageStructuredProperties; + + return ( + <> + {entityTypeProperties?.map((property) => { + const propertyRow: PropertyRow | undefined = getPropertyRowFromSearchResult( + property, + currentProperties, + ); + const isRichText = propertyRow?.dataType?.info.type === StdDataType.RichText; + const values = propertyRow?.values; + const hasMultipleValues = values && values.length > 1; + const propertyName = getDisplayName(property.entity as StructuredPropertyEntity); + + return ( + <> +
+ {propertyName}} + actions={ + canEditProperties && ( + <> + v.value) || []} + isAddMode={!values} + associatedUrn={schemaField?.schemaFieldEntity?.urn} + refetch={schemaField ? refetchSchema : undefined} + /> + + ) + } + /> + + {values ? ( + <> + {hasMultipleValues ? ( + + {values.map((value) => ( +
  • + +
  • + ))} +
    + ) : ( + <> + {values?.map((value) => ( + + ))} + + )} + + ) : ( + + )} +
    + {schemaField && } + + ); + })} + + ); +}; + +export default SidebarStructuredPropsSection; diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/SchemaTable.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/SchemaTable.tsx index 0bfd5255f3065..83ebb8f6b7828 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/SchemaTable.tsx +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/SchemaTable.tsx @@ -27,6 +27,8 @@ import PropertiesColumn from './components/PropertiesColumn'; import SchemaFieldDrawer from './components/SchemaFieldDrawer/SchemaFieldDrawer'; import useBusinessAttributeRenderer from './utils/useBusinessAttributeRenderer'; import { useBusinessAttributesFlag } from '../../../../../useAppConfig'; +import { useGetTableColumnProperties } from './useGetTableColumnProperties'; +import { useGetStructuredPropColumns } from './useGetStructuredPropColumns'; const TableContainer = styled.div` overflow: inherit; @@ -126,6 +128,9 @@ export default function SchemaTable({ const schemaTitleRenderer = useSchemaTitleRenderer(schemaMetadata, setSelectedFkFieldPath, filterText); const schemaBlameRenderer = useSchemaBlameRenderer(schemaFieldBlameList); + const tableColumnStructuredProps = useGetTableColumnProperties(); + const structuredPropColumns = useGetStructuredPropColumns(tableColumnStructuredProps); + const fieldColumn = { width: '22%', title: 'Field', @@ -221,6 +226,10 @@ export default function SchemaTable({ allColumns = [...allColumns, propertiesColumn]; } + if (structuredPropColumns) { + allColumns = [...allColumns, ...structuredPropColumns]; + } + if (hasUsageStats) { allColumns = [...allColumns, usageColumn]; } diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/PropertiesColumn.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/PropertiesColumn.tsx index b74de3e94e554..74d14cb0db753 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/PropertiesColumn.tsx +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/PropertiesColumn.tsx @@ -17,7 +17,9 @@ interface Props { export default function PropertiesColumn({ field }: Props) { const { schemaFieldEntity } = field; - const numProperties = schemaFieldEntity?.structuredProperties?.properties?.length; + const numProperties = schemaFieldEntity?.structuredProperties?.properties?.filter( + (prop) => !prop.structuredProperty.settings?.isHidden, + )?.length; if (!schemaFieldEntity || !numProperties) return null; diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/FieldProperties.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/FieldProperties.tsx index 9a0da20f22dfd..d6f2a83748251 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/FieldProperties.tsx +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/FieldProperties.tsx @@ -1,47 +1,76 @@ +import { useEntityData } from '@src/app/entity/shared/EntityContext'; import React from 'react'; import styled from 'styled-components'; -import { SchemaField, StdDataType } from '../../../../../../../../types.generated'; -import { SectionHeader, StyledDivider } from './components'; -import { mapStructuredPropertyValues } from '../../../../Properties/useStructuredProperties'; -import StructuredPropertyValue from '../../../../Properties/StructuredPropertyValue'; +import { SchemaField, SearchResult, StdDataType } from '../../../../../../../../types.generated'; +import AddPropertyButton from '../../../../Properties/AddPropertyButton'; import { EditColumn } from '../../../../Properties/Edit/EditColumn'; +import StructuredPropertyValue from '../../../../Properties/StructuredPropertyValue'; +import { mapStructuredPropertyValues } from '../../../../Properties/useStructuredProperties'; import { useGetEntityWithSchema } from '../../useGetEntitySchema'; +import { StyledDivider } from './components'; -const PropertyTitle = styled.div` +export const PropertyTitle = styled.div` font-size: 14px; font-weight: 700; margin-bottom: 4px; `; -const PropertyWrapper = styled.div` +export const PropertyWrapper = styled.div` margin-bottom: 12px; display: flex; justify-content: space-between; `; -const PropertiesWrapper = styled.div` +export const PropertiesWrapper = styled.div` padding-left: 16px; `; -const StyledList = styled.ul` +export const StyledList = styled.ul` padding-left: 24px; `; +export const Header = styled.div` + font-size: 16px; + font-weight: 600; + margin-bottom: 16px; + display: flex; + justify-content: space-between; + align-items: center; +`; + interface Props { expandedField: SchemaField; + schemaColumnProperties?: SearchResult[]; } -export default function FieldProperties({ expandedField }: Props) { +export default function FieldProperties({ expandedField, schemaColumnProperties }: Props) { const { schemaFieldEntity } = expandedField; const { refetch } = useGetEntityWithSchema(true); + const { entityData } = useEntityData(); const properties = - schemaFieldEntity?.structuredProperties?.properties?.filter((prop) => prop.structuredProperty.exists) || []; + schemaFieldEntity?.structuredProperties?.properties?.filter( + (prop) => + prop.structuredProperty.exists && + !prop.structuredProperty.settings?.isHidden && + !schemaColumnProperties?.find((p) => p.entity.urn === prop.structuredProperty.urn), + ) || []; + + const canEditProperties = + entityData?.parent?.privileges?.canEditProperties || entityData?.privileges?.canEditProperties; - if (!schemaFieldEntity || !properties.length) return null; + if (!schemaFieldEntity) return null; return ( <> - Properties +
    + Properties + +
    {properties.map((structuredProp) => { const isRichText = @@ -71,12 +100,14 @@ export default function FieldProperties({ expandedField }: Props) { )} - v.value) || []} - refetch={refetch} - /> + {canEditProperties && ( + v.value) || []} + refetch={refetch} + /> + )} ); })} diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/SchemaFieldDrawer.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/SchemaFieldDrawer.tsx index 0d9f7a98f207c..df0cd8b2dd762 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/SchemaFieldDrawer.tsx +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/SchemaFieldDrawer.tsx @@ -1,6 +1,7 @@ import { Drawer } from 'antd'; import React, { useMemo } from 'react'; import styled from 'styled-components'; +import SidebarStructuredPropsSection from '@src/app/entity/shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection'; import DrawerHeader from './DrawerHeader'; import FieldHeader from './FieldHeader'; import FieldDescription from './FieldDescription'; @@ -11,6 +12,7 @@ import FieldTags from './FieldTags'; import FieldTerms from './FieldTerms'; import FieldProperties from './FieldProperties'; import FieldAttribute from './FieldAttribute'; +import useGetSchemaColumnProperties from './useGetSchemaColumnProperties'; const StyledDrawer = styled(Drawer)` position: absolute; @@ -50,6 +52,7 @@ export default function SchemaFieldDrawer({ const editableFieldInfo = editableSchemaMetadata?.editableSchemaFieldInfo.find((candidateEditableFieldInfo) => pathMatchesNewPath(candidateEditableFieldInfo.fieldPath, expandedField?.fieldPath), ); + const schemaColumnProperties = useGetSchemaColumnProperties(); return ( - + + diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/useGetSchemaColumnProperties.ts b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/useGetSchemaColumnProperties.ts new file mode 100644 index 0000000000000..ed5af588fa036 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/useGetSchemaColumnProperties.ts @@ -0,0 +1,39 @@ +import { getEntityTypesPropertyFilter, getNotHiddenPropertyFilter } from '@src/app/govern/structuredProperties/utils'; +import { SHOW_IN_COLUMNS_TABLE_PROPERTY_FILTER_NAME } from '@src/app/search/utils/constants'; +import { useEntityRegistry } from '@src/app/useEntityRegistry'; +import { useGetSearchResultsForMultipleQuery } from '@src/graphql/search.generated'; +import { EntityType, SearchResult } from '@src/types.generated'; + +export default function useGetSchemaColumnProperties() { + const entityRegistry = useEntityRegistry(); + + const inputs = { + types: [EntityType.StructuredProperty], + query: '', + start: 0, + count: 50, + searchFlags: { skipCache: true }, + orFilters: [ + { + and: [ + getEntityTypesPropertyFilter(entityRegistry, true, EntityType.SchemaField), + getNotHiddenPropertyFilter(), + { + field: SHOW_IN_COLUMNS_TABLE_PROPERTY_FILTER_NAME, + values: ['true'], + }, + ], + }, + ], + }; + + // Execute search + const { data } = useGetSearchResultsForMultipleQuery({ + variables: { + input: inputs, + }, + fetchPolicy: 'cache-first', + }); + + return data?.searchAcrossEntities?.searchResults || ([] as SearchResult[]); +} diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/useGetStructuredPropColumns.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/useGetStructuredPropColumns.tsx new file mode 100644 index 0000000000000..eed3fd510724b --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/useGetStructuredPropColumns.tsx @@ -0,0 +1,22 @@ +import StructuredPropValues from '@src/app/entity/dataset/profile/schema/components/StructuredPropValues'; +import { getDisplayName } from '@src/app/govern/structuredProperties/utils'; +import { SearchResult, StructuredPropertyEntity } from '@src/types.generated'; +import React, { useMemo } from 'react'; + +export const useGetStructuredPropColumns = (properties: SearchResult[] | undefined) => { + const columns = useMemo(() => { + return properties?.map((prop) => { + const name = getDisplayName(prop.entity as StructuredPropertyEntity); + return { + width: 120, + title: name, + dataIndex: 'schemaFieldEntity', + key: prop.entity.urn, + render: (record) => , + ellipsis: true, + }; + }); + }, [properties]); + + return columns; +}; diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/useGetTableColumnProperties.ts b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/useGetTableColumnProperties.ts new file mode 100644 index 0000000000000..96ff90921b937 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/useGetTableColumnProperties.ts @@ -0,0 +1,39 @@ +import { + getEntityTypesPropertyFilter, + getNotHiddenPropertyFilter, + getShowInColumnsTablePropertyFilter, +} from '@src/app/govern/structuredProperties/utils'; +import { useEntityRegistry } from '@src/app/useEntityRegistry'; +import { useGetSearchResultsForMultipleQuery } from '@src/graphql/search.generated'; +import { EntityType } from '@src/types.generated'; + +export const useGetTableColumnProperties = () => { + const entityRegistry = useEntityRegistry(); + + const inputs = { + types: [EntityType.StructuredProperty], + query: '', + start: 0, + count: 50, + searchFlags: { skipCache: true }, + orFilters: [ + { + and: [ + getEntityTypesPropertyFilter(entityRegistry, true), + getNotHiddenPropertyFilter(), + getShowInColumnsTablePropertyFilter(), + ], + }, + ], + }; + + // Execute search + const { data } = useGetSearchResultsForMultipleQuery({ + variables: { + input: inputs, + }, + fetchPolicy: 'cache-first', + }); + + return data?.searchAcrossEntities?.searchResults; +}; diff --git a/datahub-web-react/src/app/entity/shared/tabs/Documentation/components/CompactMarkdownViewer.tsx b/datahub-web-react/src/app/entity/shared/tabs/Documentation/components/CompactMarkdownViewer.tsx new file mode 100644 index 0000000000000..df93d51e721bb --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Documentation/components/CompactMarkdownViewer.tsx @@ -0,0 +1,161 @@ +import { Button } from 'antd'; +import React, { useCallback, useEffect, useState } from 'react'; +import styled from 'styled-components'; +import { Editor } from './editor/Editor'; + +const LINE_HEIGHT = 1.5; + +const ShowMoreWrapper = styled.div` + align-items: start; + display: flex; + flex-direction: column; +`; + +const MarkdownContainer = styled.div<{ lineLimit?: number | null }>` + max-width: 100%; + position: relative; + ${(props) => + props.lineLimit && + props.lineLimit <= 1 && + ` + display: flex; + align-items: center; + gap: 4px; + ${ShowMoreWrapper}{ + flex-direction: row; + align-items: center; + gap: 4px; + } + `} +`; + +const CustomButton = styled(Button)` + padding: 0; + color: #676b75; +`; + +const MarkdownViewContainer = styled.div<{ scrollableY: boolean }>` + display: block; + overflow-wrap: break-word; + word-wrap: break-word; + overflow-x: hidden; + overflow-y: ${(props) => (props.scrollableY ? 'auto' : 'hidden')}; +`; + +const CompactEditor = styled(Editor)<{ limit: number | null; customStyle?: React.CSSProperties }>` + .remirror-editor.ProseMirror { + ${({ limit }) => limit && `max-height: ${limit * LINE_HEIGHT}em;`} + h1 { + font-size: 1.4em; + } + + h2 { + font-size: 1.3em; + } + + h3 { + font-size: 1.2em; + } + + h4 { + font-size: 1.1em; + } + + h5, + h6 { + font-size: 1em; + } + + p { + ${(props) => props?.customStyle?.fontSize && `font-size: ${props?.customStyle?.fontSize}`}; + margin-bottom: 0; + } + + padding: 0; + } +`; + +const FixedLineHeightEditor = styled(CompactEditor)<{ customStyle?: React.CSSProperties }>` + .remirror-editor.ProseMirror { + * { + line-height: ${LINE_HEIGHT}; + font-size: 1em !important; + margin-top: 0; + margin-bottom: 0; + } + p { + font-size: ${(props) => (props?.customStyle?.fontSize ? props?.customStyle?.fontSize : '1em')} !important; + } + } +`; + +export type Props = { + content: string; + lineLimit?: number | null; + fixedLineHeight?: boolean; + isShowMoreEnabled?: boolean; + customStyle?: React.CSSProperties; + scrollableY?: boolean; // Whether the viewer is vertically scrollable. + handleShowMore?: () => void; + hideShowMore?: boolean; +}; + +export default function CompactMarkdownViewer({ + content, + lineLimit = 4, + fixedLineHeight = false, + isShowMoreEnabled = false, + customStyle = {}, + scrollableY = true, + handleShowMore, + hideShowMore, +}: Props) { + const [isShowingMore, setIsShowingMore] = useState(false); + const [isTruncated, setIsTruncated] = useState(false); + + useEffect(() => { + if (isShowMoreEnabled) { + setIsShowingMore(isShowMoreEnabled); + } + return () => { + setIsShowingMore(false); + }; + }, [isShowMoreEnabled]); + + const measuredRef = useCallback((node: HTMLDivElement | null) => { + if (node !== null) { + const resizeObserver = new ResizeObserver(() => { + setIsTruncated(node.scrollHeight > node.clientHeight + 1); + }); + resizeObserver.observe(node); + } + }, []); + + const StyledEditor = fixedLineHeight ? FixedLineHeightEditor : CompactEditor; + + return ( + + + + + {hideShowMore && <>...} + + {!hideShowMore && + (isShowingMore || isTruncated) && ( // "show more" when isTruncated, "show less" when isShowingMore + + (handleShowMore ? handleShowMore() : setIsShowingMore(!isShowingMore))} + > + {isShowingMore ? 'show less' : 'show more'} + + + )} + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/tabs/Properties/AddPropertyButton.tsx b/datahub-web-react/src/app/entity/shared/tabs/Properties/AddPropertyButton.tsx new file mode 100644 index 0000000000000..cac3e268c1df5 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Properties/AddPropertyButton.tsx @@ -0,0 +1,228 @@ +import { LoadingOutlined } from '@ant-design/icons'; +import { colors, Icon, Input as InputComponent, Text } from '@src/alchemy-components'; +import { useUserContext } from '@src/app/context/useUserContext'; +import { getEntityTypesPropertyFilter, getNotHiddenPropertyFilter } from '@src/app/govern/structuredProperties/utils'; +import { useEntityRegistry } from '@src/app/useEntityRegistry'; +import { PageRoutes } from '@src/conf/Global'; +import { useGetSearchResultsForMultipleQuery } from '@src/graphql/search.generated'; +import { Dropdown } from 'antd'; +import { Tooltip } from '@components'; +import { EntityType, Maybe, StructuredProperties, StructuredPropertyEntity } from '@src/types.generated'; +import React, { useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import styled from 'styled-components'; +import { useEntityData } from '../../EntityContext'; +import EditStructuredPropertyModal from './Edit/EditStructuredPropertyModal'; + +const AddButton = styled.div<{ isV1Drawer?: boolean }>` + border-radius: 200px; + background-color: #5280e2; + width: ${(props) => (props.isV1Drawer ? '24px' : '32px')}; + height: ${(props) => (props.isV1Drawer ? '24px' : '32px')}; + display: flex; + align-items: center; + justify-content: center; + + :hover { + cursor: pointer; + } +`; + +const DropdownContainer = styled.div` + border-radius: 12px; + box-shadow: 0px 0px 14px 0px rgba(0, 0, 0, 0.15); + background-color: ${colors.white}; + padding-bottom: 8px; + width: 300px; +`; + +const SearchContainer = styled.div` + padding: 8px; +`; + +const OptionsContainer = styled.div` + max-height: 200px; + overflow-y: auto; + font-size: 14px; +`; + +const Option = styled.div``; + +const LoadingContainer = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; + justify-content: center; + height: 100px; +`; + +const EmptyContainer = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; + justify-content: center; + min-height: 50px; + padding: 16px; + text-align: center; +`; + +interface Props { + fieldUrn?: string; + refetch?: () => void; + fieldProperties?: Maybe; + isV1Drawer?: boolean; +} + +const AddPropertyButton = ({ fieldUrn, refetch, fieldProperties, isV1Drawer }: Props) => { + const [searchQuery, setSearchQuery] = useState(''); + const { entityData, entityType } = useEntityData(); + const me = useUserContext(); + const entityRegistry = useEntityRegistry(); + const [isEditModalVisible, setIsEditModalVisible] = useState(false); + + const inputs = { + types: [EntityType.StructuredProperty], + query: '', + start: 0, + count: 100, + searchFlags: { skipCache: true }, + orFilters: [ + { + and: [ + getEntityTypesPropertyFilter(entityRegistry, !!fieldUrn, entityType), + getNotHiddenPropertyFilter(), + ], + }, + ], + }; + + // Execute search + const { data, loading } = useGetSearchResultsForMultipleQuery({ + variables: { + input: inputs, + }, + fetchPolicy: 'cache-first', + }); + + const [selectedProperty, setSelectedProperty] = useState( + data?.searchAcrossEntities?.searchResults?.[0]?.entity as StructuredPropertyEntity | undefined, + ); + + const handleOptionClick = (property: StructuredPropertyEntity) => { + setSelectedProperty(property); + setIsEditModalVisible(true); + }; + + const entityPropertiesUrns = entityData?.structuredProperties?.properties?.map( + (prop) => prop.structuredProperty.urn, + ); + const fieldPropertiesUrns = fieldProperties?.properties?.map((prop) => prop.structuredProperty.urn); + + // filter out the existing properties when displaying in the list of add button + const properties = useMemo( + () => + data?.searchAcrossEntities?.searchResults + .filter((result) => + fieldUrn + ? !fieldPropertiesUrns?.includes(result.entity.urn) + : !entityPropertiesUrns?.includes(result.entity.urn), + ) + .map((prop) => { + const entity = prop.entity as StructuredPropertyEntity; + return { + label: ( + + ), + key: entity.urn, + name: entity.definition?.displayName || entity.urn, + }; + }), + [data, fieldUrn, fieldPropertiesUrns, entityPropertiesUrns], + ); + + const canEditProperties = + entityData?.parent?.privileges?.canEditProperties || entityData?.privileges?.canEditProperties; + + if (!canEditProperties) return null; + + // Filter items based on search query + const filteredItems = properties?.filter((prop) => prop.name?.toLowerCase().includes(searchQuery.toLowerCase())); + + const noDataText = + properties?.length === 0 ? ( + <> + It looks like there are no structured properties for this asset type. + {me.platformPrivileges?.manageStructuredProperties && ( + + {' '} + Manage custom properties + + )} + + ) : null; + + return ( + <> + ( + + + setSearchQuery(e.target.value)} + /> + + {loading ? ( + + + Loading... + + ) : ( + <> + {filteredItems?.length === 0 && ( + + + No results found + + + {noDataText} + + + )} + {menuNode} + + )} + + )} + > + + + + + + + {selectedProperty && ( + setIsEditModalVisible(false)} + structuredProperty={selectedProperty} + associatedUrn={fieldUrn} // pass in fieldUrn to use otherwise we will use mutation urn for siblings + refetch={refetch} + isAddMode + /> + )} + + ); +}; + +export default AddPropertyButton; diff --git a/datahub-web-react/src/app/entity/shared/tabs/Properties/Edit/EditColumn.tsx b/datahub-web-react/src/app/entity/shared/tabs/Properties/Edit/EditColumn.tsx index 6a0599c0cdb33..a2d5c44b391e3 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/Properties/Edit/EditColumn.tsx +++ b/datahub-web-react/src/app/entity/shared/tabs/Properties/Edit/EditColumn.tsx @@ -1,27 +1,128 @@ -import { Button } from 'antd'; +import { colors, Icon, Text } from '@src/alchemy-components'; +import analytics, { EventType } from '@src/app/analytics'; +import { MenuItem } from '@src/app/govern/structuredProperties/styledComponents'; +import { ConfirmationModal } from '@src/app/sharedV2/modals/ConfirmationModal'; +import { showToastMessage, ToastType } from '@src/app/sharedV2/toastMessageUtils'; +import { useRemoveStructuredPropertiesMutation } from '@src/graphql/structuredProperties.generated'; +import { EntityType, StructuredPropertyEntity } from '@src/types.generated'; +import { Dropdown } from 'antd'; import React, { useState } from 'react'; +import styled from 'styled-components'; +import { useEntityContext, useEntityData, useMutationUrn } from '../../../EntityContext'; import EditStructuredPropertyModal from './EditStructuredPropertyModal'; -import { StructuredPropertyEntity } from '../../../../../../types.generated'; + +export const MoreOptionsContainer = styled.div` + display: flex; + gap: 12px; + justify-content: end; + + div { + background-color: ${colors.gray[1500]}; + border-radius: 20px; + width: 24px; + height: 24px; + padding: 3px; + color: ${colors.gray[1800]}; + :hover { + cursor: pointer; + } + } +`; interface Props { structuredProperty?: StructuredPropertyEntity; associatedUrn?: string; values?: (string | number | null)[]; refetch?: () => void; + isAddMode?: boolean; } -export function EditColumn({ structuredProperty, associatedUrn, values, refetch }: Props) { +export function EditColumn({ structuredProperty, associatedUrn, values, refetch, isAddMode }: Props) { const [isEditModalVisible, setIsEditModalVisible] = useState(false); + const { refetch: entityRefetch } = useEntityContext(); + const { entityType } = useEntityData(); + + const [removeStructuredProperty] = useRemoveStructuredPropertiesMutation(); + + const [showConfirmRemove, setShowConfirmRemove] = useState(false); + const mutationUrn = useMutationUrn(); if (!structuredProperty || structuredProperty?.definition.immutable) { return null; } + const handleRemoveProperty = () => { + showToastMessage(ToastType.LOADING, 'Removing structured property', 1); + removeStructuredProperty({ + variables: { + input: { + assetUrn: associatedUrn || mutationUrn, + structuredPropertyUrns: [structuredProperty.urn], + }, + }, + }) + .then(() => { + analytics.event({ + type: EventType.RemoveStructuredPropertyEvent, + propertyUrn: structuredProperty.urn, + propertyType: structuredProperty.definition.valueType.urn, + assetUrn: associatedUrn || mutationUrn, + assetType: associatedUrn?.includes('urn:li:schemaField') ? EntityType.SchemaField : entityType, + }); + showToastMessage(ToastType.SUCCESS, 'Structured property removed successfully!', 3); + if (refetch) { + refetch(); + } else { + entityRefetch(); + } + }) + .catch(() => { + showToastMessage(ToastType.ERROR, 'Failed to remove structured property', 3); + }); + + setShowConfirmRemove(false); + }; + + const handleRemoveClose = () => { + setShowConfirmRemove(false); + }; + + const items = [ + { + key: '0', + label: ( + { + setIsEditModalVisible(true); + }} + > + {isAddMode ? 'Add' : 'Edit'} + + ), + }, + ]; + if (values && values?.length > 0) { + items.push({ + key: '1', + label: ( + { + setShowConfirmRemove(true); + }} + > + Remove + + ), + }); + } + return ( <> - + + + + + setIsEditModalVisible(false)} refetch={refetch} + isAddMode={isAddMode} + /> + handleRemoveProperty()} + modalTitle="Confirm Remove Structured Property" + modalText={`Are you sure you want to remove ${structuredProperty.definition.displayName} from this asset?`} /> ); diff --git a/datahub-web-react/src/app/entity/shared/tabs/Properties/Edit/EditStructuredPropertyModal.tsx b/datahub-web-react/src/app/entity/shared/tabs/Properties/Edit/EditStructuredPropertyModal.tsx index c8def8bef5e19..13aa0dfd42d1e 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/Properties/Edit/EditStructuredPropertyModal.tsx +++ b/datahub-web-react/src/app/entity/shared/tabs/Properties/Edit/EditStructuredPropertyModal.tsx @@ -1,12 +1,13 @@ +import analytics, { EventType } from '@src/app/analytics'; import { Button, Modal, message } from 'antd'; import React, { useEffect, useMemo } from 'react'; import styled from 'styled-components'; -import StructuredPropertyInput from '../../../components/styled/StructuredProperty/StructuredPropertyInput'; -import { PropertyValueInput, StructuredPropertyEntity } from '../../../../../../types.generated'; import { useUpsertStructuredPropertiesMutation } from '../../../../../../graphql/structuredProperties.generated'; -import { useEditStructuredProperty } from '../../../components/styled/StructuredProperty/useEditStructuredProperty'; -import { useEntityContext, useMutationUrn } from '../../../EntityContext'; +import { EntityType, PropertyValueInput, StructuredPropertyEntity } from '../../../../../../types.generated'; import handleGraphQLError from '../../../../../shared/handleGraphQLError'; +import StructuredPropertyInput from '../../../components/styled/StructuredProperty/StructuredPropertyInput'; +import { useEditStructuredProperty } from '../../../components/styled/StructuredProperty/useEditStructuredProperty'; +import { useEntityContext, useEntityData, useMutationUrn } from '../../../EntityContext'; const Description = styled.div` font-size: 14px; @@ -21,6 +22,7 @@ interface Props { values?: (string | number | null)[]; closeModal: () => void; refetch?: () => void; + isAddMode?: boolean; } export default function EditStructuredPropertyModal({ @@ -30,9 +32,11 @@ export default function EditStructuredPropertyModal({ values, closeModal, refetch, + isAddMode, }: Props) { const { refetch: entityRefetch } = useEntityContext(); const mutationUrn = useMutationUrn(); + const { entityType } = useEntityData(); const urn = associatedUrn || mutationUrn; const initialValues = useMemo(() => values || [], [values]); const { selectedValues, selectSingleValue, toggleSelectedValue, updateSelectedValues, setSelectedValues } = @@ -44,7 +48,13 @@ export default function EditStructuredPropertyModal({ }, [isOpen, initialValues, setSelectedValues]); function upsertProperties() { - message.loading('Updating...'); + message.loading(isAddMode ? 'Adding...' : 'Updating...'); + const propValues = selectedValues.map((value) => { + if (typeof value === 'string') { + return { stringValue: value as string }; + } + return { numberValue: value as number }; + }) as PropertyValueInput[]; upsertStructuredProperties({ variables: { input: { @@ -52,25 +62,30 @@ export default function EditStructuredPropertyModal({ structuredPropertyInputParams: [ { structuredPropertyUrn: structuredProperty.urn, - values: selectedValues.map((value) => { - if (typeof value === 'string') { - return { stringValue: value as string }; - } - return { numberValue: value as number }; - }) as PropertyValueInput[], + values: propValues, }, ], }, }, }) .then(() => { + analytics.event({ + type: isAddMode + ? EventType.ApplyStructuredPropertyEvent + : EventType.UpdateStructuredPropertyOnAssetEvent, + propertyUrn: structuredProperty.urn, + propertyType: structuredProperty.definition.valueType.urn, + assetUrn: urn, + assetType: associatedUrn?.includes('urn:li:schemaField') ? EntityType.SchemaField : entityType, + values: propValues, + }); if (refetch) { refetch(); } else { entityRefetch(); } message.destroy(); - message.success('Successfully updated structured property!'); + message.success(`Successfully ${isAddMode ? 'added' : 'updated'} structured property!`); closeModal(); }) .catch((error) => { @@ -84,7 +99,7 @@ export default function EditStructuredPropertyModal({ return ( Cancel - } diff --git a/datahub-web-react/src/app/entity/shared/tabs/Properties/PropertiesTab.tsx b/datahub-web-react/src/app/entity/shared/tabs/Properties/PropertiesTab.tsx index eeff8fc2e2795..5fc209688c957 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/Properties/PropertiesTab.tsx +++ b/datahub-web-react/src/app/entity/shared/tabs/Properties/PropertiesTab.tsx @@ -44,6 +44,7 @@ export const PropertiesTab = () => { render: (propertyRow: PropertyRow) => ( v.value) || []} /> ), @@ -51,9 +52,12 @@ export const PropertiesTab = () => { } const { structuredPropertyRows, expandedRowsFromFilter } = useStructuredProperties(entityRegistry, filterText); + const filteredStructuredPropertyRows = structuredPropertyRows.filter( + (row) => !row.structuredProperty?.settings?.isHidden, + ); const customProperties = getFilteredCustomProperties(filterText, entityData) || []; const customPropertyRows = mapCustomPropertiesToPropertyRows(customProperties); - const dataSource: PropertyRow[] = structuredPropertyRows.concat(customPropertyRows); + const dataSource: PropertyRow[] = filteredStructuredPropertyRows.concat(customPropertyRows); const [expandedRows, setExpandedRows] = useState>(new Set()); diff --git a/datahub-web-react/src/app/entity/shared/tabs/Properties/StructuredPropertyValue.tsx b/datahub-web-react/src/app/entity/shared/tabs/Properties/StructuredPropertyValue.tsx index b1a01f2b69fe1..2ed4ab79a41ee 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/Properties/StructuredPropertyValue.tsx +++ b/datahub-web-react/src/app/entity/shared/tabs/Properties/StructuredPropertyValue.tsx @@ -1,24 +1,26 @@ import Icon from '@ant-design/icons/lib/components/Icon'; -import React, { useState } from 'react'; +import React from 'react'; import Highlight from 'react-highlighter'; -import { Button, Typography } from 'antd'; +import { Typography } from 'antd'; import styled from 'styled-components'; import { ValueColumnData } from './types'; import { ANTD_GRAY } from '../../constants'; import { useEntityRegistry } from '../../../../useEntityRegistry'; import ExternalLink from '../../../../../images/link-out.svg?react'; -import MarkdownViewer, { MarkdownView } from '../../components/legacy/MarkdownViewer'; +import CompactMarkdownViewer from '../Documentation/components/CompactMarkdownViewer'; import EntityIcon from '../../components/styled/EntityIcon'; const ValueText = styled(Typography.Text)` font-family: 'Manrope'; font-weight: 400; - font-size: 14px; + font-size: 12px; color: ${ANTD_GRAY[9]}; display: block; + width: 100%; + margin-bottom: 2px; - ${MarkdownView} { - font-size: 14px; + .remirror-editor.ProseMirror { + font-size: 12px; } `; @@ -28,38 +30,56 @@ const StyledIcon = styled(Icon)` const IconWrapper = styled.span` margin-right: 4px; + display: flex; `; -const StyledButton = styled(Button)` - margin-top: 2px; +const EntityWrapper = styled.div` + display: flex; + align-items: center; +`; + +const EntityName = styled(Typography.Text)` + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +`; + +const StyledHighlight = styled(Highlight)<{ truncateText?: boolean }>` + line-height: 1.5; + text-wrap: wrap; + + ${(props) => + props.truncateText && + ` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + display: block; + `} `; interface Props { value: ValueColumnData; isRichText?: boolean; filterText?: string; + truncateText?: boolean; + isFieldColumn?: boolean; } -const MAX_CHARACTERS = 200; - -export default function StructuredPropertyValue({ value, isRichText, filterText }: Props) { +export default function StructuredPropertyValue({ value, isRichText, filterText, truncateText, isFieldColumn }: Props) { const entityRegistry = useEntityRegistry(); - const [showMore, setShowMore] = useState(false); - - const toggleShowMore = () => { - setShowMore(!showMore); - }; - - const valueAsString = value?.value?.toString() ?? ''; return ( {value.entity ? ( - <> + - + - {entityRegistry.getDisplayName(value.entity.type, value.entity)} + + {entityRegistry.getDisplayName(value.entity.type, value.entity)} + - + ) : ( <> {isRichText ? ( - + ) : ( <> - - {showMore ? valueAsString : valueAsString?.substring(0, MAX_CHARACTERS)} - - {valueAsString?.length > MAX_CHARACTERS && ( - - {showMore ? 'Show less' : 'Show more'} - + {truncateText ? ( + + {value.value?.toString() ||
    } + + ) : ( + + {value.value?.toString() ||
    } + )} )} diff --git a/datahub-web-react/src/app/entity/shared/tabs/Properties/TabHeader.tsx b/datahub-web-react/src/app/entity/shared/tabs/Properties/TabHeader.tsx index 9e0b4992d9c78..192b840b50040 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/Properties/TabHeader.tsx +++ b/datahub-web-react/src/app/entity/shared/tabs/Properties/TabHeader.tsx @@ -1,8 +1,10 @@ import { SearchOutlined } from '@ant-design/icons'; +import { Maybe, StructuredProperties } from '@src/types.generated'; import { Input } from 'antd'; import React from 'react'; import styled from 'styled-components'; import { ANTD_GRAY } from '../../constants'; +import AddPropertyButton from './AddPropertyButton'; const StyledInput = styled(Input)` border-radius: 70px; @@ -12,13 +14,18 @@ const StyledInput = styled(Input)` const TableHeader = styled.div` padding: 8px 16px; border-bottom: 1px solid ${ANTD_GRAY[4.5]}; + display: flex; + justify-content: space-between; `; interface Props { setFilterText: (text: string) => void; + fieldUrn?: string; + fieldProperties?: Maybe; + refetch?: () => void; } -export default function TabHeader({ setFilterText }: Props) { +export default function TabHeader({ setFilterText, fieldUrn, fieldProperties, refetch }: Props) { return ( } /> + ); } diff --git a/datahub-web-react/src/app/entity/shared/tabs/Properties/types.ts b/datahub-web-react/src/app/entity/shared/tabs/Properties/types.ts index b93ba886d5a64..4adaafc3d98b6 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/Properties/types.ts +++ b/datahub-web-react/src/app/entity/shared/tabs/Properties/types.ts @@ -22,4 +22,5 @@ export interface PropertyRow { dataType?: DataTypeEntity; isParentRow?: boolean; structuredProperty?: StructuredPropertyEntity; + associatedUrn?: string; } diff --git a/datahub-web-react/src/app/entity/shared/tabs/Properties/useStructuredProperties.tsx b/datahub-web-react/src/app/entity/shared/tabs/Properties/useStructuredProperties.tsx index 86365b8232905..18ee6bb18da3d 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/Properties/useStructuredProperties.tsx +++ b/datahub-web-react/src/app/entity/shared/tabs/Properties/useStructuredProperties.tsx @@ -23,6 +23,25 @@ export function mapStructuredPropertyValues(structuredPropertiesEntry: Structure })); } +export function mapStructuredPropertyToPropertyRow(structuredPropertiesEntry: StructuredPropertiesEntry) { + const { displayName, qualifiedName } = structuredPropertiesEntry.structuredProperty.definition; + return { + displayName: displayName || qualifiedName, + qualifiedName, + values: mapStructuredPropertyValues(structuredPropertiesEntry), + dataType: structuredPropertiesEntry.structuredProperty.definition.valueType, + structuredProperty: structuredPropertiesEntry.structuredProperty, + type: + structuredPropertiesEntry.values[0] && structuredPropertiesEntry.values[0].__typename + ? { + type: typeNameToType[structuredPropertiesEntry.values[0].__typename].type, + nativeDataType: typeNameToType[structuredPropertiesEntry.values[0].__typename].nativeDataType, + } + : undefined, + associatedUrn: structuredPropertiesEntry.associatedUrn, + }; +} + // map the properties map into a list of PropertyRow objects to render in a table function getStructuredPropertyRows(entityData?: GenericEntityProperties | null) { const structuredPropertyRows: PropertyRow[] = []; @@ -45,6 +64,7 @@ function getStructuredPropertyRows(entityData?: GenericEntityProperties | null) typeNameToType[structuredPropertiesEntry.values[0].__typename].nativeDataType, } : undefined, + associatedUrn: structuredPropertiesEntry.associatedUrn, }); }); diff --git a/datahub-web-react/src/app/entity/shared/types.ts b/datahub-web-react/src/app/entity/shared/types.ts index 00e501740c2ad..ceba5b4bf30eb 100644 --- a/datahub-web-react/src/app/entity/shared/types.ts +++ b/datahub-web-react/src/app/entity/shared/types.ts @@ -122,6 +122,7 @@ export type GenericEntityProperties = { browsePathV2?: Maybe; inputOutput?: Maybe; forms?: Maybe; + parent?: Maybe; }; export type GenericEntityUpdate = { diff --git a/datahub-web-react/src/app/entity/structuredProperty/StructuredPropertyEntity.tsx b/datahub-web-react/src/app/entity/structuredProperty/StructuredPropertyEntity.tsx new file mode 100644 index 0000000000000..2bdfd550652fa --- /dev/null +++ b/datahub-web-react/src/app/entity/structuredProperty/StructuredPropertyEntity.tsx @@ -0,0 +1,86 @@ +import * as React from 'react'; +import styled from 'styled-components'; +import TableIcon from '@src/images/table-icon.svg?react'; +import { TYPE_ICON_CLASS_NAME } from '@src/app/shared/constants'; +import DefaultPreviewCard from '@src/app/preview/DefaultPreviewCard'; +import { EntityType, SearchResult, StructuredPropertyEntity as StructuredProperty } from '../../../types.generated'; +import { Entity, IconStyleType, PreviewType } from '../Entity'; +import { getDataForEntityType } from '../shared/containers/profile/utils'; +import { urlEncodeUrn } from '../shared/utils'; + +const PreviewPropIcon = styled(TableIcon)` + font-size: 20px; +`; + +/** + * Definition of the DataHub Structured Property entity. + */ +export class StructuredPropertyEntity implements Entity { + type: EntityType = EntityType.StructuredProperty; + + icon = (fontSize?: number, styleType?: IconStyleType, color?: string) => { + if (styleType === IconStyleType.TAB_VIEW) { + return ; + } + + if (styleType === IconStyleType.HIGHLIGHT) { + return ; + } + + return ( + + ); + }; + + isSearchEnabled = () => false; + + isBrowseEnabled = () => false; + + isLineageEnabled = () => false; + + getAutoCompleteFieldName = () => 'name'; + + getGraphName = () => 'structuredProperty'; + + getPathName: () => string = () => this.getGraphName(); + + getCollectionName: () => string = () => 'Structured Properties'; + + getEntityName: () => string = () => 'Structured Property'; + + renderProfile: (urn: string) => JSX.Element = (_urn) =>
    ; // not used right now + + renderPreview = (previewType: PreviewType, data: StructuredProperty) => ( + } + typeIcon={this.icon(14, IconStyleType.ACCENT)} + previewType={previewType} + /> + ); + + renderSearch = (result: SearchResult) => { + return this.renderPreview(PreviewType.SEARCH, result.entity as StructuredProperty); + }; + + displayName = (data: StructuredProperty) => { + return data.definition?.displayName || data.definition?.qualifiedName || data.urn; + }; + + getGenericEntityProperties = (entity: StructuredProperty) => { + return getDataForEntityType({ data: entity, entityType: this.type, getOverrideProperties: (data) => data }); + }; + + supportedCapabilities = () => { + return new Set([]); + }; +} diff --git a/datahub-web-react/src/app/entity/tag/Tag.tsx b/datahub-web-react/src/app/entity/tag/Tag.tsx index 6f0839e5f812b..d3c5b07966099 100644 --- a/datahub-web-react/src/app/entity/tag/Tag.tsx +++ b/datahub-web-react/src/app/entity/tag/Tag.tsx @@ -46,6 +46,8 @@ export class TagEntity implements Entity { getAutoCompleteFieldName = () => 'name'; + getGraphName = () => 'tag'; + getPathName: () => string = () => 'tag'; getCollectionName: () => string = () => 'Tags'; diff --git a/datahub-web-react/src/app/entity/user/User.tsx b/datahub-web-react/src/app/entity/user/User.tsx index ec1c5fbdc8698..058349f83eaec 100644 --- a/datahub-web-react/src/app/entity/user/User.tsx +++ b/datahub-web-react/src/app/entity/user/User.tsx @@ -39,6 +39,8 @@ export class UserEntity implements Entity { getAutoCompleteFieldName = () => 'username'; + getGraphName: () => string = () => 'corpuser'; + getPathName: () => string = () => 'user'; getEntityName = () => 'Person'; diff --git a/datahub-web-react/src/app/govern/structuredProperties/AdvancedOptions.tsx b/datahub-web-react/src/app/govern/structuredProperties/AdvancedOptions.tsx new file mode 100644 index 0000000000000..620143258ef5f --- /dev/null +++ b/datahub-web-react/src/app/govern/structuredProperties/AdvancedOptions.tsx @@ -0,0 +1,64 @@ +import { Icon, Input, Text, Tooltip } from '@components'; +import { Collapse, Form } from 'antd'; +import React from 'react'; +import { CollapseHeader, FlexContainer, InputLabel, StyledCollapse } from './styledComponents'; + +interface Props { + isEditMode: boolean; +} + +const AdvancedOptions = ({ isEditMode }: Props) => { + return ( + ( + + )} + expandIconPosition="end" + defaultActiveKey={[]} + > + + + Advanced Options + + + } + forceRender + > + + + Qualified Name + + + + + + + + + + + + + ); +}; + +export default AdvancedOptions; diff --git a/datahub-web-react/src/app/govern/structuredProperties/AllowedValuesDrawer.tsx b/datahub-web-react/src/app/govern/structuredProperties/AllowedValuesDrawer.tsx new file mode 100644 index 0000000000000..f1dccb6db0c22 --- /dev/null +++ b/datahub-web-react/src/app/govern/structuredProperties/AllowedValuesDrawer.tsx @@ -0,0 +1,142 @@ +import { Button, Icon, Input, Text, TextArea } from '@src/alchemy-components'; +import { AllowedValue } from '@src/types.generated'; +import { Form, FormInstance } from 'antd'; +import { Tooltip } from '@components'; +import React, { useEffect, useRef } from 'react'; +import { + AddButtonContainer, + DeleteIconContainer, + FieldGroupContainer, + FormContainer, + InputLabel, + StyledDivider, + ValuesContainer, +} from './styledComponents'; +import { PropValueField } from './utils'; + +interface Props { + showAllowedValuesDrawer: boolean; + propType: PropValueField; + allowedValues: AllowedValue[] | undefined; + isEditMode: boolean; + noOfExistingValues: number; + form: FormInstance; +} + +const AllowedValuesDrawer = ({ + showAllowedValuesDrawer, + propType, + allowedValues, + isEditMode, + noOfExistingValues, + form, +}: Props) => { + useEffect(() => { + form.setFieldsValue({ allowedValues: allowedValues || [{}] }); + }, [form, showAllowedValuesDrawer, allowedValues]); + + const containerRef = useRef(null); + + // Scroll to the bottom to show the newly added fields + const scrollToBottom = () => { + if (containerRef.current) { + containerRef.current.scrollTop = containerRef.current.scrollHeight; + } + }; + + return ( +
    + + {(fields, { add, remove }) => ( + + {fields.length > 0 && ( + + {fields.map((field, index) => { + const isExisting = isEditMode && index < noOfExistingValues; + + return ( + + + Value + + * + + + + + + + + +