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.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: (
+ handleOptionClick(entity)}>
+
+ {entity.definition?.displayName}
+
+
+ ),
+ 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(true)}>
- Edit
-
+
+
+
+
+
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
-
- Update
+
+ {isAddMode ? 'Add' : 'Update'}
>
}
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
+
+ *
+
+
+
+
+
+
+
+
+
+
+ {!isExisting && (
+
+
+ remove(field.name)}
+ color="gray"
+ size="xl"
+ />
+
+
+ )}
+ {index < fields.length - 1 && }
+
+ );
+ })}
+
+ )}
+
+
+
+ {
+ add();
+ setTimeout(() => scrollToBottom(), 0);
+ }}
+ color="violet"
+ >
+ Add
+
+
+
+
+ )}
+
+
+ );
+};
+
+export default AllowedValuesDrawer;
diff --git a/datahub-web-react/src/app/govern/structuredProperties/AllowedValuesField.tsx b/datahub-web-react/src/app/govern/structuredProperties/AllowedValuesField.tsx
new file mode 100644
index 0000000000000..9a71d309c101f
--- /dev/null
+++ b/datahub-web-react/src/app/govern/structuredProperties/AllowedValuesField.tsx
@@ -0,0 +1,77 @@
+import { Icon, Text, Tooltip } from '@components';
+import { AllowedValue } from '@src/types.generated';
+import React from 'react';
+import {
+ FieldLabel,
+ FlexContainer,
+ ItemsContainer,
+ RowContainer,
+ StyledIcon,
+ ValueListContainer,
+ ValuesList,
+ ValueType,
+ VerticalDivider,
+} from './styledComponents';
+import { isStringOrNumberTypeSelected, PropValueField } from './utils';
+
+interface Props {
+ selectedValueType: string;
+ allowedValues: AllowedValue[] | undefined;
+ valueField: PropValueField;
+ setShowAllowedValuesDrawer: React.Dispatch>;
+}
+
+const AllowedValuesField = ({ selectedValueType, allowedValues, valueField, setShowAllowedValuesDrawer }: Props) => {
+ return (
+ <>
+ {isStringOrNumberTypeSelected(selectedValueType) && (
+
+
+
+ Allowed Values
+
+
+
+
+
+
+ {allowedValues && allowedValues.length > 0 ? (
+
+
+ {allowedValues.map((val, index) => {
+ return (
+ <>
+ {val[valueField]}
+ {index < allowedValues.length - 1 && }
+ >
+ );
+ })}
+
+
+ setShowAllowedValuesDrawer(true)}
+ />
+
+
+ ) : (
+
+ Any
+ {valueField === 'stringValue' ? 'text' : 'number'}
+ value will be allowed
+
+ setShowAllowedValuesDrawer(true)} />
+
+
+ )}
+
+ )}
+ >
+ );
+};
+
+export default AllowedValuesField;
diff --git a/datahub-web-react/src/app/govern/structuredProperties/DisplayPreferences.tsx b/datahub-web-react/src/app/govern/structuredProperties/DisplayPreferences.tsx
new file mode 100644
index 0000000000000..260c91ef93207
--- /dev/null
+++ b/datahub-web-react/src/app/govern/structuredProperties/DisplayPreferences.tsx
@@ -0,0 +1,165 @@
+import { Icon, Pill, Switch, Text } from '@src/alchemy-components';
+import { ConfirmationModal } from '@src/app/sharedV2/modals/ConfirmationModal';
+import { AllowedValue, StructuredPropertyEntity } from '@src/types.generated';
+import { Collapse } from 'antd';
+import React, { useState } from 'react';
+import { useUpdateStructuredPropertyMutation } from '@src/graphql/structuredProperties.generated';
+import { CollapseHeader, StyledCollapse, StyledFormItem, TogglesContainer } from './styledComponents';
+import { getDisplayName, canBeAssetBadge, StructuredProp } from './utils';
+
+const SCHEMA_FIELD_URN = 'urn:li:entityType:datahub.schemaField';
+
+interface Props {
+ formValues: StructuredProp | undefined;
+ handleDisplaySettingChange: (settingField: string, value: boolean) => void;
+ selectedValueType: string;
+ refetchProperties: () => void;
+ allowedValues?: AllowedValue[];
+ badgeProperty?: StructuredPropertyEntity;
+}
+
+const DisplayPreferences = ({
+ formValues,
+ handleDisplaySettingChange,
+ selectedValueType,
+ refetchProperties,
+ allowedValues,
+ badgeProperty,
+}: Props) => {
+ const [updateProperty] = useUpdateStructuredPropertyMutation();
+ const [showReplaceBadge, setShowReplaceBadge] = useState(false);
+
+ const handleReplaceClose = () => {
+ setShowReplaceBadge(false);
+ };
+
+ function updateBadgePropertyToOff() {
+ if (badgeProperty) {
+ updateProperty({
+ variables: { input: { urn: badgeProperty.urn, settings: { showAsAssetBadge: false } } },
+ }).then(() => refetchProperties());
+ }
+ }
+
+ const hasAssetBadgeEnabled = formValues?.settings?.showAsAssetBadge;
+ const showInColumnsTable = formValues?.settings?.showInColumnsTable;
+ const hasColumnEntityType = formValues?.entityTypes?.includes(SCHEMA_FIELD_URN);
+
+ return (
+ <>
+ (
+
+ )}
+ expandIconPosition="end"
+ defaultActiveKey={[1]}
+ >
+
+
+ Display Preferences
+
+
+ }
+ forceRender
+ >
+
+
+ handleDisplaySettingChange('isHidden', e.target.checked)}
+ labelHoverText="If enabled, this property will be hidden everywhere"
+ data-testid="structured-props-hide-switch"
+ />
+
+
+ handleDisplaySettingChange('showInSearchFilters', e.target.checked)}
+ isDisabled={formValues?.settings?.isHidden}
+ labelHoverText="If enabled, this property will appear in search filters"
+ />
+
+
+ handleDisplaySettingChange('showInAssetSummary', e.target.checked)}
+ isDisabled={formValues?.settings?.isHidden}
+ labelHoverText="If enabled, this property will appear in asset sidebar"
+ />
+
+
+ {
+ if (badgeProperty && e.target.checked) setShowReplaceBadge(true);
+ else handleDisplaySettingChange('showAsAssetBadge', e.target.checked);
+ }}
+ isDisabled={
+ !hasAssetBadgeEnabled &&
+ (formValues?.settings?.isHidden ||
+ !canBeAssetBadge(selectedValueType, allowedValues))
+ }
+ labelHoverText="If enabled, this property will appear as asset badge"
+ disabledHoverText="Only Text or Number property types with allowed values defined can appear as an asset badge."
+ />
+
+
+ handleDisplaySettingChange('showInColumnsTable', e.target.checked)}
+ isDisabled={
+ !showInColumnsTable && (formValues?.settings?.isHidden || !hasColumnEntityType)
+ }
+ labelHoverText="If enabled, this property will appear as a column in the Columns table for Datasets"
+ disabledHoverText="Property must apply to Columns in order to show in columns table."
+ data-testid="structured-props-show-in-columns-table-switch"
+ />
+
+
+
+
+ {badgeProperty && (
+ {
+ handleDisplaySettingChange('showAsAssetBadge', true);
+ setShowReplaceBadge(false);
+ updateBadgePropertyToOff();
+ }}
+ confirmButtonText="Update"
+ modalTitle="Update Property"
+ modalText={
+
+ Another property
+
+ is already being shown on asset previews, but only one property is allowed at a time.
+ Do you want to replace the current property? This will hide PropVal on all asset previews.
+
+ }
+ />
+ )}
+ >
+ );
+};
+
+export default DisplayPreferences;
diff --git a/datahub-web-react/src/app/govern/structuredProperties/EmptyStructuredProperties.tsx b/datahub-web-react/src/app/govern/structuredProperties/EmptyStructuredProperties.tsx
new file mode 100644
index 0000000000000..5f7f77b0582fd
--- /dev/null
+++ b/datahub-web-react/src/app/govern/structuredProperties/EmptyStructuredProperties.tsx
@@ -0,0 +1,29 @@
+import { Text } from '@components';
+import EmptyFormsImage from '@src/images/empty-forms.svg?react';
+import React from 'react';
+import { EmptyContainer } from './styledComponents';
+
+interface Props {
+ isEmptySearch?: boolean;
+}
+
+const EmptyStructuredProperties = ({ isEmptySearch }: Props) => {
+ return (
+
+ {isEmptySearch ? (
+
+ No search results!
+
+ ) : (
+ <>
+
+
+ No properties yet!
+
+ >
+ )}
+
+ );
+};
+
+export default EmptyStructuredProperties;
diff --git a/datahub-web-react/src/app/govern/structuredProperties/RequiredAsterisk.tsx b/datahub-web-react/src/app/govern/structuredProperties/RequiredAsterisk.tsx
new file mode 100644
index 0000000000000..5842c363b3b31
--- /dev/null
+++ b/datahub-web-react/src/app/govern/structuredProperties/RequiredAsterisk.tsx
@@ -0,0 +1,12 @@
+import { Text } from '@components';
+import React from 'react';
+
+const RequiredAsterisk = () => {
+ return (
+
+ *
+
+ );
+};
+
+export default RequiredAsterisk;
diff --git a/datahub-web-react/src/app/govern/structuredProperties/StructuredProperties.tsx b/datahub-web-react/src/app/govern/structuredProperties/StructuredProperties.tsx
new file mode 100644
index 0000000000000..304bb77f37ed4
--- /dev/null
+++ b/datahub-web-react/src/app/govern/structuredProperties/StructuredProperties.tsx
@@ -0,0 +1,115 @@
+import { Button, PageTitle, SearchBar, Tooltip } from '@components';
+import analytics, { EventType } from '@src/app/analytics';
+import { useUserContext } from '@src/app/context/useUserContext';
+import { useGetSearchResultsForMultipleQuery } from '@src/graphql/search.generated';
+import { EntityType, SearchResult, StructuredPropertyEntity } from '@src/types.generated';
+import React, { useState } from 'react';
+import StructuredPropsDrawer from './StructuredPropsDrawer';
+import StructuredPropsTable from './StructuredPropsTable';
+import { ButtonContainer, HeaderContainer, HeaderContent, PageContainer, TableContainer } from './styledComponents';
+import ViewStructuredPropsDrawer from './ViewStructuredPropsDrawer';
+
+const StructuredProperties = () => {
+ const [searchQuery, setSearchQuery] = useState('');
+ const [isDrawerOpen, setIsDrawerOpen] = useState(false);
+ const [isViewDrawerOpen, setIsViewDrawerOpen] = useState(false);
+ const [selectedProperty, setSelectedProperty] = useState();
+ const me = useUserContext();
+ const canEditProps = me.platformPrivileges?.manageStructuredProperties;
+
+ const handleSearch = (value) => {
+ setSearchQuery(value);
+ };
+
+ const inputs = {
+ types: [EntityType.StructuredProperty],
+ query: '',
+ start: 0,
+ count: 500,
+ searchFlags: { skipCache: true },
+ };
+
+ // Execute search
+ const { data, loading, refetch } = useGetSearchResultsForMultipleQuery({
+ variables: {
+ input: inputs,
+ },
+ fetchPolicy: 'cache-first',
+ });
+
+ const searchAcrossEntities = data?.searchAcrossEntities;
+ const noOfProperties = searchAcrossEntities?.searchResults?.length;
+ const badgeProperty = searchAcrossEntities?.searchResults.find(
+ (prop) => (prop.entity as StructuredPropertyEntity).settings?.showAsAssetBadge,
+ )?.entity;
+
+ return (
+
+
+
+
+
+
+
+ {
+ setIsDrawerOpen(true);
+ analytics.event({ type: EventType.CreateStructuredPropertyClickEvent });
+ }}
+ >
+ Create
+
+
+
+
+ handleSearch(value)} />
+
+
+
+
+ {selectedProperty && (
+
+ )}
+
+ );
+};
+
+export default StructuredProperties;
diff --git a/datahub-web-react/src/app/govern/structuredProperties/StructuredPropsDrawer.tsx b/datahub-web-react/src/app/govern/structuredProperties/StructuredPropsDrawer.tsx
new file mode 100644
index 0000000000000..4b2bbaaf96826
--- /dev/null
+++ b/datahub-web-react/src/app/govern/structuredProperties/StructuredPropsDrawer.tsx
@@ -0,0 +1,396 @@
+import { LoadingOutlined } from '@ant-design/icons';
+import { useApolloClient } from '@apollo/client';
+import { Button, Text } from '@src/alchemy-components';
+import analytics, { EventType } from '@src/app/analytics';
+import { useUserContext } from '@src/app/context/useUserContext';
+import { showToastMessage, ToastType } from '@src/app/sharedV2/toastMessageUtils';
+import {
+ useCreateStructuredPropertyMutation,
+ useUpdateStructuredPropertyMutation,
+} from '@src/graphql/structuredProperties.generated';
+import {
+ AllowedValue,
+ PropertyCardinality,
+ SearchAcrossEntitiesInput,
+ SearchResult,
+ SearchResults,
+ StructuredPropertyEntity,
+ UpdateStructuredPropertyInput,
+} from '@src/types.generated';
+import { Form } from 'antd';
+import { Tooltip } from '@components';
+import React, { useEffect, useState } from 'react';
+import AllowedValuesDrawer from './AllowedValuesDrawer';
+import { updatePropertiesList } from './cacheUtils';
+import StructuredPropsForm from './StructuredPropsForm';
+import {
+ DrawerHeader,
+ FooterContainer,
+ StyledDrawer,
+ StyledIcon,
+ StyledSpin,
+ TitleContainer,
+} from './styledComponents';
+import useStructuredProp from './useStructuredProp';
+import {
+ getDisplayName,
+ getNewAllowedTypes,
+ getNewAllowedValues,
+ getNewEntityTypes,
+ getStringOrNumberValueField,
+ getValueType,
+ PropValueField,
+ StructuredProp,
+ valueTypes,
+} from './utils';
+
+interface Props {
+ isDrawerOpen: boolean;
+ setIsDrawerOpen: React.Dispatch>;
+ selectedProperty?: SearchResult;
+ setSelectedProperty: React.Dispatch>;
+ refetch: () => void;
+ inputs: SearchAcrossEntitiesInput;
+ searchAcrossEntities?: SearchResults | null;
+ badgeProperty?: StructuredPropertyEntity;
+}
+
+const StructuredPropsDrawer = ({
+ isDrawerOpen,
+ setIsDrawerOpen,
+ selectedProperty,
+ setSelectedProperty,
+ refetch,
+ inputs,
+ searchAcrossEntities,
+ badgeProperty,
+}: Props) => {
+ const [form] = Form.useForm();
+ const [valuesForm] = Form.useForm();
+ const me = useUserContext();
+ const canEditProps = me.platformPrivileges?.manageStructuredProperties;
+
+ const [createStructuredProperty] = useCreateStructuredPropertyMutation();
+ const [updateStructuredProperty] = useUpdateStructuredPropertyMutation();
+ const client = useApolloClient();
+
+ const [cardinality, setCardinality] = useState(PropertyCardinality.Single);
+ const [formValues, setFormValues] = useState();
+ const [selectedValueType, setSelectedValueType] = useState('');
+ const [allowedValues, setAllowedValues] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [valueField, setValueField] = useState('stringValue');
+
+ const [showAllowedValuesDrawer, setShowAllowedValuesDrawer] = useState(false);
+
+ const { handleTypeUpdate } = useStructuredProp({
+ selectedProperty,
+ form,
+ setFormValues,
+ setCardinality,
+ setSelectedValueType,
+ });
+
+ const isEditMode = !!selectedProperty;
+
+ const clearValues = () => {
+ form.resetFields();
+ setIsDrawerOpen(false);
+ setSelectedProperty(undefined);
+ setFormValues(undefined);
+ setSelectedValueType('');
+ setAllowedValues(undefined);
+ setCardinality(PropertyCardinality.Single);
+ setShowAllowedValuesDrawer(false);
+ valuesForm.resetFields();
+ };
+
+ const handleClose = () => {
+ clearValues();
+ };
+
+ const showErrorMessage = () => {
+ showToastMessage(ToastType.ERROR, `Failed to ${isEditMode ? 'update' : 'create'} structured property.`, 3);
+ };
+
+ const showSuccessMessage = () => {
+ showToastMessage(ToastType.SUCCESS, `Structured property ${isEditMode ? 'updated' : 'created'}!`, 3);
+ };
+
+ const handleSubmit = () => {
+ if (isEditMode) {
+ form.validateFields().then(() => {
+ const updateValues = {
+ ...form.getFieldsValue(),
+ allowedValues,
+ };
+
+ const editInput: UpdateStructuredPropertyInput = {
+ urn: selectedProperty.entity.urn,
+ displayName: updateValues.displayName,
+ description: updateValues.description,
+ typeQualifier: {
+ newAllowedTypes: getNewAllowedTypes(
+ selectedProperty.entity as StructuredPropertyEntity,
+ updateValues,
+ ),
+ },
+ newEntityTypes: getNewEntityTypes(
+ selectedProperty.entity as StructuredPropertyEntity,
+ updateValues,
+ ),
+ newAllowedValues: getNewAllowedValues(
+ selectedProperty.entity as StructuredPropertyEntity,
+ updateValues,
+ ),
+ setCardinalityAsMultiple: cardinality === PropertyCardinality.Multiple,
+ settings: {
+ isHidden: updateValues.settings?.isHidden ?? false,
+ showInSearchFilters: updateValues.settings?.showInSearchFilters ?? false,
+ showAsAssetBadge: updateValues.settings?.showAsAssetBadge ?? false,
+ showInAssetSummary: updateValues.settings?.showInAssetSummary ?? false,
+ showInColumnsTable: updateValues.settings?.showInColumnsTable ?? false,
+ },
+ };
+
+ setIsLoading(true);
+ updateStructuredProperty({
+ variables: {
+ input: editInput,
+ },
+ })
+ .then(() => {
+ analytics.event({
+ type: EventType.EditStructuredPropertyEvent,
+ propertyUrn: selectedProperty.entity.urn,
+ propertyType:
+ valueTypes.find((valType) => valType.value === form.getFieldValue('valueType'))?.urn ||
+ '',
+ appliesTo: form.getFieldValue('entityTypes'),
+ qualifiedName: form.getFieldValue('qualifiedName'),
+ allowedAssetTypes: form.getFieldValue(['typeQualifier', 'allowedTypes']),
+ allowedValues: form.getFieldValue('allowedValues'),
+ cardinality,
+ isHidden: form.getFieldValue(['settings', 'isHidden']) ?? false,
+ showInSearchFilters: form.getFieldValue(['settings', 'showInSearchFilters']) ?? false,
+ showAsAssetBadge: form.getFieldValue(['settings', 'showAsAssetBadge']) ?? false,
+ showInAssetSummary: form.getFieldValue(['settings', 'showInAssetSummary']) ?? false,
+ showInColumnsTable: form.getFieldValue(['settings', 'showInColumnsTable']) ?? false,
+ });
+ refetch();
+ showSuccessMessage();
+ })
+ .catch(() => {
+ showErrorMessage();
+ })
+ .finally(() => {
+ setIsLoading(false);
+ clearValues();
+ });
+ });
+ } else {
+ form.validateFields().then(() => {
+ const createInput = {
+ ...form.getFieldsValue(),
+ valueType: valueTypes.find((type) => type.value === form.getFieldValue('valueType'))?.urn,
+ allowedValues,
+ cardinality,
+ settings: {
+ isHidden: form.getFieldValue(['settings', 'isHidden']) ?? false,
+ showInSearchFilters: form.getFieldValue(['settings', 'showInSearchFilters']) ?? false,
+ showAsAssetBadge: form.getFieldValue(['settings', 'showAsAssetBadge']) ?? false,
+ showInAssetSummary: form.getFieldValue(['settings', 'showInAssetSummary']) ?? false,
+ showInColumnsTable: form.getFieldValue(['settings', 'showInColumnsTable']) ?? false,
+ },
+ };
+
+ setIsLoading(true);
+ createStructuredProperty({
+ variables: {
+ input: createInput,
+ },
+ })
+ .then((res) => {
+ analytics.event({
+ type: EventType.CreateStructuredPropertyEvent,
+ propertyType:
+ valueTypes.find((valType) => valType.value === form.getFieldValue('valueType'))?.urn ||
+ '',
+ appliesTo: form.getFieldValue('entityTypes'),
+ qualifiedName: form.getFieldValue('qualifiedName'),
+ allowedAssetTypes: form.getFieldValue(['typeQualifier', 'allowedTypes']),
+ allowedValues: form.getFieldValue('allowedValues'),
+ cardinality,
+ isHidden: form.getFieldValue(['settings', 'isHidden']) ?? false,
+ showInSearchFilters: form.getFieldValue(['settings', 'showInSearchFilters']) ?? false,
+ showAsAssetBadge: form.getFieldValue(['settings', 'showAsAssetBadge']) ?? false,
+ showInAssetSummary: form.getFieldValue(['settings', 'showInAssetSummary']) ?? false,
+ showInColumnsTable: form.getFieldValue(['settings', 'showInColumnsTable']) ?? false,
+ });
+
+ showSuccessMessage();
+ updatePropertiesList(client, inputs, res.data?.createStructuredProperty, searchAcrossEntities);
+ })
+ .catch(() => {
+ showErrorMessage();
+ })
+ .finally(() => {
+ setIsLoading(false);
+ clearValues();
+ });
+ });
+ }
+ };
+
+ useEffect(() => {
+ if (selectedProperty) {
+ const entity = selectedProperty.entity as StructuredPropertyEntity;
+ const typeValue = getValueType(
+ entity.definition.valueType.urn,
+ entity.definition.cardinality || PropertyCardinality.Single,
+ );
+
+ const values: StructuredProp = {
+ displayName: getDisplayName(entity),
+ description: entity.definition.description,
+ qualifiedName: entity.definition.qualifiedName,
+ valueType: typeValue,
+ entityTypes: entity.definition.entityTypes.map((entityType) => entityType.urn),
+ typeQualifier: {
+ allowedTypes: entity.definition.typeQualifier?.allowedTypes?.map((entityType) => entityType.urn),
+ },
+ immutable: entity.definition.immutable,
+ settings: entity.settings,
+ };
+
+ setFormValues(values);
+ if (typeValue) handleTypeUpdate(typeValue);
+ form.setFieldsValue(values);
+ } else {
+ setFormValues(undefined);
+ form.resetFields();
+ setSelectedValueType('');
+ }
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectedProperty, form]);
+
+ useEffect(() => {
+ const entity = selectedProperty?.entity as StructuredPropertyEntity;
+ const field = getStringOrNumberValueField(selectedValueType);
+ setValueField(field);
+ const allowedList = entity?.definition?.allowedValues?.map((item) => {
+ return {
+ [field]: item.value[field],
+ description: item.description,
+ } as AllowedValue;
+ });
+ setAllowedValues(allowedList);
+ }, [selectedProperty, selectedValueType, setAllowedValues]);
+
+ const handleUpdateAllowedValues = () => {
+ valuesForm.validateFields().then(() => {
+ setAllowedValues(valuesForm.getFieldValue('allowedValues'));
+ form.setFieldValue('allowedValues', valuesForm.getFieldValue('allowedValues'));
+ setShowAllowedValuesDrawer(false);
+ });
+ };
+
+ return (
+
+ {showAllowedValuesDrawer ? (
+
+ setShowAllowedValuesDrawer(false)}
+ />
+
+ Allowed Values
+
+
+ ) : (
+
+ {`${isEditMode ? 'Edit' : 'Create'} Structured Property`}
+
+ )}
+
+
+ }
+ footer={
+
+
+ {showAllowedValuesDrawer ? (
+
+ Update Allowed Values
+
+ ) : (
+
+ {isEditMode ? 'Update' : 'Create'}
+
+ )}
+
+
+ }
+ destroyOnClose
+ >
+ }>
+ {showAllowedValuesDrawer ? (
+ <>
+
+ >
+ ) : (
+
+ )}
+
+
+ );
+};
+
+export default StructuredPropsDrawer;
diff --git a/datahub-web-react/src/app/govern/structuredProperties/StructuredPropsForm.tsx b/datahub-web-react/src/app/govern/structuredProperties/StructuredPropsForm.tsx
new file mode 100644
index 0000000000000..5159936722884
--- /dev/null
+++ b/datahub-web-react/src/app/govern/structuredProperties/StructuredPropsForm.tsx
@@ -0,0 +1,139 @@
+import { Icon, Input, SimpleSelect, TextArea } from '@src/alchemy-components';
+import { AllowedValue, PropertyCardinality, SearchResult, StructuredPropertyEntity } from '@src/types.generated';
+import { Form, FormInstance } from 'antd';
+import { Tooltip } from '@components';
+import React from 'react';
+import AdvancedOptions from './AdvancedOptions';
+import RequiredAsterisk from './RequiredAsterisk';
+import DisplayPreferences from './DisplayPreferences';
+import StructuredPropsFormSection from './StructuredPropsFormSection';
+import { FieldLabel, FlexContainer, GridFormItem, RowContainer } from './styledComponents';
+import useStructuredProp from './useStructuredProp';
+import { PropValueField, StructuredProp, valueTypes } from './utils';
+
+interface Props {
+ selectedProperty: SearchResult | undefined;
+ form: FormInstance;
+ formValues: StructuredProp | undefined;
+ setFormValues: React.Dispatch>;
+ setCardinality: React.Dispatch>;
+ isEditMode: boolean;
+ selectedValueType: string;
+ setSelectedValueType: React.Dispatch>;
+ allowedValues: AllowedValue[] | undefined;
+ valueField: PropValueField;
+ setShowAllowedValuesDrawer: React.Dispatch>;
+ refetchProperties: () => void;
+ badgeProperty?: StructuredPropertyEntity;
+}
+
+const StructuredPropsForm = ({
+ selectedProperty,
+ form,
+ formValues,
+ setFormValues,
+ isEditMode,
+ setCardinality,
+ selectedValueType,
+ setSelectedValueType,
+ allowedValues,
+ valueField,
+ setShowAllowedValuesDrawer,
+ refetchProperties,
+ badgeProperty,
+}: Props) => {
+ const { handleTypeUpdate, handleDisplaySettingChange } = useStructuredProp({
+ selectedProperty,
+ form,
+ setFormValues,
+ setCardinality,
+ setSelectedValueType,
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+ Property Type
+
+
+
+
+
+
+
+
+
+ {
+ handleTypeUpdate(values[0]);
+ }}
+ placeholder="Select Property Type"
+ options={valueTypes}
+ values={formValues?.valueType ? [formValues?.valueType] : undefined}
+ isDisabled={isEditMode}
+ showDescriptions
+ data-testid="structured-props-select-input-type"
+ optionListTestId="structured-props-property-type-options-list"
+ />
+
+
+
+
+
+
+
+
+ );
+};
+
+export default StructuredPropsForm;
diff --git a/datahub-web-react/src/app/govern/structuredProperties/StructuredPropsFormSection.tsx b/datahub-web-react/src/app/govern/structuredProperties/StructuredPropsFormSection.tsx
new file mode 100644
index 0000000000000..82875c8f58a4d
--- /dev/null
+++ b/datahub-web-react/src/app/govern/structuredProperties/StructuredPropsFormSection.tsx
@@ -0,0 +1,176 @@
+import { Icon, SimpleSelect, Text, Tooltip } from '@src/alchemy-components';
+import { AllowedValue, PropertyCardinality, SearchResult } from '@src/types.generated';
+import { Form, FormInstance } from 'antd';
+import React from 'react';
+import AllowedValuesField from './AllowedValuesField';
+import RequiredAsterisk from './RequiredAsterisk';
+import { FieldLabel, FlexContainer, RowContainer, SubTextContainer } from './styledComponents';
+import useStructuredProp from './useStructuredProp';
+import {
+ APPLIES_TO_ENTITIES,
+ isEntityTypeSelected,
+ PropValueField,
+ SEARCHABLE_ENTITY_TYPES,
+ StructuredProp,
+} from './utils';
+
+interface Props {
+ selectedProperty: SearchResult | undefined;
+ form: FormInstance;
+ formValues: StructuredProp | undefined;
+ setFormValues: React.Dispatch>;
+ setCardinality: React.Dispatch>;
+ isEditMode: boolean;
+ selectedValueType: string;
+ setSelectedValueType: React.Dispatch>;
+ allowedValues: AllowedValue[] | undefined;
+ valueField: PropValueField;
+ setShowAllowedValuesDrawer: React.Dispatch>;
+}
+
+const StructuredPropsFormSection = ({
+ selectedProperty,
+ form,
+ formValues,
+ setFormValues,
+ isEditMode,
+ setCardinality,
+ selectedValueType,
+ setSelectedValueType,
+ allowedValues,
+ valueField,
+ setShowAllowedValuesDrawer,
+}: Props) => {
+ const {
+ handleSelectChange,
+ handleSelectUpdateChange,
+ getEntitiesListOptions,
+ disabledEntityTypeValues,
+ disabledTypeQualifierValues,
+ } = useStructuredProp({
+ selectedProperty,
+ form,
+ setFormValues,
+ setCardinality,
+ setSelectedValueType,
+ });
+
+ return (
+ <>
+ {!(isEditMode && !allowedValues) && (
+
+ )}
+ {isEntityTypeSelected(selectedValueType) && (
+
+
+
+ Allowed Entity Types
+
+
+
+
+ {isEditMode && (
+
+
+
+ (Add-only)
+
+
+
+ )}
+
+
+
+
+ isEditMode
+ ? handleSelectUpdateChange(['typeQualifier', 'allowedTypes'], values)
+ : handleSelectChange(['typeQualifier', 'allowedTypes'], values)
+ }
+ placeholder="Any"
+ isMultiSelect
+ values={formValues?.typeQualifier?.allowedTypes}
+ disabledValues={disabledTypeQualifierValues}
+ width="full"
+ />
+
+
+
+ )}
+
+
+
+ Applies to
+
+
+
+
+
+ {isEditMode && (
+
+
+
+ (Add-only)
+
+
+
+ )}
+
+
+
+
+ isEditMode
+ ? handleSelectUpdateChange('entityTypes', values)
+ : handleSelectChange('entityTypes', values)
+ }
+ placeholder="Select Entity Types"
+ isMultiSelect
+ values={formValues?.entityTypes ? formValues?.entityTypes : undefined}
+ disabledValues={disabledEntityTypeValues}
+ width="full"
+ showSelectAll
+ selectAllLabel="All Asset Types"
+ data-testid="structured-props-select-input-applies-to"
+ optionListTestId="applies-to-options-list"
+ />
+
+
+ >
+ );
+};
+
+export default StructuredPropsFormSection;
diff --git a/datahub-web-react/src/app/govern/structuredProperties/StructuredPropsTable.tsx b/datahub-web-react/src/app/govern/structuredProperties/StructuredPropsTable.tsx
new file mode 100644
index 0000000000000..10c42c0925eea
--- /dev/null
+++ b/datahub-web-react/src/app/govern/structuredProperties/StructuredPropsTable.tsx
@@ -0,0 +1,376 @@
+import { useApolloClient } from '@apollo/client';
+import { Icon, Pill, Table, Text, Tooltip } from '@components';
+import { AlignmentOptions } from '@src/alchemy-components/theme/config';
+import analytics, { EventType } from '@src/app/analytics';
+import { useUserContext } from '@src/app/context/useUserContext';
+import { HoverEntityTooltip } from '@src/app/recommendations/renderer/component/HoverEntityTooltip';
+import { CustomAvatar } from '@src/app/shared/avatar';
+import { toLocalDateString, toRelativeTimeString } from '@src/app/shared/time/timeUtils';
+import { ConfirmationModal } from '@src/app/sharedV2/modals/ConfirmationModal';
+import { showToastMessage, ToastType } from '@src/app/sharedV2/toastMessageUtils';
+import { useEntityRegistry } from '@src/app/useEntityRegistry';
+import { GetSearchResultsForMultipleQuery } from '@src/graphql/search.generated';
+import { useDeleteStructuredPropertyMutation } from '@src/graphql/structuredProperties.generated';
+import TableIcon from '@src/images/table-icon.svg?react';
+import {
+ Entity,
+ EntityType,
+ SearchAcrossEntitiesInput,
+ SearchResult,
+ SearchResults,
+ StructuredPropertyEntity,
+} from '@src/types.generated';
+import { Dropdown } from 'antd';
+import React, { useState } from 'react';
+import Highlight from 'react-highlighter';
+import { Link } from 'react-router-dom';
+import { removeFromPropertiesList } from './cacheUtils';
+import EmptyStructuredProperties from './EmptyStructuredProperties';
+import {
+ CardIcons,
+ CreatedByContainer,
+ DataContainer,
+ IconContainer,
+ MenuItem,
+ NameColumn,
+ PillContainer,
+ PillsContainer,
+ PropDescription,
+ PropName,
+} from './styledComponents';
+import { getDisplayName } from './utils';
+
+interface Props {
+ searchQuery: string;
+ data: GetSearchResultsForMultipleQuery | undefined;
+ loading: boolean;
+ setIsDrawerOpen: React.Dispatch>;
+ setIsViewDrawerOpen: React.Dispatch>;
+ selectedProperty?: SearchResult;
+ setSelectedProperty: React.Dispatch>;
+ inputs: SearchAcrossEntitiesInput;
+ searchAcrossEntities?: SearchResults | null;
+}
+
+const StructuredPropsTable = ({
+ searchQuery,
+ data,
+ loading,
+ setIsDrawerOpen,
+ setIsViewDrawerOpen,
+ selectedProperty,
+ setSelectedProperty,
+ inputs,
+ searchAcrossEntities,
+}: Props) => {
+ const entityRegistry = useEntityRegistry();
+ const client = useApolloClient();
+ const me = useUserContext();
+ const canEditProps = me.platformPrivileges?.manageStructuredProperties;
+
+ const structuredProperties = data?.searchAcrossEntities?.searchResults || [];
+
+ // Filter the table data based on the search query
+ const filteredProperties = structuredProperties
+ .filter((prop: any) => prop.entity.definition?.displayName?.toLowerCase().includes(searchQuery.toLowerCase()))
+ .sort(
+ (propA, propB) =>
+ ((propB.entity as StructuredPropertyEntity).definition.created?.time || 0) -
+ ((propA.entity as StructuredPropertyEntity).definition.created?.time || 0),
+ );
+
+ const [deleteStructuredProperty] = useDeleteStructuredPropertyMutation();
+
+ const [showConfirmDelete, setShowConfirmDelete] = useState(false);
+
+ const handleDeleteProperty = (property) => {
+ const deleteEntity = property.entity as StructuredPropertyEntity;
+ showToastMessage(ToastType.LOADING, 'Deleting structured property', 1);
+ deleteStructuredProperty({
+ variables: {
+ input: {
+ urn: deleteEntity.urn,
+ },
+ },
+ })
+ .then(() => {
+ analytics.event({
+ type: EventType.DeleteStructuredPropertyEvent,
+ propertyUrn: property.entity.urn,
+ propertyType: deleteEntity.definition.valueType.urn,
+ appliesTo: deleteEntity.definition.entityTypes.map((type) => type.urn),
+ qualifiedName: deleteEntity.definition.qualifiedName,
+ showInFilters: deleteEntity.settings?.showInSearchFilters,
+ allowedAssetTypes: deleteEntity.definition.typeQualifier?.allowedTypes?.map(
+ (allowedType) => allowedType.urn,
+ ),
+ allowedValues: deleteEntity.definition.allowedValues || undefined,
+ cardinality: deleteEntity.definition.cardinality || undefined,
+ isHidden: deleteEntity.settings?.isHidden ?? false,
+ showInSearchFilters: deleteEntity.settings?.showInSearchFilters ?? false,
+ showAsAssetBadge: deleteEntity.settings?.showAsAssetBadge ?? false,
+ showInAssetSummary: deleteEntity.settings?.showInAssetSummary ?? false,
+ showInColumnsTable: deleteEntity.settings?.showInColumnsTable ?? false,
+ });
+ showToastMessage(ToastType.SUCCESS, 'Structured property deleted successfully!', 3);
+ removeFromPropertiesList(client, inputs, property.entity.urn, searchAcrossEntities);
+ })
+ .catch(() => {
+ showToastMessage(ToastType.ERROR, 'Failed to delete structured property', 3);
+ });
+
+ setShowConfirmDelete(false);
+ setSelectedProperty(undefined);
+ };
+
+ const handleDeleteClose = () => {
+ setShowConfirmDelete(false);
+ setSelectedProperty(undefined);
+ };
+
+ if (!loading && !filteredProperties.length) {
+ return ;
+ }
+
+ const columns = [
+ {
+ title: 'Name',
+ key: 'name',
+ render: (record) => {
+ return (
+
+
+
+
+
+ {
+ if (canEditProps) setIsDrawerOpen(true);
+ else setIsViewDrawerOpen(true);
+
+ setSelectedProperty(record);
+ analytics.event({
+ type: EventType.ViewStructuredPropertyEvent,
+ propertyUrn: record.entity.urn,
+ });
+ }}
+ >
+ {getDisplayName(record.entity)}
+
+ {record.entity.definition.description}
+
+
+ );
+ },
+ width: '580px',
+ sorter: (sourceA, sourceB) => {
+ return getDisplayName(sourceA.entity).localeCompare(getDisplayName(sourceB.entity));
+ },
+ },
+ {
+ title: 'Entity Types',
+ key: 'entityTypes',
+ width: '270px',
+ render: (record) => {
+ const types = record.entity.definition.entityTypes;
+ const maxTypesToShow = 2;
+ const overflowCount = types.length - maxTypesToShow;
+
+ return (
+
+ {types.slice(0, maxTypesToShow).map((entityType) => {
+ const typeName = entityRegistry.getEntityName(entityType.info.type);
+ return (
+ {typeName && }
+ );
+ })}
+ {overflowCount > 0 && (
+ {
+ const name = entityRegistry.getEntityName(eType.info.type);
+ return name;
+ })
+ .join(', ')}
+ showArrow={false}
+ >
+ <>
+ {`+${overflowCount}`}
+ >
+
+ )}
+
+ );
+ },
+ },
+ {
+ title: 'Creation Date',
+ key: 'creationDate',
+ render: (record) => {
+ const createdTime = record.entity.definition.created?.time;
+ return (
+
+ {createdTime ? toRelativeTimeString(createdTime) : '-'}
+
+ );
+ },
+ sorter: (sourceA, sourceB) => {
+ const timeA = sourceA.entity.definition.created?.time || Number.MAX_SAFE_INTEGER;
+ const timeB = sourceB.entity.definition.created?.time || Number.MAX_SAFE_INTEGER;
+
+ return timeA - timeB;
+ },
+ },
+
+ {
+ title: 'Created By',
+ key: 'createdBy',
+ render: (record) => {
+ const createdByUser = record.entity.definition?.created?.actor;
+ const name = createdByUser && entityRegistry.getDisplayName(EntityType.CorpUser, createdByUser);
+ const avatarUrl = createdByUser?.editableProperties?.pictureLink || undefined;
+
+ return (
+ <>
+ {createdByUser && (
+
+
+
+
+
+ {name}
+
+
+
+
+ )}
+ >
+ );
+ },
+ sorter: (sourceA, sourceB) => {
+ const createdByUserA = sourceA.entity.definition?.created?.actor;
+ const nameA = createdByUserA && entityRegistry.getDisplayName(EntityType.CorpUser, createdByUserA);
+ const createdByUserB = sourceB.entity.definition?.created?.actor;
+ const nameB = createdByUserB && entityRegistry.getDisplayName(EntityType.CorpUser, createdByUserB);
+
+ return nameA?.localeCompare(nameB);
+ },
+ },
+ {
+ title: '',
+ key: 'actions',
+ alignment: 'right' as AlignmentOptions,
+ render: (record) => {
+ const items = [
+ {
+ key: '0',
+ label: (
+ {
+ setIsViewDrawerOpen(true);
+ setSelectedProperty(record);
+ analytics.event({
+ type: EventType.ViewStructuredPropertyEvent,
+ propertyUrn: record.entity.urn,
+ });
+ }}
+ >
+ View
+
+ ),
+ },
+ {
+ key: '1',
+ disabled: !canEditProps,
+ label: (
+
+ {
+ if (canEditProps) {
+ setIsDrawerOpen(true);
+ setSelectedProperty(record);
+ analytics.event({
+ type: EventType.ViewStructuredPropertyEvent,
+ propertyUrn: record.entity.urn,
+ });
+ }
+ }}
+ >
+ Edit
+
+
+ ),
+ },
+ {
+ key: '2',
+ disabled: !canEditProps,
+ label: (
+
+ {
+ if (canEditProps) {
+ setSelectedProperty(record);
+ setShowConfirmDelete(true);
+ }
+ }}
+ >
+ Delete
+
+
+ ),
+ },
+ ];
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+ },
+ },
+ ];
+ return (
+ <>
+
+ handleDeleteProperty(selectedProperty)}
+ modalTitle="Confirm Delete"
+ modalText="Are you sure you want to delete? Deleting will remove this structured property from all assets it's currently on."
+ />
+ >
+ );
+};
+
+export default StructuredPropsTable;
diff --git a/datahub-web-react/src/app/govern/structuredProperties/ViewAdvancedOptions.tsx b/datahub-web-react/src/app/govern/structuredProperties/ViewAdvancedOptions.tsx
new file mode 100644
index 0000000000000..1f08995e237ec
--- /dev/null
+++ b/datahub-web-react/src/app/govern/structuredProperties/ViewAdvancedOptions.tsx
@@ -0,0 +1,43 @@
+import { Icon, Text } from '@components';
+import { StructuredPropertyEntity } from '@src/types.generated';
+import { Collapse } from 'antd';
+import React from 'react';
+import { CollapseHeader, RowContainer, StyledCollapse, StyledLabel } from './styledComponents';
+
+interface Props {
+ propEntity: StructuredPropertyEntity;
+}
+
+const ViewAdvancedOptions = ({ propEntity }: Props) => {
+ return (
+ (
+
+ )}
+ expandIconPosition="end"
+ defaultActiveKey={[]}
+ >
+
+
+ Advanced Options
+
+
+ }
+ forceRender
+ >
+ {propEntity && (
+
+ Qualified Name
+ {propEntity?.definition.qualifiedName}
+
+ )}
+
+
+ );
+};
+
+export default ViewAdvancedOptions;
diff --git a/datahub-web-react/src/app/govern/structuredProperties/ViewDisplayPreferences.tsx b/datahub-web-react/src/app/govern/structuredProperties/ViewDisplayPreferences.tsx
new file mode 100644
index 0000000000000..a1baeaeb618f7
--- /dev/null
+++ b/datahub-web-react/src/app/govern/structuredProperties/ViewDisplayPreferences.tsx
@@ -0,0 +1,86 @@
+import { colors, Icon, Switch, Text } from '@src/alchemy-components';
+import { StructuredPropertyEntity } from '@src/types.generated';
+import { Collapse } from 'antd';
+import React from 'react';
+import { CollapseHeader, StyledCollapse, StyledFormItem, TogglesContainer } from './styledComponents';
+
+interface Props {
+ propEntity: StructuredPropertyEntity;
+}
+
+const ViewDisplayPreferences = ({ propEntity }: Props) => {
+ return (
+ <>
+ (
+
+ )}
+ expandIconPosition="end"
+ defaultActiveKey={[1]}
+ >
+
+
+ Display Preferences
+
+
+ }
+ forceRender
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export default ViewDisplayPreferences;
diff --git a/datahub-web-react/src/app/govern/structuredProperties/ViewStructuredPropsDrawer.tsx b/datahub-web-react/src/app/govern/structuredProperties/ViewStructuredPropsDrawer.tsx
new file mode 100644
index 0000000000000..2fd36a29c8e76
--- /dev/null
+++ b/datahub-web-react/src/app/govern/structuredProperties/ViewStructuredPropsDrawer.tsx
@@ -0,0 +1,142 @@
+import { Text } from '@components';
+import { useEntityRegistry } from '@src/app/useEntityRegistry';
+import { PropertyCardinality, SearchResult, StructuredPropertyEntity } from '@src/types.generated';
+import React from 'react';
+import {
+ DescriptionContainer,
+ DrawerHeader,
+ ItemsList,
+ RowContainer,
+ StyledDrawer,
+ StyledIcon,
+ StyledLabel,
+ VerticalDivider,
+ ViewDivider,
+ ViewFieldsContainer,
+} from './styledComponents';
+import { getDisplayName, getValueTypeLabel } from './utils';
+import ViewAdvancedOptions from './ViewAdvancedOptions';
+import ViewDisplayPreferences from './ViewDisplayPreferences';
+
+interface Props {
+ isViewDrawerOpen: boolean;
+ setIsViewDrawerOpen: React.Dispatch>;
+ selectedProperty: SearchResult;
+ setSelectedProperty: React.Dispatch>;
+}
+
+const ViewStructuredPropsDrawer = ({
+ isViewDrawerOpen,
+ setIsViewDrawerOpen,
+ selectedProperty,
+ setSelectedProperty,
+}: Props) => {
+ const entityRegistry = useEntityRegistry();
+
+ const handleClose = () => {
+ setIsViewDrawerOpen(false);
+ setSelectedProperty(undefined);
+ };
+
+ const selectedPropEntity = selectedProperty && (selectedProperty?.entity as StructuredPropertyEntity);
+
+ const allowedValues = selectedPropEntity?.definition.allowedValues;
+
+ const allowedTypes = selectedPropEntity?.definition.typeQualifier?.allowedTypes;
+
+ const propType = getValueTypeLabel(
+ selectedPropEntity.definition.valueType.urn,
+ selectedPropEntity.definition.cardinality || PropertyCardinality.Single,
+ );
+
+ return (
+
+ {selectedProperty && (
+
+
+ {getDisplayName(selectedProperty?.entity as StructuredPropertyEntity)}
+
+
+
+
+ )}
+ >
+ }
+ footer={null}
+ destroyOnClose
+ >
+
+ {selectedPropEntity.definition.description && (
+
+
+ Description
+
+ {selectedPropEntity.definition.description}
+
+
+ )}
+
+ Property Type
+ {propType}
+
+ {allowedTypes && allowedTypes.length > 0 && (
+
+ Allowed Entity Types
+
+ {allowedTypes.map((type, index) => {
+ return (
+ <>
+ {entityRegistry.getEntityName(type.info.type)}
+ {index < allowedTypes.length - 1 && }
+ >
+ );
+ })}
+
+
+ )}
+ {allowedValues && allowedValues.length > 0 && (
+
+ Allowed Values
+
+ {allowedValues?.map((val, index) => {
+ return (
+ <>
+
+ {(val.value as any).stringValue || (val.value as any).numberValue}
+
+ {index < allowedValues?.length - 1 && }
+ >
+ );
+ })}
+
+
+ )}
+
+
+ Applies To
+
+ {selectedPropEntity.definition.entityTypes?.map((type, index) => {
+ return (
+ <>
+ {entityRegistry.getEntityName(type.info.type)}
+ {index < selectedPropEntity.definition.entityTypes?.length - 1 && (
+
+ )}
+ >
+ );
+ })}
+
+
+
+
+
+
+ );
+};
+
+export default ViewStructuredPropsDrawer;
diff --git a/datahub-web-react/src/app/govern/structuredProperties/cacheUtils.ts b/datahub-web-react/src/app/govern/structuredProperties/cacheUtils.ts
new file mode 100644
index 0000000000000..590189d06e6b1
--- /dev/null
+++ b/datahub-web-react/src/app/govern/structuredProperties/cacheUtils.ts
@@ -0,0 +1,102 @@
+import { GetSearchResultsForMultipleDocument, GetSearchResultsForMultipleQuery } from '@src/graphql/search.generated';
+
+const addToCache = (existingProperties, newProperty) => {
+ const propertyToWrite = {
+ entity: {
+ urn: newProperty.urn,
+ type: newProperty.type,
+ definition: {
+ displayName: newProperty.definition.displayName,
+ qualifiedName: newProperty.definition.qualifiedName,
+ description: newProperty.definition.description,
+ cardinality: newProperty.definition.cardinality,
+ immutable: newProperty.definition.immutable,
+ valueType: newProperty.definition.valueType,
+ entityTypes: newProperty.definition.entityTypes,
+ typeQualifier: newProperty.definition.typeQualifier,
+ allowedValues: newProperty.definition.allowedValues,
+ created: newProperty.definition.created,
+ lastModified: newProperty.definition.lastModified,
+ filterStatus: newProperty.definition.filterStatus,
+ },
+ settings: {
+ isHidden: newProperty.settings.isHidden,
+ showInSearchFilters: newProperty.settings.showInSearchFilters,
+ showAsAssetBadge: newProperty.settings.showAsAssetBadge,
+ showInAssetSummary: newProperty.settings.showInAssetSummary,
+ showInColumnsTable: newProperty.settings.showInColumnsTable,
+ },
+ __typename: 'StructuredPropertyEntity',
+ },
+ matchedFields: [],
+ insights: [],
+ extraProperties: [],
+ __typename: 'SearchResult',
+ };
+
+ return [propertyToWrite, ...existingProperties];
+};
+
+export const updatePropertiesList = (client, inputs, newProperty, searchAcrossEntities) => {
+ // Read the data from our cache for this query.
+ const currData: GetSearchResultsForMultipleQuery | null = client.readQuery({
+ query: GetSearchResultsForMultipleDocument,
+ variables: {
+ input: inputs,
+ },
+ });
+
+ if (currData === null) {
+ // If there's no cached data, the first load has not occurred. Let it occur naturally.
+ return;
+ }
+
+ const existingProperties = currData?.searchAcrossEntities?.searchResults || [];
+ const newProperties = addToCache(existingProperties, newProperty);
+
+ // Write our data back to the cache.
+ client.writeQuery({
+ query: GetSearchResultsForMultipleDocument,
+ variables: {
+ input: inputs,
+ },
+ data: {
+ searchAcrossEntities: {
+ ...searchAcrossEntities,
+ total: newProperties.length,
+ searchResults: newProperties,
+ },
+ },
+ });
+};
+
+export const removeFromPropertiesList = (client, inputs, deleteUrn, searchAcrossEntities) => {
+ const currData: GetSearchResultsForMultipleQuery | null = client.readQuery({
+ query: GetSearchResultsForMultipleDocument,
+ variables: {
+ input: inputs,
+ },
+ });
+
+ if (currData === null) {
+ return;
+ }
+
+ const existingProperties = currData?.searchAcrossEntities?.searchResults || [];
+
+ const newProperties = [...existingProperties.filter((prop) => prop.entity.urn !== deleteUrn)];
+
+ client.writeQuery({
+ query: GetSearchResultsForMultipleDocument,
+ variables: {
+ input: inputs,
+ },
+ data: {
+ searchAcrossEntities: {
+ ...searchAcrossEntities,
+ total: newProperties.length,
+ searchResults: newProperties,
+ },
+ },
+ });
+};
diff --git a/datahub-web-react/src/app/govern/structuredProperties/styledComponents.ts b/datahub-web-react/src/app/govern/structuredProperties/styledComponents.ts
new file mode 100644
index 0000000000000..211b4cdab6638
--- /dev/null
+++ b/datahub-web-react/src/app/govern/structuredProperties/styledComponents.ts
@@ -0,0 +1,473 @@
+import { colors, Icon, typography } from '@src/alchemy-components';
+import { Checkbox, Collapse, Divider, Drawer, Form, Modal, Select, Spin, Typography } from 'antd';
+import styled from 'styled-components';
+
+export const PageContainer = styled.div<{ $isShowNavBarRedesign?: boolean }>`
+ overflow: auto;
+ margin: ${(props) => (props.$isShowNavBarRedesign ? '0' : '0 12px 12px 0')};
+ padding: 16px 20px 20px 20px;
+ border-radius: ${(props) =>
+ props.$isShowNavBarRedesign ? props.theme.styles['border-radius-navbar-redesign'] : '8px'};
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ gap: 20px;
+ background-color: ${colors.white};
+ ${(props) => props.$isShowNavBarRedesign && 'max-height: calc(100vh - 88px);'};
+ ${(props) =>
+ props.$isShowNavBarRedesign &&
+ `
+ box-shadow: ${props.theme.styles['box-shadow-navbar-redesign']};
+ margin: 5px;
+ `}
+`;
+
+export const HeaderContainer = styled.div`
+ display: flex;
+ justify-content: space-between;
+`;
+
+export const TableContainer = styled.div`
+ display: flex;
+ overflow: auto;
+ flex: 1;
+`;
+
+export const HeaderContent = styled.div`
+ display: flex;
+ flex-direction: column;
+`;
+
+export const ButtonContainer = styled.div`
+ display: flex;
+ align-self: center;
+`;
+
+export const DataContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ width: calc(100% - 44px);
+`;
+
+export const PropName = styled(Typography.Text)`
+ font-size: 14px;
+ font-weight: 600;
+ color: ${colors.gray[600]};
+ line-height: normal;
+
+ :hover {
+ cursor: pointer;
+ text-decoration: underline;
+ }
+`;
+
+export const PropDescription = styled(Typography.Text)`
+ font-size: 14px;
+ font-weight: 400;
+ color: ${colors.gray[1700]};
+ line-height: normal;
+`;
+
+export const NameColumn = styled.div`
+ display: flex;
+ gap: 12px;
+ align-items: center;
+`;
+
+export const IconContainer = styled.div`
+ height: 32px;
+ width: 32px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 12px;
+ border-radius: 200px;
+ background-color: ${colors.gray[1000]};
+`;
+
+export const PillsContainer = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 8px;
+`;
+
+export const PillContainer = styled.div`
+ display: flex;
+`;
+
+export const MenuItem = styled.div`
+ display: flex;
+ padding: 5px 100px 5px 5px;
+ font-size: 14px;
+ font-weight: 400;
+ color: ${colors.gray[600]};
+ font-family: ${typography.fonts.body};
+`;
+
+export const DrawerHeader = styled.div`
+ display: flex;
+ justify-content: space-between;
+`;
+
+export const TitleContainer = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 6px;
+`;
+
+export const StyledIcon = styled(Icon)`
+ &:hover {
+ cursor: pointer;
+ }
+`;
+
+export const FooterContainer = styled.div`
+ width: 100%;
+`;
+
+export const RowContainer = styled.div`
+ display: grid;
+ grid-template-columns: 180px 1fr;
+ align-items: center;
+`;
+
+export const ViewFieldsContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+`;
+
+export const CheckboxWrapper = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin: 10px 0;
+ color: #374066;
+ p {
+ color: #374066;
+ font-weight: 500;
+ }
+`;
+
+export const StyledCheckbox = styled(Checkbox)`
+ .ant-checkbox-checked .ant-checkbox-inner {
+ background-color: ${colors.violet[500]};
+ border-color: ${colors.violet[500]} !important;
+ },
+`;
+
+export const StyledText = styled.div`
+ display: inline-flex;
+ margin-left: -4px;
+`;
+
+export const StyledFormItem = styled(Form.Item)`
+ margin: 0;
+`;
+
+export const GridFormItem = styled(Form.Item)`
+ display: grid;
+`;
+
+export const FieldLabel = styled.div`
+ font-size: 14px;
+ font-weight: 500;
+ color: ${colors.gray[600]};
+ margin-bottom: 24px;
+`;
+
+export const InputLabel = styled.div`
+ font-size: 14px;
+ font-weight: 500;
+ color: ${colors.gray[600]};
+ display: flex;
+ gap: 2px;
+`;
+
+export const StyledLabel = styled.div`
+ font-size: 12px;
+ font-weight: 700;
+ color: ${colors.gray[1700]};
+`;
+
+export const DescriptionContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+`;
+
+export const FlexContainer = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 4px;
+`;
+
+export const StyledDrawer = styled(Drawer)`
+ .ant-drawer-body {
+ padding: 16px;
+ }
+
+ .ant-drawer-header {
+ padding: 16px;
+ }
+`;
+
+export const StyledCollapse = styled(Collapse)`
+ .ant-collapse-header {
+ padding: 0 !important;
+ }
+
+ .ant-collapse-content-box {
+ padding: 12px 0 !important;
+ }
+
+ .ant-collapse-arrow {
+ right: 0 !important;
+ }
+`;
+
+export const CollapseHeader = styled.div`
+ border-top: 1px solid ${colors.gray[1400]};
+ padding: 16px 0;
+ margin-left: -16px;
+ width: calc(100% + 32px);
+ padding: 16px;
+ margin-top: 12px;
+`;
+
+export const TogglesContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 32px;
+`;
+
+export const StyledSpin = styled(Spin)`
+ max-height: 100% !important;
+ color: ${colors.violet[500]};
+`;
+
+export const CreatedByContainer = styled.div`
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 3px 6px 3px 4px;
+ border-radius: 20px;
+ border: 1px solid ${colors.gray[1400]};
+
+ :hover {
+ cursor: pointer;
+ }
+`;
+
+export const SubTextContainer = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 4px;
+
+ p {
+ margin: 0;
+ }
+`;
+
+export const ValueListContainer = styled.div`
+ margin-bottom: 24px;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 14px;
+ color: ${colors.gray[500]};
+
+ svg {
+ :hover {
+ cursor: pointer;
+ }
+ }
+`;
+
+export const ValueType = styled.div`
+ background-color: ${colors.gray[100]};
+ border-radius: 4px;
+ padding: 2px 4px;
+`;
+
+export const StyledModal = styled(Modal)`
+ font-family: Mulish;
+
+ &&& .ant-modal-content {
+ box-shadow: 0px 4px 12px 0px rgba(9, 1, 61, 0.12);
+ border-radius: 12px;
+ width: 452px;
+ }
+
+ .ant-modal-header {
+ border-top-left-radius: 12px !important;
+ border-top-right-radius: 12px !important;
+ padding: 20px 20px 8px 20px;
+ }
+
+ .ant-modal-body {
+ padding: 0;
+ }
+`;
+
+export const FieldGroupContainer = styled.div`
+ display: grid;
+ margin-bottom: 8px;
+`;
+
+export const DeleteIconContainer = styled.div`
+ display: flex;
+ margin-top: -16px;
+ justify-self: end;
+
+ :hover {
+ cursor: pointer;
+ }
+`;
+
+export const ValuesContainer = styled.div<{ height: number }>`
+ max-height: ${(props) => `calc(${props.height}px - 200px)`};
+ overflow: auto;
+ padding: 20px;
+`;
+
+export const ValuesList = styled.div`
+ font-size: 14px;
+ color: ${colors.gray[500]};
+ display: flex;
+ flex: 1;
+ align-items: center;
+ flex-wrap: wrap;
+
+ p {
+ line-height: 24px;
+ }
+`;
+
+export const ItemsList = styled.div`
+ font-size: 14px;
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+`;
+
+export const ItemsContainer = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ width: fit-content;
+ margin: 0 24px 24px 0;
+`;
+
+export const AddButtonContainer = styled.div`
+ display: flex;
+ margin: 10px 20px 0 0;
+ justify-self: end;
+`;
+
+export const FormContainer = styled.div`
+ display: grid;
+`;
+
+export const ModalFooter = styled.div`
+ display: flex;
+ gap: 16px;
+ justify-content: end;
+`;
+
+export const VerticalDivider = styled(Divider)`
+ color: ${colors.gray[1400]};
+ height: 20px;
+ width: 2px;
+`;
+
+export const StyledSelect = styled(Select)`
+ font-family: ${typography.fonts.body};
+
+ .ant-select-selector {
+ height: 50px !important;
+ border-radius: 8px !important;
+ outline: 2px solid transparent !important;
+
+ &:hover,
+ &:focus-within {
+ border: 1px solid ${colors.violet[200]} !important;
+ outline: 2px solid ${colors.violet[200]} !important;
+ box-shadow: none !important;
+ }
+ }
+
+ .ant-select-selection-item {
+ p {
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+ }
+ }
+
+ .ant-select-selection-placeholder {
+ display: flex;
+ align-items: center;
+ font-size: 14px;
+ color: ${colors.gray[400]};
+ }
+`;
+
+export const CustomDropdown = styled.div`
+ .ant-select-item-option-content {
+ white-space: normal;
+ }
+`;
+
+export const SelectOptionContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ padding: 4px 0;
+
+ p {
+ line-height: 20px;
+ }
+`;
+
+export const StyledDivider = styled(Divider)`
+ color: ${colors.gray[1400]};
+ margin: 16px 0;
+`;
+
+export const ViewDivider = styled(Divider)`
+ color: ${colors.gray[1400]};
+ margin: 16px 0 0 -16px;
+ width: calc(100% + 32px);
+`;
+
+export const EmptyContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ width: 100%;
+ gap: 16px;
+
+ svg {
+ width: 160px;
+ height: 160px;
+ }
+`;
+
+export const CardIcons = styled.div`
+ display: flex;
+ justify-content: end;
+ gap: 12px;
+
+ div {
+ border: 1px solid $E9EAEE;
+ border-radius: 20px;
+ width: 28px;
+ height: 28px;
+ padding: 4px;
+ color: #8088a3;
+ :hover {
+ cursor: pointer;
+ }
+ }
+`;
diff --git a/datahub-web-react/src/app/govern/structuredProperties/useStructuredProp.ts b/datahub-web-react/src/app/govern/structuredProperties/useStructuredProp.ts
new file mode 100644
index 0000000000000..6de195c1cd424
--- /dev/null
+++ b/datahub-web-react/src/app/govern/structuredProperties/useStructuredProp.ts
@@ -0,0 +1,135 @@
+import { useEntityRegistry } from '@src/app/useEntityRegistry';
+import { EntityType, PropertyCardinality, SearchResult, StructuredPropertyEntity } from '@src/types.generated';
+import { FormInstance } from 'antd';
+import { useMemo } from 'react';
+import { getEntityTypeUrn, StructuredProp, valueTypes } from './utils';
+
+interface Props {
+ selectedProperty?: SearchResult;
+ form: FormInstance;
+ setFormValues: React.Dispatch>;
+ setCardinality: React.Dispatch>;
+ setSelectedValueType: React.Dispatch>;
+}
+
+export default function useStructuredProp({
+ selectedProperty,
+ form,
+ setFormValues,
+ setCardinality,
+ setSelectedValueType,
+}: Props) {
+ const entityRegistry = useEntityRegistry();
+
+ const getEntitiesListOptions = (entitiesList: EntityType[]) => {
+ const listOptions: { label: string; value: string }[] = [];
+ entitiesList.forEach((type) => {
+ const entity = {
+ label: entityRegistry.getEntityName(type) || '',
+ value: getEntityTypeUrn(entityRegistry, type),
+ };
+ listOptions.push(entity);
+ });
+ return listOptions;
+ };
+
+ const updateFormValues = (field, values) => {
+ if (field.includes('typeQualifier')) {
+ setFormValues((prev) => ({
+ ...prev,
+ typeQualifier: {
+ allowedTypes: values,
+ },
+ }));
+ } else
+ setFormValues((prev) => ({
+ ...prev,
+ [field]: values,
+ }));
+ };
+
+ const handleSelectChange = (field, values) => {
+ form.setFieldValue(field, values);
+ updateFormValues(field, values);
+ };
+
+ const handleSelectUpdateChange = (field, values) => {
+ const entity = selectedProperty?.entity as StructuredPropertyEntity;
+ let initialValues: string[] = [];
+
+ if (field === 'entityTypes') initialValues = entity.definition.entityTypes.map((type) => type.urn);
+
+ if (field.includes('typeQualifier'))
+ initialValues = entity.definition.typeQualifier?.allowedTypes?.map((type) => type.urn) || [];
+
+ const updatedValues = [...initialValues, ...values.filter((value) => !initialValues.includes(value))];
+
+ form.setFieldValue(field, updatedValues);
+ updateFormValues(field, updatedValues);
+ };
+
+ // Handle change in the property type dropdown
+ const handleTypeUpdate = (value: string) => {
+ const typeOption = valueTypes.find((type) => type.value === value);
+ setSelectedValueType(value);
+ handleSelectChange('valueType', value);
+ setFormValues((prev) => ({
+ ...prev,
+ valueType: value,
+ }));
+
+ const isList = typeOption?.cardinality === PropertyCardinality.Multiple;
+ if (isList) setCardinality(PropertyCardinality.Multiple);
+ else setCardinality(PropertyCardinality.Single);
+ };
+
+ const settingsDefault = {
+ isHidden: false,
+ showInSearchFilters: false,
+ showAsAssetBadge: false,
+ showInAssetSummary: false,
+ showInColumnsTable: false,
+ };
+
+ const handleDisplaySettingChange = (settingField: string, value: boolean) => {
+ if (settingField === 'isHidden' && value) {
+ Object.keys(settingsDefault).forEach((settingKey) => form.setFieldValue(['settings', settingKey], false));
+ setFormValues((prev) => ({
+ ...prev,
+ settings: {
+ ...settingsDefault,
+ [settingField]: value,
+ },
+ }));
+ } else {
+ setFormValues((prev) => ({
+ ...prev,
+ settings: {
+ ...(prev?.settings || settingsDefault),
+ [settingField]: value,
+ },
+ }));
+ }
+ form.setFieldValue(['settings', settingField], value);
+ };
+
+ const disabledEntityTypeValues = useMemo(() => {
+ return (selectedProperty?.entity as StructuredPropertyEntity)?.definition?.entityTypes?.map((type) => type.urn);
+ }, [selectedProperty]);
+
+ const disabledTypeQualifierValues = useMemo(() => {
+ return (selectedProperty?.entity as StructuredPropertyEntity)?.definition?.typeQualifier?.allowedTypes?.map(
+ (type) => type.urn,
+ );
+ }, [selectedProperty]);
+
+ return {
+ handleSelectChange,
+ handleSelectUpdateChange,
+ handleTypeUpdate,
+ getEntitiesListOptions,
+ disabledEntityTypeValues,
+ disabledTypeQualifierValues,
+ handleDisplaySettingChange,
+ };
+}
diff --git a/datahub-web-react/src/app/govern/structuredProperties/utils.ts b/datahub-web-react/src/app/govern/structuredProperties/utils.ts
new file mode 100644
index 0000000000000..744ce20cf4573
--- /dev/null
+++ b/datahub-web-react/src/app/govern/structuredProperties/utils.ts
@@ -0,0 +1,249 @@
+import EntityRegistry from '@src/app/entity/EntityRegistry';
+import { mapStructuredPropertyToPropertyRow } from '@src/app/entity/shared/tabs/Properties/useStructuredProperties';
+import {
+ ENTITY_TYPES_FILTER_NAME,
+ IS_HIDDEN_PROPERTY_FILTER_NAME,
+ SHOW_IN_ASSET_SUMMARY_PROPERTY_FILTER_NAME,
+ SHOW_IN_COLUMNS_TABLE_PROPERTY_FILTER_NAME,
+} from '@src/app/search/utils/constants';
+import {
+ AllowedValue,
+ EntityType,
+ FacetFilterInput,
+ Maybe,
+ PropertyCardinality,
+ SearchResult,
+ StructuredProperties,
+ StructuredPropertyEntity,
+ StructuredPropertySettings,
+} from '@src/types.generated';
+
+export type StructuredProp = {
+ displayName?: string;
+ qualifiedName?: string;
+ cardinality?: PropertyCardinality;
+ description?: string | null;
+ valueType?: string;
+ entityTypes?: string[];
+ typeQualifier?: {
+ allowedTypes?: string[];
+ };
+ immutable?: boolean;
+ allowedValues?: AllowedValue[];
+ settings?: StructuredPropertySettings | null;
+};
+
+export const valueTypes = [
+ {
+ urn: 'urn:li:dataType:datahub.string',
+ label: 'Text',
+ value: 'string',
+ cardinality: PropertyCardinality.Single,
+ description: 'A string value',
+ },
+ {
+ urn: 'urn:li:dataType:datahub.string',
+ label: 'Text - List',
+ value: 'stringList',
+ cardinality: PropertyCardinality.Multiple,
+ description: 'A list of string values',
+ },
+ {
+ urn: 'urn:li:dataType:datahub.number',
+ label: 'Number',
+ value: 'number',
+ cardinality: PropertyCardinality.Single,
+ description: 'An integer or decimal',
+ },
+ {
+ urn: 'urn:li:dataType:datahub.number',
+ label: 'Number - List',
+ value: 'numberList',
+ cardinality: PropertyCardinality.Multiple,
+ description: 'A list of integers or decimals',
+ },
+ {
+ urn: 'urn:li:dataType:datahub.urn',
+ label: 'Entity',
+ value: 'entity',
+ cardinality: PropertyCardinality.Single,
+ description: 'A reference to a DataHub asset',
+ },
+ {
+ urn: 'urn:li:dataType:datahub.urn',
+ label: 'Entity - List',
+ value: 'entityList',
+ cardinality: PropertyCardinality.Multiple,
+ description: 'A reference to a list of DataHub assets',
+ },
+ {
+ urn: 'urn:li:dataType:datahub.rich_text',
+ label: 'Rich Text',
+ value: 'richText',
+ cardinality: PropertyCardinality.Single,
+ description: 'A freeform string of markdown text ',
+ },
+ {
+ urn: 'urn:li:dataType:datahub.date',
+ label: 'Date',
+ value: 'date',
+ cardinality: PropertyCardinality.Single,
+ description: 'A specific date',
+ },
+];
+
+export const SEARCHABLE_ENTITY_TYPES = [
+ EntityType.Dataset,
+ EntityType.DataJob,
+ EntityType.DataFlow,
+ EntityType.Chart,
+ EntityType.Dashboard,
+ EntityType.Domain,
+ EntityType.Container,
+ EntityType.GlossaryTerm,
+ EntityType.GlossaryNode,
+ EntityType.Mlmodel,
+ EntityType.MlmodelGroup,
+ EntityType.Mlfeature,
+ EntityType.MlfeatureTable,
+ EntityType.MlprimaryKey,
+ EntityType.DataProduct,
+ EntityType.CorpUser,
+ EntityType.CorpGroup,
+ EntityType.Tag,
+ EntityType.Role,
+];
+
+export const APPLIES_TO_ENTITIES = [
+ EntityType.Dataset,
+ EntityType.DataJob,
+ EntityType.DataFlow,
+ EntityType.Chart,
+ EntityType.Dashboard,
+ EntityType.Domain,
+ EntityType.Container,
+ EntityType.GlossaryTerm,
+ EntityType.GlossaryNode,
+ EntityType.Mlmodel,
+ EntityType.MlmodelGroup,
+ EntityType.Mlfeature,
+ EntityType.MlfeatureTable,
+ EntityType.MlprimaryKey,
+ EntityType.DataProduct,
+ EntityType.SchemaField,
+];
+
+export const getEntityTypeUrn = (entityRegistry: EntityRegistry, entityType: EntityType) => {
+ return `urn:li:entityType:datahub.${entityRegistry.getGraphNameFromType(entityType)}`;
+};
+
+export function getDisplayName(structuredProperty: StructuredPropertyEntity) {
+ return structuredProperty.definition.displayName || structuredProperty.definition.qualifiedName;
+}
+
+export const getValueType = (valueUrn: string, cardinality: PropertyCardinality) => {
+ return valueTypes.find((valueType) => valueType.urn === valueUrn && valueType.cardinality === cardinality)?.value;
+};
+
+export const getValueTypeLabel = (valueUrn: string, cardinality: PropertyCardinality) => {
+ return valueTypes.find((valueType) => valueType.urn === valueUrn && valueType.cardinality === cardinality)?.label;
+};
+
+export const getNewAllowedTypes = (entity: StructuredPropertyEntity, values: StructuredProp) => {
+ const currentTypeUrns = entity.definition.typeQualifier?.allowedTypes?.map((type) => type.urn);
+ return values.typeQualifier?.allowedTypes?.filter((type) => !currentTypeUrns?.includes(type));
+};
+
+export const getNewEntityTypes = (entity: StructuredPropertyEntity, values: StructuredProp) => {
+ const currentTypeUrns = entity.definition.entityTypes?.map((type) => type.urn);
+ return values.entityTypes?.filter((type) => !currentTypeUrns.includes(type));
+};
+
+export const getNewAllowedValues = (entity: StructuredPropertyEntity, values: StructuredProp) => {
+ const currentAllowedValues = entity.definition.allowedValues?.map(
+ (val: any) => val.value.numberValue || val.value.stringValue,
+ );
+ return values.allowedValues?.filter(
+ (val: any) =>
+ !(currentAllowedValues?.includes(val.stringValue) || currentAllowedValues?.includes(val.numberValue)),
+ );
+};
+
+export const isEntityTypeSelected = (selectedType: string) => {
+ if (selectedType === 'entity' || selectedType === 'entityList') return true;
+ return false;
+};
+
+export const isStringOrNumberTypeSelected = (selectedType: string) => {
+ if (
+ selectedType === 'string' ||
+ selectedType === 'stringList' ||
+ selectedType === 'number' ||
+ selectedType === 'numberList'
+ )
+ return true;
+ return false;
+};
+
+export const canBeAssetBadge = (selectedType: string, allowedValues?: AllowedValue[]) => {
+ if (selectedType === 'string' || selectedType === 'number') {
+ return !!allowedValues?.length;
+ }
+ return false;
+};
+
+export type PropValueField = 'stringValue' | 'numberValue';
+
+export const getStringOrNumberValueField = (selectedType: string) => {
+ if (selectedType === 'number' || selectedType === 'numberList') return 'numberValue' as PropValueField;
+ return 'stringValue' as PropValueField;
+};
+
+export const getPropertyRowFromSearchResult = (
+ property: SearchResult,
+ structuredProperties: Maybe | undefined,
+) => {
+ const entityProp = structuredProperties?.properties?.find(
+ (prop) => prop.structuredProperty.urn === property.entity.urn,
+ );
+ return entityProp ? mapStructuredPropertyToPropertyRow(entityProp) : undefined;
+};
+
+export const getNotHiddenPropertyFilter = () => {
+ const isHiddenFilter: FacetFilterInput = {
+ field: IS_HIDDEN_PROPERTY_FILTER_NAME,
+ values: ['true'],
+ negated: true,
+ };
+ return isHiddenFilter;
+};
+
+export const getShowInColumnsTablePropertyFilter = () => {
+ const columnsTableFilter: FacetFilterInput = {
+ field: SHOW_IN_COLUMNS_TABLE_PROPERTY_FILTER_NAME,
+ values: ['true'],
+ };
+ return columnsTableFilter;
+};
+
+export const getShowInAssetSummaryPropertyFilter = () => {
+ const assetSummaryFilter: FacetFilterInput = {
+ field: SHOW_IN_ASSET_SUMMARY_PROPERTY_FILTER_NAME,
+ values: ['true'],
+ };
+ return assetSummaryFilter;
+};
+
+export const getEntityTypesPropertyFilter = (
+ entityRegistry: EntityRegistry,
+ isSchemaField: boolean,
+ entityType?: EntityType,
+) => {
+ const type = isSchemaField ? EntityType.SchemaField : entityType;
+
+ const entityTypesFilter: FacetFilterInput = {
+ field: ENTITY_TYPES_FILTER_NAME,
+ values: [getEntityTypeUrn(entityRegistry, type || EntityType.SchemaField)],
+ };
+ return entityTypesFilter;
+};
diff --git a/datahub-web-react/src/app/lineage/LineageEntityNode.tsx b/datahub-web-react/src/app/lineage/LineageEntityNode.tsx
index fcf2198e7e004..994090059754c 100644
--- a/datahub-web-react/src/app/lineage/LineageEntityNode.tsx
+++ b/datahub-web-react/src/app/lineage/LineageEntityNode.tsx
@@ -19,6 +19,10 @@ import ManageLineageMenu from './manage/ManageLineageMenu';
import { useGetLineageTimeParams } from './utils/useGetLineageTimeParams';
import { EntityHealth } from '../entity/shared/containers/profile/header/EntityHealth';
import { EntityType } from '../../types.generated';
+import StructuredPropertyBadge, {
+ MAX_PROP_BADGE_WIDTH,
+} from '../entity/shared/containers/profile/header/StructuredPropertyBadge';
+import { filterForAssetBadge } from '../entity/shared/containers/profile/header/utils';
const CLICK_DELAY_THRESHOLD = 1000;
const DRAG_DISTANCE_THRESHOLD = 20;
@@ -38,6 +42,11 @@ const MultilineTitleText = styled.p`
word-break: break-all;
`;
+const PropertyBadgeWrapper = styled.div`
+ display: flex;
+ justify-content: flex-end;
+`;
+
export default function LineageEntityNode({
node,
isSelected,
@@ -150,6 +159,11 @@ export default function LineageEntityNode({
const baseUrl = node.data.type && node.data.urn && entityRegistry.getEntityUrl(node.data.type, node.data.urn);
const hasHealth = (health && baseUrl) || false;
+ const entityStructuredProps = node.data.structuredProperties;
+ const hasAssetBadge = entityStructuredProps?.properties?.find(filterForAssetBadge);
+ const siblingStructuredProps = node.data.siblingStructuredProperties;
+ const siblingHasAssetBadge = siblingStructuredProps?.properties?.find(filterForAssetBadge);
+
return (
{unexploredHiddenChildren && (isHovered || isSelected) ? (
@@ -340,6 +354,32 @@ export default function LineageEntityNode({
/>
)}
+ {hasAssetBadge && (
+ e.stopPropagation()}
+ >
+
+
+
+
+ )}
+ {!hasAssetBadge && siblingHasAssetBadge && (
+ e.stopPropagation()}
+ >
+
+
+
+
+ )}
;
+ structuredProperties?: Maybe;
};
export type NodeData = {
@@ -82,6 +84,8 @@ export type NodeData = {
upstreamRelationships?: Array;
downstreamRelationships?: Array;
health?: Maybe;
+ structuredProperties?: Maybe;
+ siblingStructuredProperties?: Maybe;
};
export type VizNode = {
diff --git a/datahub-web-react/src/app/lineage/utils/constructFetchedNode.ts b/datahub-web-react/src/app/lineage/utils/constructFetchedNode.ts
index 12d4cca352bb3..bb9f29522d0cb 100644
--- a/datahub-web-react/src/app/lineage/utils/constructFetchedNode.ts
+++ b/datahub-web-react/src/app/lineage/utils/constructFetchedNode.ts
@@ -68,6 +68,7 @@ export default function constructFetchedNode(
upstreamRelationships: fetchedNode?.upstreamRelationships || [],
downstreamRelationships: fetchedNode?.downstreamRelationships || [],
health: fetchedNode?.health,
+ structuredProperties: fetchedNode?.structuredProperties,
};
// eslint-disable-next-line no-param-reassign
diff --git a/datahub-web-react/src/app/lineage/utils/constructTree.ts b/datahub-web-react/src/app/lineage/utils/constructTree.ts
index 38a865ea9e093..4e94ad2813674 100644
--- a/datahub-web-react/src/app/lineage/utils/constructTree.ts
+++ b/datahub-web-react/src/app/lineage/utils/constructTree.ts
@@ -83,6 +83,8 @@ export default function constructTree(
});
const fetchedEntity = entityRegistry.getLineageVizConfig(entityAndType.type, entityAndType.entity);
+ const sibling = fetchedEntity?.siblings?.siblings?.[0];
+ const fetchedSiblingEntity = sibling ? entityRegistry.getLineageVizConfig(sibling.type, sibling) : null;
const root: NodeData = {
name: fetchedEntity?.name || '',
@@ -100,6 +102,8 @@ export default function constructTree(
upstreamRelationships: fetchedEntity?.upstreamRelationships || [],
downstreamRelationships: fetchedEntity?.downstreamRelationships || [],
health: fetchedEntity?.health,
+ structuredProperties: fetchedEntity?.structuredProperties,
+ siblingStructuredProperties: fetchedSiblingEntity?.structuredProperties,
};
const lineageConfig = entityRegistry.getLineageVizConfig(entityAndType.type, entityAndType.entity);
let updatedLineageConfig = { ...lineageConfig };
diff --git a/datahub-web-react/src/app/permissions/policy/PolicyDetailsModal.tsx b/datahub-web-react/src/app/permissions/policy/PolicyDetailsModal.tsx
index 37349585fa4c9..1988fea349699 100644
--- a/datahub-web-react/src/app/permissions/policy/PolicyDetailsModal.tsx
+++ b/datahub-web-react/src/app/permissions/policy/PolicyDetailsModal.tsx
@@ -12,6 +12,7 @@ import {
mapResourceTypeToDisplayName,
} from './policyUtils';
import AvatarsGroup from '../AvatarsGroup';
+import { RESOURCE_TYPE, RESOURCE_URN, TYPE, URN } from './constants';
type PrivilegeOptionType = {
type?: string;
@@ -72,10 +73,11 @@ export default function PolicyDetailsModal({ policy, open, onClose, privileges }
const isMetadataPolicy = policy?.type === PolicyType.Metadata;
const resources = convertLegacyResourceFilter(policy?.resources);
- const resourceTypes = getFieldValues(resources?.filter, 'TYPE') || [];
+ const resourceTypes = getFieldValues(resources?.filter, TYPE, RESOURCE_TYPE) || [];
const dataPlatformInstances = getFieldValues(resources?.filter, 'DATA_PLATFORM_INSTANCE') || [];
- const resourceEntities = getFieldValues(resources?.filter, 'URN') || [];
- const resourceFilterCondition = getFieldCondition(resources?.filter, 'URN') || PolicyMatchCondition.Equals;
+ const resourceEntities = getFieldValues(resources?.filter, URN, RESOURCE_URN) || [];
+ const resourceFilterCondition =
+ getFieldCondition(resources?.filter, URN, RESOURCE_URN) || PolicyMatchCondition.Equals;
const domains = getFieldValues(resources?.filter, 'DOMAIN') || [];
const {
diff --git a/datahub-web-react/src/app/permissions/policy/PolicyPrivilegeForm.tsx b/datahub-web-react/src/app/permissions/policy/PolicyPrivilegeForm.tsx
index 7a0de67f41419..414346c2776db 100644
--- a/datahub-web-react/src/app/permissions/policy/PolicyPrivilegeForm.tsx
+++ b/datahub-web-react/src/app/permissions/policy/PolicyPrivilegeForm.tsx
@@ -28,6 +28,7 @@ import ClickOutside from '../../shared/ClickOutside';
import { TagTermLabel } from '../../shared/tags/TagTermLabel';
import { ENTER_KEY_CODE } from '../../shared/constants';
import { useGetRecommendations } from '../../shared/recommendation';
+import { RESOURCE_TYPE, RESOURCE_URN, TYPE, URN } from './constants';
type Props = {
policyType: PolicyType;
@@ -102,8 +103,9 @@ export default function PolicyPrivilegeForm({
} = useAppConfig();
const resources: ResourceFilter = convertLegacyResourceFilter(maybeResources) || EMPTY_POLICY.resources;
- const resourceTypes = getFieldValues(resources.filter, 'TYPE') || [];
- const resourceEntities = getFieldValues(resources.filter, 'URN') || [];
+ // RESOURCE_TYPE and RESOURCE_URN are deprecated, but need to get them for backwards compatibility
+ const resourceTypes = getFieldValues(resources.filter, TYPE, RESOURCE_TYPE) || [];
+ const resourceEntities = getFieldValues(resources.filter, URN, RESOURCE_URN) || [];
const getDisplayName = (entity) => {
if (!entity) {
@@ -178,9 +180,14 @@ export default function PolicyPrivilegeForm({
const filter = resources.filter || {
criteria: [],
};
+ // remove the deprecated RESOURCE_TYPE field and replace with TYPE field
+ const filterWithoutDeprecatedField = setFieldValues(filter, RESOURCE_TYPE, []);
setResources({
...resources,
- filter: setFieldValues(filter, 'TYPE', [...resourceTypes, createCriterionValue(selectedResourceType)]),
+ filter: setFieldValues(filterWithoutDeprecatedField, TYPE, [
+ ...resourceTypes,
+ createCriterionValue(selectedResourceType),
+ ]),
});
};
@@ -188,11 +195,13 @@ export default function PolicyPrivilegeForm({
const filter = resources.filter || {
criteria: [],
};
+ // remove the deprecated RESOURCE_TYPE field and replace with TYPE field
+ const filterWithoutDeprecatedField = setFieldValues(filter, RESOURCE_TYPE, []);
setResources({
...resources,
filter: setFieldValues(
- filter,
- 'TYPE',
+ filterWithoutDeprecatedField,
+ TYPE,
resourceTypes?.filter((criterionValue) => criterionValue.value !== deselectedResourceType),
),
});
@@ -203,9 +212,11 @@ export default function PolicyPrivilegeForm({
const filter = resources.filter || {
criteria: [],
};
+ // remove the deprecated RESOURCE_URN field and replace with URN field
+ const filterWithoutDeprecatedField = setFieldValues(filter, RESOURCE_URN, []);
setResources({
...resources,
- filter: setFieldValues(filter, 'URN', [
+ filter: setFieldValues(filterWithoutDeprecatedField, URN, [
...resourceEntities,
createCriterionValueWithEntity(
resource,
@@ -220,11 +231,13 @@ export default function PolicyPrivilegeForm({
const filter = resources.filter || {
criteria: [],
};
+ // remove the deprecated RESOURCE_URN field and replace with URN field
+ const filterWithoutDeprecatedField = setFieldValues(filter, RESOURCE_URN, []);
setResources({
...resources,
filter: setFieldValues(
- filter,
- 'URN',
+ filterWithoutDeprecatedField,
+ URN,
resourceEntities?.filter((criterionValue) => criterionValue.value !== resource),
),
});
diff --git a/datahub-web-react/src/app/permissions/policy/_tests_/policyUtils.test.tsx b/datahub-web-react/src/app/permissions/policy/_tests_/policyUtils.test.tsx
index 1c9884e5fcf09..eae735b3477f0 100644
--- a/datahub-web-react/src/app/permissions/policy/_tests_/policyUtils.test.tsx
+++ b/datahub-web-react/src/app/permissions/policy/_tests_/policyUtils.test.tsx
@@ -1,4 +1,12 @@
-import { addOrUpdatePoliciesInList, updateListPoliciesCache, removeFromListPoliciesCache } from '../policyUtils';
+import { PolicyMatchCondition } from '../../../../types.generated';
+import {
+ addOrUpdatePoliciesInList,
+ updateListPoliciesCache,
+ removeFromListPoliciesCache,
+ getFieldValues,
+ getFieldCondition,
+ setFieldValues,
+} from '../policyUtils';
// Mock the Apollo Client readQuery and writeQuery methods
const mockReadQuery = vi.fn();
@@ -103,3 +111,153 @@ describe('removeFromListPoliciesCache', () => {
});
});
});
+
+describe('getFieldValues', () => {
+ it('should get field values for a given field', () => {
+ const filter = {
+ criteria: [
+ {
+ condition: PolicyMatchCondition.Equals,
+ field: 'TYPE',
+ values: [{ value: 'dataset' }, { value: 'dataJob' }],
+ },
+ ],
+ };
+
+ expect(getFieldValues(filter, 'TYPE')).toMatchObject([{ value: 'dataset' }, { value: 'dataJob' }]);
+ });
+
+ it('should get field values for a alternate field (for deprecated fields)', () => {
+ const filter = {
+ criteria: [
+ {
+ condition: PolicyMatchCondition.Equals,
+ field: 'RESOURCE_TYPE',
+ values: [{ value: 'dataset' }, { value: 'dataJob' }],
+ },
+ ],
+ };
+
+ expect(getFieldValues(filter, 'TYPE', 'RESOURCE_TYPE')).toMatchObject([
+ { value: 'dataset' },
+ { value: 'dataJob' },
+ ]);
+ });
+
+ it('should get field values for main field with alternative field given and has values', () => {
+ const filter = {
+ criteria: [
+ {
+ condition: PolicyMatchCondition.Equals,
+ field: 'RESOURCE_TYPE',
+ values: [{ value: 'container' }, { value: 'dataFlow' }],
+ },
+ {
+ condition: PolicyMatchCondition.Equals,
+ field: 'TYPE',
+ values: [{ value: 'dataset' }, { value: 'dataJob' }],
+ },
+ ],
+ };
+
+ // should only return values from main field
+ expect(getFieldValues(filter, 'TYPE', 'RESOURCE_TYPE')).toMatchObject([
+ { value: 'dataset' },
+ { value: 'dataJob' },
+ ]);
+ });
+});
+
+describe('getFieldCondition', () => {
+ it('should get field values for a given field', () => {
+ const filter = {
+ criteria: [
+ {
+ condition: PolicyMatchCondition.Equals,
+ field: 'TYPE',
+ values: [{ value: 'dataset' }],
+ },
+ ],
+ };
+
+ expect(getFieldCondition(filter, 'TYPE')).toBe(PolicyMatchCondition.Equals);
+ });
+
+ it('should get field values for a alternate field (for deprecated fields)', () => {
+ const filter = {
+ criteria: [
+ {
+ condition: PolicyMatchCondition.Equals,
+ field: 'RESOURCE_TYPE',
+ values: [{ value: 'dataset' }],
+ },
+ ],
+ };
+
+ expect(getFieldCondition(filter, 'TYPE', 'RESOURCE_TYPE')).toBe(PolicyMatchCondition.Equals);
+ });
+
+ it('should get field values for main field with alternative field given and has values', () => {
+ const filter = {
+ criteria: [
+ {
+ condition: PolicyMatchCondition.StartsWith,
+ field: 'RESOURCE_TYPE',
+ values: [{ value: 'container' }, { value: 'dataFlow' }],
+ },
+ {
+ condition: PolicyMatchCondition.Equals,
+ field: 'TYPE',
+ values: [{ value: 'dataset' }],
+ },
+ ],
+ };
+
+ // should only return values from main field
+ expect(getFieldCondition(filter, 'TYPE', 'RESOURCE_TYPE')).toBe(PolicyMatchCondition.Equals);
+ });
+});
+describe('setFieldValues', () => {
+ it('should remove a field if you pass in an empty array', () => {
+ const filter = {
+ criteria: [
+ {
+ condition: PolicyMatchCondition.Equals,
+ field: 'RESOURCE_TYPE',
+ values: [{ value: 'dataset' }],
+ },
+ {
+ condition: PolicyMatchCondition.Equals,
+ field: 'TYPE',
+ values: [{ value: 'dataJob' }],
+ },
+ ],
+ };
+
+ expect(setFieldValues(filter, 'RESOURCE_TYPE', [])).toMatchObject({
+ criteria: [
+ {
+ condition: PolicyMatchCondition.Equals,
+ field: 'TYPE',
+ values: [{ value: 'dataJob' }],
+ },
+ ],
+ });
+ });
+
+ it('should set values for a field properly', () => {
+ const filter = {
+ criteria: [],
+ };
+
+ expect(setFieldValues(filter, 'TYPE', [{ value: 'dataFlow' }])).toMatchObject({
+ criteria: [
+ {
+ condition: PolicyMatchCondition.Equals,
+ field: 'TYPE',
+ values: [{ value: 'dataFlow' }],
+ },
+ ],
+ });
+ });
+});
diff --git a/datahub-web-react/src/app/permissions/policy/constants.ts b/datahub-web-react/src/app/permissions/policy/constants.ts
new file mode 100644
index 0000000000000..cdd20bf9b50d6
--- /dev/null
+++ b/datahub-web-react/src/app/permissions/policy/constants.ts
@@ -0,0 +1,4 @@
+export const TYPE = 'TYPE';
+export const RESOURCE_TYPE = 'RESOURCE_TYPE';
+export const URN = 'URN';
+export const RESOURCE_URN = 'RESOURCE_URN';
diff --git a/datahub-web-react/src/app/permissions/policy/policyUtils.ts b/datahub-web-react/src/app/permissions/policy/policyUtils.ts
index b71a38f80fc25..d6221a0a9293a 100644
--- a/datahub-web-react/src/app/permissions/policy/policyUtils.ts
+++ b/datahub-web-react/src/app/permissions/policy/policyUtils.ts
@@ -114,12 +114,28 @@ export const convertLegacyResourceFilter = (resourceFilter: Maybe | undefined, resourceFieldType: string) => {
- return filter?.criteria?.find((criterion) => criterion.field === resourceFieldType)?.values || [];
+export const getFieldValues = (
+ filter: Maybe | undefined,
+ resourceFieldType: string,
+ alternateResourceFieldType?: string,
+) => {
+ return (
+ filter?.criteria?.find((criterion) => criterion.field === resourceFieldType)?.values ||
+ filter?.criteria?.find((criterion) => criterion.field === alternateResourceFieldType)?.values ||
+ []
+ );
};
-export const getFieldCondition = (filter: Maybe | undefined, resourceFieldType: string) => {
- return filter?.criteria?.find((criterion) => criterion.field === resourceFieldType)?.condition || null;
+export const getFieldCondition = (
+ filter: Maybe | undefined,
+ resourceFieldType: string,
+ alternateResourceFieldType?: string,
+) => {
+ return (
+ filter?.criteria?.find((criterion) => criterion.field === resourceFieldType)?.condition ||
+ filter?.criteria?.find((criterion) => criterion.field === alternateResourceFieldType)?.condition ||
+ null
+ );
};
export const getFieldValuesOfTags = (filter: Maybe | undefined, resourceFieldType: string) => {
diff --git a/datahub-web-react/src/app/preview/DefaultPreviewCard.tsx b/datahub-web-react/src/app/preview/DefaultPreviewCard.tsx
index 0d5cfaaf42b9a..4c8948a6664e0 100644
--- a/datahub-web-react/src/app/preview/DefaultPreviewCard.tsx
+++ b/datahub-web-react/src/app/preview/DefaultPreviewCard.tsx
@@ -37,6 +37,8 @@ import { DataProductLink } from '../shared/tags/DataProductLink';
import { EntityHealth } from '../entity/shared/containers/profile/header/EntityHealth';
import SearchTextHighlighter from '../search/matches/SearchTextHighlighter';
import { getUniqueOwners } from './utils';
+import StructuredPropertyBadge from '../entity/shared/containers/profile/header/StructuredPropertyBadge';
+import { usePreviewData } from '../entity/shared/PreviewContext';
const PreviewContainer = styled.div`
display: flex;
@@ -245,6 +247,7 @@ export default function DefaultPreviewCard({
// sometimes these lists will be rendered inside an entity container (for example, in the case of impact analysis)
// in those cases, we may want to enrich the preview w/ context about the container entity
const { entityData } = useEntityData();
+ const previewData = usePreviewData();
const insightViews: Array = [
...(insights?.map((insight) => (
<>
@@ -305,6 +308,7 @@ export default function DefaultPreviewCard({
)}
{health && health.length > 0 ? : null}
+
{externalUrl && (
{
orFilters,
viewUrn,
sortInput,
- searchFlags: { getSuggestions: true },
+ searchFlags: { getSuggestions: true, includeStructuredPropertyFacets: true },
},
},
fetchPolicy: 'cache-and-network',
diff --git a/datahub-web-react/src/app/search/filters/ActiveFilter.tsx b/datahub-web-react/src/app/search/filters/ActiveFilter.tsx
index 29789f872e51d..5882641cd2c9c 100644
--- a/datahub-web-react/src/app/search/filters/ActiveFilter.tsx
+++ b/datahub-web-react/src/app/search/filters/ActiveFilter.tsx
@@ -49,9 +49,18 @@ function ActiveFilter({
const filterEntity = getFilterEntity(filterFacet.field, filterValue, availableFilters);
const filterLabelOverride = useGetBrowseV2LabelOverride(filterFacet.field, filterValue, entityRegistry);
const filterRenderer = useFilterRendererRegistry();
+ const facetEntity = availableFilters?.find((f) => f.field === filterFacet.field)?.entity;
const { icon, label } = !filterRenderer.hasRenderer(filterFacet.field)
- ? getFilterIconAndLabel(filterFacet.field, filterValue, entityRegistry, filterEntity, 12, filterLabelOverride)
+ ? getFilterIconAndLabel(
+ filterFacet.field,
+ filterValue,
+ entityRegistry,
+ filterEntity,
+ 12,
+ filterLabelOverride,
+ facetEntity,
+ )
: {
icon: filterRenderer.getIcon(filterFacet.field),
label: filterRenderer.getValueLabel(filterFacet.field, filterValue),
diff --git a/datahub-web-react/src/app/search/filters/DateRangeMenu/DateRangeMenu.tsx b/datahub-web-react/src/app/search/filters/DateRangeMenu/DateRangeMenu.tsx
new file mode 100644
index 0000000000000..aace8dcb97f48
--- /dev/null
+++ b/datahub-web-react/src/app/search/filters/DateRangeMenu/DateRangeMenu.tsx
@@ -0,0 +1,88 @@
+import moment from 'moment';
+import { Text } from '@src/alchemy-components';
+import React, { useCallback, useRef, useState } from 'react';
+import { DatePicker } from 'antd';
+import styled from 'styled-components';
+import { FacetFilterInput, FacetMetadata, FilterOperator } from '@src/types.generated';
+import { useFilterDisplayName } from '../utils';
+import useDateRangeFilterValues, { Datetime } from './useDateRangeFilterValues';
+
+const { RangePicker } = DatePicker;
+
+const Container = styled.div`
+ padding: 16px;
+ background-color: #ffffff;
+ box-shadow: 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 9px 28px 8px rgba(0, 0, 0, 0.05);
+ border-radius: 8px;
+ min-width: 225px;
+`;
+
+interface Props {
+ field: FacetMetadata;
+ manuallyUpdateFilters: (newValues: FacetFilterInput[]) => void;
+}
+
+export default function DateRangeMenu({ field, manuallyUpdateFilters }: Props) {
+ const displayName = useFilterDisplayName(field);
+ moment.tz.setDefault('GMT');
+
+ const [startDate, setStartDate] = useState(null);
+ const [endDate, setEndDate] = useState(null);
+ const [isOpen, setIsOpen] = useState(false);
+ const ref = useRef(null);
+
+ useDateRangeFilterValues({ filterField: field.field, setStartDate, setEndDate });
+
+ const handleOpenChange = useCallback(
+ (open: boolean) => {
+ setIsOpen(open);
+ if (!open) {
+ ref.current?.blur();
+ if (startDate && endDate) {
+ manuallyUpdateFilters([
+ {
+ field: field.field,
+ values: [startDate.valueOf().toString()],
+ condition: FilterOperator.GreaterThan,
+ },
+ {
+ field: field.field,
+ values: [endDate.valueOf().toString()],
+ condition: FilterOperator.LessThan,
+ },
+ ]);
+ }
+ }
+ },
+ [startDate, endDate, field.field, manuallyUpdateFilters],
+ );
+
+ const handleRangeChange = useCallback((dates: [Datetime, Datetime] | null) => {
+ const [start, end] = dates || [null, null];
+
+ start?.set({ hour: 0, minute: 0, second: 0, millisecond: 0 });
+ end?.set({ hour: 23, minute: 59, second: 59, millisecond: 999 });
+
+ setStartDate(start);
+ setEndDate(end);
+ }, []);
+
+ return (
+
+ Filter by {displayName}
+ handleOpenChange(true)}
+ style={{ paddingLeft: 0, paddingRight: 0 }}
+ />
+
+ );
+}
diff --git a/datahub-web-react/src/app/search/filters/DateRangeMenu/useDateRangeFilterValues.ts b/datahub-web-react/src/app/search/filters/DateRangeMenu/useDateRangeFilterValues.ts
new file mode 100644
index 0000000000000..ce1810c86002f
--- /dev/null
+++ b/datahub-web-react/src/app/search/filters/DateRangeMenu/useDateRangeFilterValues.ts
@@ -0,0 +1,48 @@
+import { useEffect, useMemo } from 'react';
+import { FilterOperator } from '@src/types.generated';
+import moment from 'moment';
+import useGetSearchQueryInputs from '../../useGetSearchQueryInputs';
+
+export type Datetime = moment.Moment | null;
+
+interface Props {
+ filterField: string;
+ setStartDate: React.Dispatch>;
+ setEndDate: React.Dispatch>;
+}
+
+export default function useDateRangeFilterValues({ filterField, setStartDate, setEndDate }: Props) {
+ const { filters: activeFilters } = useGetSearchQueryInputs();
+ const matchingFilters = useMemo(
+ () => activeFilters.filter((f) => f.field === filterField),
+ [activeFilters, filterField],
+ );
+ const startDateFromFilter = useMemo(
+ () => matchingFilters.find((f) => f.condition === FilterOperator.GreaterThan)?.values?.[0],
+ [matchingFilters],
+ );
+ const endDateFromFilter = useMemo(
+ () => matchingFilters.find((f) => f.condition === FilterOperator.LessThan)?.values?.[0],
+ [matchingFilters],
+ );
+
+ useEffect(() => {
+ if (startDateFromFilter) {
+ setStartDate(
+ moment(parseInt(startDateFromFilter, 10)).set({ hour: 0, minute: 0, second: 0, millisecond: 0 }),
+ );
+ } else {
+ setStartDate(null);
+ }
+ }, [startDateFromFilter, setStartDate]);
+
+ useEffect(() => {
+ if (endDateFromFilter) {
+ setEndDate(
+ moment(parseInt(endDateFromFilter, 10)).set({ hour: 23, minute: 59, second: 59, millisecond: 999 }),
+ );
+ } else {
+ setEndDate(null);
+ }
+ }, [endDateFromFilter, setEndDate]);
+}
diff --git a/datahub-web-react/src/app/search/filters/EntityTypeFilter/entityTypeFilterUtils.ts b/datahub-web-react/src/app/search/filters/EntityTypeFilter/entityTypeFilterUtils.ts
index 38f8007985451..87c85a4e39dc0 100644
--- a/datahub-web-react/src/app/search/filters/EntityTypeFilter/entityTypeFilterUtils.ts
+++ b/datahub-web-react/src/app/search/filters/EntityTypeFilter/entityTypeFilterUtils.ts
@@ -91,7 +91,7 @@ export function getDisplayedFilterOptions(
.filter(
(option) => option.value.includes(FILTER_DELIMITER) && option.value.includes(filterOption.value),
)
- .map((option) => ({ field: ENTITY_SUB_TYPE_FILTER_NAME, ...option }))
+ .map((option) => ({ field: ENTITY_SUB_TYPE_FILTER_NAME, ...option } as FilterOptionType))
.filter((o) => filterNestedOptions(o, entityRegistry, searchQuery));
return mapFilterOption({
filterOption,
diff --git a/datahub-web-react/src/app/search/filters/FilterOption.tsx b/datahub-web-react/src/app/search/filters/FilterOption.tsx
index 50b78c7f0685c..15851eb915e73 100644
--- a/datahub-web-react/src/app/search/filters/FilterOption.tsx
+++ b/datahub-web-react/src/app/search/filters/FilterOption.tsx
@@ -123,9 +123,10 @@ export default function FilterOption({
addPadding,
}: Props) {
const [areChildrenVisible, setAreChildrenVisible] = useState(true);
- const { field, value, count, entity } = filterOption;
+ const { field, value, count, entity, displayName } = filterOption;
const entityRegistry = useEntityRegistry();
const { icon, label } = getFilterIconAndLabel(field, value, entityRegistry, entity || null, 14);
+ const finalLabel = displayName || label;
const shouldShowIcon = (field === PLATFORM_FILTER_NAME || field === CONTAINER_FILTER_NAME) && icon !== null;
const shouldShowTagColor = field === TAGS_FILTER_NAME && entity?.type === EntityType.Tag;
const isSubTypeFilter = field === TYPE_NAMES_FILTER_NAME;
@@ -156,7 +157,7 @@ export default function FilterOption({
nestedOptions?.map((o) => o.value),
)}
onClick={updateFilterValues}
- data-testid={`filter-option-${label}`}
+ data-testid={`filter-option-${finalLabel}`}
>
{parentEntities.length > 0 && (
@@ -170,8 +171,8 @@ export default function FilterOption({
)}
{(shouldShowIcon || shouldShowTagColor) && }
-
- {isSubTypeFilter ? capitalizeFirstLetterOnly(label as string) : label}
+
+ {isSubTypeFilter ? capitalizeFirstLetterOnly(finalLabel as string) : finalLabel}
{countText}
{nestedOptions && nestedOptions.length > 0 && (
diff --git a/datahub-web-react/src/app/search/filters/MoreFilterOption.tsx b/datahub-web-react/src/app/search/filters/MoreFilterOption.tsx
index 5012f8c645d17..41df6b86c0162 100644
--- a/datahub-web-react/src/app/search/filters/MoreFilterOption.tsx
+++ b/datahub-web-react/src/app/search/filters/MoreFilterOption.tsx
@@ -1,14 +1,13 @@
import { RightOutlined } from '@ant-design/icons';
import { Dropdown } from 'antd';
-import styled from 'styled-components';
import React, { useRef } from 'react';
+import styled from 'styled-components';
import { FacetFilterInput, FacetMetadata } from '../../../types.generated';
-import { capitalizeFirstLetterOnly } from '../../shared/textUtil';
import OptionsDropdownMenu from './OptionsDropdownMenu';
-import useSearchFilterDropdown from './useSearchFilterDropdown';
import { IconWrapper } from './SearchFilterView';
-import { getFilterDropdownIcon, useElementDimensions } from './utils';
import { MoreFilterOptionLabel } from './styledComponents';
+import useSearchFilterDropdown from './useSearchFilterDropdown';
+import { getFilterDropdownIcon, useElementDimensions, useFilterDisplayName } from './utils';
const IconNameWrapper = styled.span`
display: flex;
@@ -34,12 +33,14 @@ export default function MoreFilterOption({ filter, activeFilters, onChangeFilter
areFiltersLoading,
searchQuery,
updateSearchQuery,
+ manuallyUpdateFilters,
} = useSearchFilterDropdown({
filter,
activeFilters,
onChangeFilters,
});
const filterIcon = getFilterDropdownIcon(filter.field);
+ const displayName = useFilterDisplayName(filter);
return (
)}
>
@@ -64,11 +67,11 @@ export default function MoreFilterOption({ filter, activeFilters, onChangeFilter
onClick={() => updateIsMenuOpen(!isMenuOpen)}
isActive={!!numActiveFilters}
isOpen={isMenuOpen}
- data-testid={`more-filter-${capitalizeFirstLetterOnly(filter.displayName)}`}
+ data-testid={`more-filter-${displayName?.replace(/\s/g, '-')}`}
>
{filterIcon && {filterIcon} }
- {capitalizeFirstLetterOnly(filter.displayName)} {numActiveFilters ? `(${numActiveFilters}) ` : ''}
+ {displayName} {numActiveFilters ? `(${numActiveFilters}) ` : ''}
diff --git a/datahub-web-react/src/app/search/filters/OptionsDropdownMenu.tsx b/datahub-web-react/src/app/search/filters/OptionsDropdownMenu.tsx
index 13196af5ef85b..f1c806671a39b 100644
--- a/datahub-web-react/src/app/search/filters/OptionsDropdownMenu.tsx
+++ b/datahub-web-react/src/app/search/filters/OptionsDropdownMenu.tsx
@@ -2,9 +2,12 @@ import { LoadingOutlined } from '@ant-design/icons';
import { Button } from 'antd';
import React, { CSSProperties } from 'react';
import styled from 'styled-components/macro';
+import { FacetFilterInput, FacetMetadata } from '@src/types.generated';
import { useEntityRegistry } from '../../useEntityRegistry';
import { SearchBar } from '../SearchBar';
import { useEnterKeyListener } from '../../shared/useEnterKeyListener';
+import { getIsDateRangeFilter } from './utils';
+import DateRangeMenu from './DateRangeMenu/DateRangeMenu';
const StyledButton = styled(Button)`
width: 100%;
@@ -53,6 +56,8 @@ interface Props {
updateSearchQuery: (query: string) => void;
searchPlaceholder?: string;
style?: CSSProperties;
+ filter?: FacetMetadata;
+ manuallyUpdateFilters?: (newValues: FacetFilterInput[]) => void;
}
export default function OptionsDropdownMenu({
@@ -63,11 +68,21 @@ export default function OptionsDropdownMenu({
updateSearchQuery,
searchPlaceholder,
style,
+ filter,
+ manuallyUpdateFilters,
}: Props) {
const entityRegistry = useEntityRegistry();
useEnterKeyListener({ querySelectorToExecuteClick: '#updateFiltersButton' });
+ if (filter && manuallyUpdateFilters && getIsDateRangeFilter(filter)) {
+ return (
+
+
+
+ );
+ }
+
return (
diff --git a/datahub-web-react/src/app/search/filters/SearchFilter.tsx b/datahub-web-react/src/app/search/filters/SearchFilter.tsx
index 26b3a0d5ff5c2..9a69c4ec07716 100644
--- a/datahub-web-react/src/app/search/filters/SearchFilter.tsx
+++ b/datahub-web-react/src/app/search/filters/SearchFilter.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import { FacetFilterInput, FacetMetadata } from '../../../types.generated';
import useSearchFilterDropdown from './useSearchFilterDropdown';
-import { getFilterDropdownIcon } from './utils';
+import { getFilterDropdownIcon, useFilterDisplayName } from './utils';
import SearchFilterView from './SearchFilterView';
import { ENTITY_FILTER_NAME } from '../utils/constants';
import EntityTypeFilter from './EntityTypeFilter/EntityTypeFilter';
@@ -22,12 +22,14 @@ export default function SearchFilter({ filter, activeFilters, onChangeFilters }:
areFiltersLoading,
searchQuery,
updateSearchQuery,
+ manuallyUpdateFilters,
} = useSearchFilterDropdown({
filter,
activeFilters,
onChangeFilters,
});
const filterIcon = getFilterDropdownIcon(filter.field);
+ const displayName = useFilterDisplayName(filter);
if (filter.field === ENTITY_FILTER_NAME) {
return ;
@@ -39,12 +41,14 @@ export default function SearchFilter({ filter, activeFilters, onChangeFilters }:
isMenuOpen={isMenuOpen}
numActiveFilters={numActiveFilters}
filterIcon={filterIcon}
- displayName={filter.displayName || ''}
+ displayName={displayName || ''}
searchQuery={searchQuery}
loading={areFiltersLoading}
updateIsMenuOpen={updateIsMenuOpen}
setSearchQuery={updateSearchQuery}
updateFilters={updateFilters}
+ filter={filter}
+ manuallyUpdateFilters={manuallyUpdateFilters}
/>
);
}
diff --git a/datahub-web-react/src/app/search/filters/SearchFilterView.tsx b/datahub-web-react/src/app/search/filters/SearchFilterView.tsx
index ee448e6e1acf2..c8c629d3735b8 100644
--- a/datahub-web-react/src/app/search/filters/SearchFilterView.tsx
+++ b/datahub-web-react/src/app/search/filters/SearchFilterView.tsx
@@ -2,8 +2,8 @@ import { CaretDownFilled } from '@ant-design/icons';
import { Dropdown } from 'antd';
import React from 'react';
import styled from 'styled-components';
+import { FacetFilterInput, FacetMetadata } from '@src/types.generated';
import OptionsDropdownMenu from './OptionsDropdownMenu';
-import { capitalizeFirstLetterOnly } from '../../shared/textUtil';
import { DisplayedFilterOption } from './mapFilterOption';
import { SearchFilterLabel } from './styledComponents';
@@ -27,6 +27,8 @@ interface Props {
updateIsMenuOpen: (isOpen: boolean) => void;
setSearchQuery: (query: string) => void;
updateFilters: () => void;
+ filter?: FacetMetadata;
+ manuallyUpdateFilters?: (newValues: FacetFilterInput[]) => void;
}
export default function SearchFilterView({
@@ -40,6 +42,8 @@ export default function SearchFilterView({
updateIsMenuOpen,
setSearchQuery,
updateFilters,
+ filter,
+ manuallyUpdateFilters,
}: Props) {
return (
)}
>
updateIsMenuOpen(!isMenuOpen)}
isActive={!!numActiveFilters}
- data-testid={`filter-dropdown-${capitalizeFirstLetterOnly(displayName)}`}
+ data-testid={`filter-dropdown-${displayName?.replace(/\s/g, '-')}`}
>
{filterIcon && {filterIcon} }
- {capitalizeFirstLetterOnly(displayName)} {numActiveFilters ? `(${numActiveFilters}) ` : ''}
+ {displayName} {numActiveFilters ? `(${numActiveFilters}) ` : ''}
diff --git a/datahub-web-react/src/app/search/filters/mapFilterOption.tsx b/datahub-web-react/src/app/search/filters/mapFilterOption.tsx
index 4f2b7bf21051c..6abc3505fe3a4 100644
--- a/datahub-web-react/src/app/search/filters/mapFilterOption.tsx
+++ b/datahub-web-react/src/app/search/filters/mapFilterOption.tsx
@@ -45,7 +45,7 @@ export function mapFilterOption({
/>
),
style: { padding: 0 },
- displayName: displayName as string,
+ displayName: filterOption.displayName || (displayName as string),
nestedOptions,
};
}
diff --git a/datahub-web-react/src/app/search/filters/types.ts b/datahub-web-react/src/app/search/filters/types.ts
index 3f4a705e827c4..6704c7fa01a48 100644
--- a/datahub-web-react/src/app/search/filters/types.ts
+++ b/datahub-web-react/src/app/search/filters/types.ts
@@ -5,4 +5,5 @@ export interface FilterOptionType {
value: string;
count?: number;
entity?: Entity | null;
+ displayName?: string;
}
diff --git a/datahub-web-react/src/app/search/filters/useSearchFilterDropdown.tsx b/datahub-web-react/src/app/search/filters/useSearchFilterDropdown.tsx
index 8467449ab6029..a614ae8e7f35e 100644
--- a/datahub-web-react/src/app/search/filters/useSearchFilterDropdown.tsx
+++ b/datahub-web-react/src/app/search/filters/useSearchFilterDropdown.tsx
@@ -81,6 +81,15 @@ export default function useSearchFilterDropdown({ filter, activeFilters, onChang
setIsMenuOpen(false);
}
+ function manuallyUpdateFilters(newFilters: FacetFilterInput[]) {
+ // remove any filters that are in newFilters to overwrite them
+ const filtersNotInNewFilters = activeFilters.filter(
+ (f) => !newFilters.find((newFilter) => newFilter.field === f.field),
+ );
+ onChangeFilters([...filtersNotInNewFilters, ...newFilters]);
+ setIsMenuOpen(false);
+ }
+
function updateSearchQuery(newQuery: string) {
setSearchQuery(newQuery);
if (newQuery && FACETS_TO_ENTITY_TYPES[filter.field]) {
@@ -107,7 +116,13 @@ export default function useSearchFilterDropdown({ filter, activeFilters, onChang
);
// filter out any aggregations with a count of 0 unless it's already selected and in activeFilters
const finalAggregations = filterEmptyAggregations(combinedAggregations, activeFilters);
- const filterOptions = getFilterOptions(filter.field, finalAggregations, selectedFilterOptions, autoCompleteResults)
+ const filterOptions = getFilterOptions(
+ filter.field,
+ finalAggregations,
+ selectedFilterOptions,
+ autoCompleteResults,
+ filter.entity,
+ )
.map((filterOption) =>
mapFilterOption({ filterOption, entityRegistry, selectedFilterOptions, setSelectedFilterOptions }),
)
@@ -122,5 +137,6 @@ export default function useSearchFilterDropdown({ filter, activeFilters, onChang
areFiltersLoading: loading,
searchQuery,
updateSearchQuery,
+ manuallyUpdateFilters,
};
}
diff --git a/datahub-web-react/src/app/search/filters/utils.tsx b/datahub-web-react/src/app/search/filters/utils.tsx
index bd747777d1117..2bbad25153363 100644
--- a/datahub-web-react/src/app/search/filters/utils.tsx
+++ b/datahub-web-react/src/app/search/filters/utils.tsx
@@ -1,4 +1,7 @@
+import moment from 'moment-timezone';
+import { useEntityRegistry } from '@src/app/useEntityRegistry';
import Icon from '@ant-design/icons/lib/components/Icon';
+import TableIcon from '@src/images/table-icon.svg?react';
import {
BookOutlined,
DatabaseOutlined,
@@ -8,6 +11,8 @@ import {
TagOutlined,
UserOutlined,
} from '@ant-design/icons';
+import { removeMarkdown } from '@src/app/entity/shared/components/styled/StripMarkdownText';
+import { DATE_TYPE_URN } from '@src/app/shared/constants';
import React, { useLayoutEffect, useState } from 'react';
import styled from 'styled-components';
import {
@@ -21,6 +26,7 @@ import {
FacetMetadata,
GlossaryTerm,
Container,
+ StructuredPropertyEntity,
} from '../../../types.generated';
import { IconStyleType } from '../../entity/Entity';
import {
@@ -34,6 +40,7 @@ import {
LEGACY_ENTITY_FILTER_NAME,
OWNERS_FILTER_NAME,
PLATFORM_FILTER_NAME,
+ STRUCTURED_PROPERTIES_FILTER_NAME,
TAGS_FILTER_NAME,
TYPE_NAMES_FILTER_NAME,
UNIT_SEPARATOR,
@@ -164,6 +171,7 @@ export function getFilterIconAndLabel(
filterEntity: Entity | null,
size?: number,
filterLabelOverride?: string | null,
+ facetEntity?: Entity | null,
) {
let icon: React.ReactNode = null;
let label: React.ReactNode = null;
@@ -209,6 +217,9 @@ export function getFilterIconAndLabel(
icon = newIcon;
label = newLabel;
+ } else if (filterField.startsWith(STRUCTURED_PROPERTIES_FILTER_NAME)) {
+ label = getStructuredPropFilterDisplayName(filterField, filterValue, facetEntity);
+ icon = ;
} else {
label = filterValue;
}
@@ -268,6 +279,10 @@ export function sortFacets(facetA: FacetMetadata, facetB: FacetMetadata, sortedF
}
export function getFilterDropdownIcon(field: string) {
+ if (field.startsWith(STRUCTURED_PROPERTIES_FILTER_NAME)) {
+ return ;
+ }
+
switch (field) {
case PLATFORM_FILTER_NAME:
return ;
@@ -296,8 +311,13 @@ export function getFilterOptions(
aggregations: AggregationMetadata[],
selectedFilterOptions: FilterOptionType[],
autoCompleteResults?: GetAutoCompleteMultipleResultsQuery,
+ filterEntity?: Entity | null,
) {
- const aggregationFilterOptions = aggregations.map((agg) => ({ field: filterField, ...agg }));
+ const aggregationFilterOptions = aggregations.map((agg) => ({
+ field: filterField,
+ displayName: getStructuredPropFilterDisplayName(filterField, agg.value, filterEntity),
+ ...agg,
+ }));
const searchResults = autoCompleteResults?.autoCompleteForMultiple?.suggestions.find((suggestion) =>
FACETS_TO_ENTITY_TYPES[filterField]?.includes(suggestion.type),
@@ -384,3 +404,43 @@ export function useElementDimensions(ref) {
return dimensions;
}
+
+export function getStructuredPropFilterDisplayName(field: string, value: string, entity?: Entity | null) {
+ const isStructuredPropertyValue = field.startsWith('structuredProperties.');
+ if (!isStructuredPropertyValue) return undefined;
+
+ // check for structured prop entity values
+ if (value.startsWith('urn:li:')) {
+ // this value is an urn, handle entity display names elsewhere
+ return undefined;
+ }
+
+ // check for structured prop date values
+ if (entity && (entity as StructuredPropertyEntity).definition?.valueType?.urn === DATE_TYPE_URN) {
+ return moment(parseInt(value, 10)).tz('GMT').format('MM/DD/YYYY').toString();
+ }
+
+ // check for structured prop number values
+ if (!Number.isNaN(parseFloat(value))) {
+ return parseFloat(value).toString();
+ }
+
+ return removeMarkdown(value);
+}
+
+export function useFilterDisplayName(filter: FacetMetadata, predicateDisplayName?: string) {
+ const entityRegistry = useEntityRegistry();
+
+ if (filter.entity) {
+ return entityRegistry.getDisplayName(filter.entity.type, filter.entity);
+ }
+
+ return predicateDisplayName || filter.displayName || filter.field;
+}
+
+export function getIsDateRangeFilter(field: FacetMetadata) {
+ if (field.entity && field.entity.type === EntityType.StructuredProperty) {
+ return (field.entity as StructuredPropertyEntity).definition?.valueType?.urn === DATE_TYPE_URN;
+ }
+ return false;
+}
diff --git a/datahub-web-react/src/app/search/utils/constants.ts b/datahub-web-react/src/app/search/utils/constants.ts
index 8616f67ac2b6a..7aa416e12c954 100644
--- a/datahub-web-react/src/app/search/utils/constants.ts
+++ b/datahub-web-react/src/app/search/utils/constants.ts
@@ -25,6 +25,11 @@ export const REMOVED_FILTER_NAME = 'removed';
export const ORIGIN_FILTER_NAME = 'origin';
export const DEGREE_FILTER_NAME = 'degree';
export const BROWSE_PATH_V2_FILTER_NAME = 'browsePathV2';
+export const STRUCTURED_PROPERTIES_FILTER_NAME = 'structuredProperties.';
+export const ENTITY_TYPES_FILTER_NAME = 'entityTypes';
+export const IS_HIDDEN_PROPERTY_FILTER_NAME = 'isHidden';
+export const SHOW_IN_COLUMNS_TABLE_PROPERTY_FILTER_NAME = 'showInColumnsTable';
+export const SHOW_IN_ASSET_SUMMARY_PROPERTY_FILTER_NAME = 'showInAssetSummary';
export const LEGACY_ENTITY_FILTER_FIELDS = [ENTITY_FILTER_NAME, LEGACY_ENTITY_FILTER_NAME];
diff --git a/datahub-web-react/src/app/shared/admin/HeaderLinks.tsx b/datahub-web-react/src/app/shared/admin/HeaderLinks.tsx
index 8936a935735c5..2e8556a20b086 100644
--- a/datahub-web-react/src/app/shared/admin/HeaderLinks.tsx
+++ b/datahub-web-react/src/app/shared/admin/HeaderLinks.tsx
@@ -8,6 +8,7 @@ import {
SolutionOutlined,
DownOutlined,
GlobalOutlined,
+ UnorderedListOutlined,
} from '@ant-design/icons';
import { Link } from 'react-router-dom';
import { Button, Dropdown, Tooltip } from 'antd';
@@ -15,6 +16,7 @@ import { useAppConfig, useBusinessAttributesFlag } from '../../useAppConfig';
import { ANTD_GRAY } from '../../entity/shared/constants';
import { HOME_PAGE_INGESTION_ID } from '../../onboarding/config/HomePageOnboardingConfig';
import { useToggleEducationStepIdsAllowList } from '../../onboarding/useToggleEducationStepIdsAllowList';
+import { PageRoutes } from '../../../conf/Global';
import { useUserContext } from '../../context/useUserContext';
import DomainIcon from '../../domain/DomainIcon';
@@ -42,6 +44,10 @@ const NavTitleContainer = styled.span`
padding: 2px;
`;
+const NavSubItemTitleContainer = styled(NavTitleContainer)`
+ line-height: 20px;
+`;
+
const NavTitleText = styled.span`
margin-left: 6px;
font-weight: bold;
@@ -71,6 +77,9 @@ export function HeaderLinks(props: Props) {
const showSettings = true;
const showIngestion =
isIngestionEnabled && me && (me.platformPrivileges?.manageIngestion || me.platformPrivileges?.manageSecrets);
+ const showStructuredProperties =
+ config?.featureFlags?.showManageStructuredProperties &&
+ (me.platformPrivileges?.manageStructuredProperties || me.platformPrivileges?.viewStructuredPropertiesPage);
useToggleEducationStepIdsAllowList(!!showIngestion, HOME_PAGE_INGESTION_ID);
@@ -125,6 +134,22 @@ export function HeaderLinks(props: Props) {
},
]
: []),
+ ...(showStructuredProperties
+ ? [
+ {
+ key: 5,
+ label: (
+
+
+
+ Structured Properties
+
+ Manage custom properties for your data assets
+
+ ),
+ },
+ ]
+ : []),
];
return (
diff --git a/datahub-web-react/src/app/shared/constants.ts b/datahub-web-react/src/app/shared/constants.ts
index 1cd9077ab8cdf..5ccc6e5698faa 100644
--- a/datahub-web-react/src/app/shared/constants.ts
+++ b/datahub-web-react/src/app/shared/constants.ts
@@ -1,5 +1,8 @@
export const ENTER_KEY_CODE = 13;
+export const DATE_TYPE_URN = 'urn:li:dataType:datahub.date';
+export const TYPE_ICON_CLASS_NAME = 'typeIcon';
+
export enum ErrorCodes {
BadRequest = 400,
Unauthorized = 401,
diff --git a/datahub-web-react/src/app/shared/sidebar/EmptySectionText.tsx b/datahub-web-react/src/app/shared/sidebar/EmptySectionText.tsx
new file mode 100644
index 0000000000000..10236261fd577
--- /dev/null
+++ b/datahub-web-react/src/app/shared/sidebar/EmptySectionText.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import styled from 'styled-components';
+import { Typography } from 'antd';
+
+const EmptyContentMessage = styled(Typography.Text)`
+ font-size: 12px;
+ font-weight: 400;
+ color: #56668e;
+ opacity: 0.5;
+`;
+
+type Props = {
+ message: string;
+};
+
+const EmptySectionText = ({ message }: Props) => {
+ return {message}. ;
+};
+
+export default EmptySectionText;
diff --git a/datahub-web-react/src/app/sharedV2/modals/ConfirmationModal.tsx b/datahub-web-react/src/app/sharedV2/modals/ConfirmationModal.tsx
new file mode 100644
index 0000000000000..34c317d27a0a4
--- /dev/null
+++ b/datahub-web-react/src/app/sharedV2/modals/ConfirmationModal.tsx
@@ -0,0 +1,76 @@
+import { Button, Heading, Text, typography } from '@components';
+import { Modal } from 'antd';
+import React from 'react';
+import styled from 'styled-components';
+
+const ButtonsContainer = styled.div`
+ display: flex;
+ gap: 16px;
+ justify-content: end;
+`;
+
+export const StyledModal = styled(Modal)`
+ font-family: ${typography.fonts.body};
+
+ &&& .ant-modal-content {
+ box-shadow: 0px 4px 12px 0px rgba(9, 1, 61, 0.12);
+ border-radius: 12px;
+ }
+
+ .ant-modal-header {
+ border-bottom: 0;
+ padding-bottom: 0;
+ border-radius: 12px !important;
+ }
+
+ .ant-modal-body {
+ padding: 12px 24px;
+ }
+`;
+
+interface Props {
+ isOpen: boolean;
+ handleConfirm: (e: any) => void;
+ handleClose: () => void;
+ modalTitle?: string;
+ modalText?: string | React.ReactNode;
+ closeButtonText?: string;
+ confirmButtonText?: string;
+}
+
+export const ConfirmationModal = ({
+ isOpen,
+ handleClose,
+ handleConfirm,
+ modalTitle,
+ modalText,
+ closeButtonText,
+ confirmButtonText,
+}: Props) => {
+ return (
+
+
+ {closeButtonText || 'Cancel'}
+
+
+ {confirmButtonText || 'Yes'}
+
+
+ }
+ title={
+
+ {modalTitle || 'Confirm'}
+
+ }
+ >
+
+ {modalText || 'Are you sure?'}
+
+
+ );
+};
diff --git a/datahub-web-react/src/app/sharedV2/toastMessageUtils.ts b/datahub-web-react/src/app/sharedV2/toastMessageUtils.ts
new file mode 100644
index 0000000000000..b714d668e05fc
--- /dev/null
+++ b/datahub-web-react/src/app/sharedV2/toastMessageUtils.ts
@@ -0,0 +1,18 @@
+import { message } from 'antd';
+import { ReactNode } from 'react';
+
+export enum ToastType {
+ INFO = 'info',
+ ERROR = 'error',
+ SUCCESS = 'success',
+ WARNING = 'warning',
+ LOADING = 'loading',
+}
+
+export const showToastMessage = (type: ToastType, content: string | ReactNode, duration: number) => {
+ message.open({
+ type,
+ content,
+ duration,
+ });
+};
diff --git a/datahub-web-react/src/appConfigContext.tsx b/datahub-web-react/src/appConfigContext.tsx
index f119f8bc7d53a..d7fef85db4b62 100644
--- a/datahub-web-react/src/appConfigContext.tsx
+++ b/datahub-web-react/src/appConfigContext.tsx
@@ -56,6 +56,7 @@ export const DEFAULT_APP_CONFIG = {
dataContractsEnabled: false,
editableDatasetNameEnabled: false,
showSeparateSiblings: false,
+ showManageStructuredProperties: false,
},
};
diff --git a/datahub-web-react/src/conf/Global.ts b/datahub-web-react/src/conf/Global.ts
index e0172d3f07e41..7b3925f8c6ba1 100644
--- a/datahub-web-react/src/conf/Global.ts
+++ b/datahub-web-react/src/conf/Global.ts
@@ -25,6 +25,7 @@ export enum PageRoutes {
DOMAINS = '/domains',
DOMAIN = '/domain',
GLOSSARY = '/glossary',
+ STRUCTURED_PROPERTIES = '/structured-properties',
SETTINGS_VIEWS = '/settings/views',
EMBED = '/embed',
EMBED_LOOKUP = '/embed/lookup/:url',
diff --git a/datahub-web-react/src/graphql/app.graphql b/datahub-web-react/src/graphql/app.graphql
index 2a0baf238761f..0d1999f82f77c 100644
--- a/datahub-web-react/src/graphql/app.graphql
+++ b/datahub-web-react/src/graphql/app.graphql
@@ -71,6 +71,7 @@ query appConfig {
dataContractsEnabled
editableDatasetNameEnabled
showSeparateSiblings
+ showManageStructuredProperties
}
}
}
diff --git a/datahub-web-react/src/graphql/fragments.graphql b/datahub-web-react/src/graphql/fragments.graphql
index ade63f151d1a0..788c68349b426 100644
--- a/datahub-web-react/src/graphql/fragments.graphql
+++ b/datahub-web-react/src/graphql/fragments.graphql
@@ -589,6 +589,11 @@ fragment nonRecursiveMLFeature on MLFeature {
}
}
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
fragment nonRecursiveMLPrimaryKey on MLPrimaryKey {
@@ -659,6 +664,11 @@ fragment nonRecursiveMLPrimaryKey on MLPrimaryKey {
}
}
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
fragment nonRecursiveMLFeatureTable on MLFeatureTable {
@@ -715,6 +725,11 @@ fragment nonRecursiveMLFeatureTable on MLFeatureTable {
browsePathV2 {
...browsePathV2Fields
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
fragment schemaFieldFields on SchemaField {
@@ -1225,6 +1240,12 @@ fragment entityDisplayNameFields on Entity {
... on DataPlatformInstance {
instanceId
}
+ ... on StructuredPropertyEntity {
+ definition {
+ displayName
+ qualifiedName
+ }
+ }
}
fragment ermodelrelationPropertiesFields on ERModelRelationshipProperties {
@@ -1278,12 +1299,16 @@ fragment structuredPropertyFields on StructuredPropertyEntity {
cardinality
immutable
valueType {
+ urn
+ type
info {
type
displayName
}
}
entityTypes {
+ urn
+ type
info {
type
}
@@ -1310,6 +1335,35 @@ fragment structuredPropertyFields on StructuredPropertyEntity {
}
description
}
+ created {
+ time
+ actor {
+ urn
+ editableProperties {
+ displayName
+ pictureLink
+ }
+ ...entityDisplayNameFields
+ }
+ }
+ lastModified {
+ time
+ actor {
+ urn
+ editableProperties {
+ displayName
+ pictureLink
+ }
+ ...entityDisplayNameFields
+ }
+ }
+ }
+ settings {
+ isHidden
+ showInSearchFilters
+ showAsAssetBadge
+ showInAssetSummary
+ showInColumnsTable
}
}
@@ -1331,6 +1385,7 @@ fragment structuredPropertiesFields on StructuredPropertiesEntry {
type
...entityDisplayNameFields
}
+ associatedUrn
}
fragment autoRenderAspectFields on RawAspect {
diff --git a/datahub-web-react/src/graphql/lineage.graphql b/datahub-web-react/src/graphql/lineage.graphql
index c4936fc3c8abe..ee05811cbb72d 100644
--- a/datahub-web-react/src/graphql/lineage.graphql
+++ b/datahub-web-react/src/graphql/lineage.graphql
@@ -62,6 +62,11 @@ fragment lineageNodeProperties on EntityWithRelationships {
health {
...entityHealth
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on DataFlow {
orchestrator
@@ -97,6 +102,11 @@ fragment lineageNodeProperties on EntityWithRelationships {
health {
...entityHealth
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on Dashboard {
urn
@@ -155,6 +165,11 @@ fragment lineageNodeProperties on EntityWithRelationships {
health {
...entityHealth
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on Chart {
tool
@@ -188,6 +203,11 @@ fragment lineageNodeProperties on EntityWithRelationships {
health {
...entityHealth
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on Dataset {
name
@@ -227,6 +247,11 @@ fragment lineageNodeProperties on EntityWithRelationships {
health {
...entityHealth
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on MLModelGroup {
urn
@@ -243,6 +268,11 @@ fragment lineageNodeProperties on EntityWithRelationships {
status {
removed
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on MLModel {
urn
@@ -259,6 +289,11 @@ fragment lineageNodeProperties on EntityWithRelationships {
status {
removed
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on MLFeatureTable {
...nonRecursiveMLFeatureTable
diff --git a/datahub-web-react/src/graphql/me.graphql b/datahub-web-react/src/graphql/me.graphql
index 549aae1ad17de..e02a480fffe76 100644
--- a/datahub-web-react/src/graphql/me.graphql
+++ b/datahub-web-react/src/graphql/me.graphql
@@ -51,6 +51,8 @@ query getMe {
manageGlobalAnnouncements
createBusinessAttributes
manageBusinessAttributes
+ manageStructuredProperties
+ viewStructuredPropertiesPage
}
}
}
diff --git a/datahub-web-react/src/graphql/search.graphql b/datahub-web-react/src/graphql/search.graphql
index 3e26dd7121b72..ce0fde27f4c42 100644
--- a/datahub-web-react/src/graphql/search.graphql
+++ b/datahub-web-react/src/graphql/search.graphql
@@ -396,9 +396,19 @@ fragment searchResultsWithoutSchemaField on Entity {
type
... on Dataset {
...nonSiblingsDatasetSearchFields
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
}
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on Role {
id
@@ -509,6 +519,11 @@ fragment searchResultsWithoutSchemaField on Entity {
health {
...entityHealth
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on Chart {
chartId
@@ -578,6 +593,11 @@ fragment searchResultsWithoutSchemaField on Entity {
health {
...entityHealth
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on DataFlow {
flowId
@@ -619,6 +639,11 @@ fragment searchResultsWithoutSchemaField on Entity {
health {
...entityHealth
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on DataJob {
dataFlow {
@@ -671,6 +696,11 @@ fragment searchResultsWithoutSchemaField on Entity {
health {
...entityHealth
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on GlossaryTerm {
name
@@ -696,12 +726,22 @@ fragment searchResultsWithoutSchemaField on Entity {
domain {
...entityDomain
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on GlossaryNode {
...glossaryNode
parentNodes {
...parentNodesFields
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on Domain {
properties {
@@ -715,6 +755,11 @@ fragment searchResultsWithoutSchemaField on Entity {
...parentDomainsFields
}
...domainEntitiesFields
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on Container {
properties {
@@ -752,6 +797,11 @@ fragment searchResultsWithoutSchemaField on Entity {
parentContainers {
...parentContainersFields
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on MLFeatureTable {
name
@@ -777,12 +827,27 @@ fragment searchResultsWithoutSchemaField on Entity {
dataPlatformInstance {
...dataPlatformInstanceFields
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on MLFeature {
...nonRecursiveMLFeature
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on MLPrimaryKey {
...nonRecursiveMLPrimaryKey
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on MLModel {
name
@@ -800,6 +865,11 @@ fragment searchResultsWithoutSchemaField on Entity {
dataPlatformInstance {
...dataPlatformInstanceFields
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on MLModelGroup {
name
@@ -817,6 +887,11 @@ fragment searchResultsWithoutSchemaField on Entity {
dataPlatformInstance {
...dataPlatformInstanceFields
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on Tag {
name
@@ -855,6 +930,9 @@ fragment searchResultsWithoutSchemaField on Entity {
... on BusinessAttribute {
...businessAttributeFields
}
+ ... on StructuredPropertyEntity {
+ ...structuredPropertyFields
+ }
}
fragment searchResultFields on Entity {
@@ -867,6 +945,18 @@ fragment searchResultFields on Entity {
fragment facetFields on FacetMetadata {
field
displayName
+ entity {
+ urn
+ type
+ ...entityDisplayNameFields
+ ... on StructuredPropertyEntity {
+ definition {
+ valueType {
+ urn
+ }
+ }
+ }
+ }
aggregations {
value
count
diff --git a/datahub-web-react/src/graphql/structuredProperties.graphql b/datahub-web-react/src/graphql/structuredProperties.graphql
index 60079db3b1cd4..8a59b6e5fe0a0 100644
--- a/datahub-web-react/src/graphql/structuredProperties.graphql
+++ b/datahub-web-react/src/graphql/structuredProperties.graphql
@@ -1,3 +1,116 @@
+query getStructuredProperty($urn: String!) {
+ entity(urn: $urn) {
+ urn
+ type
+ ... on StructuredPropertyEntity {
+ urn
+ type
+ definition {
+ qualifiedName
+ displayName
+ description
+ cardinality
+ entityTypes {
+ urn
+ type
+ info {
+ type
+ }
+ }
+ valueType {
+ urn
+ type
+ info {
+ type
+ qualifiedName
+ displayName
+ description
+ }
+ }
+ allowedValues {
+ value {
+ ... on StringValue {
+ stringValue
+ }
+ ... on NumberValue {
+ numberValue
+ }
+ }
+ description
+ }
+ typeQualifier {
+ allowedTypes {
+ urn
+ type
+ info {
+ type
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+query searchStructuredProperties($query: String!, $start: Int!, $count: Int!) {
+ searchAcrossEntities(input: { query: $query, types: [STRUCTURED_PROPERTY], start: $start, count: $count }) {
+ start
+ count
+ total
+ searchResults {
+ entity {
+ ... on StructuredPropertyEntity {
+ urn
+ type
+ definition {
+ qualifiedName
+ displayName
+ description
+ cardinality
+ entityTypes {
+ urn
+ type
+ info {
+ type
+ }
+ }
+ valueType {
+ urn
+ type
+ info {
+ type
+ qualifiedName
+ displayName
+ description
+ }
+ }
+ allowedValues {
+ value {
+ ... on StringValue {
+ stringValue
+ }
+ ... on NumberValue {
+ numberValue
+ }
+ }
+ description
+ }
+ typeQualifier {
+ allowedTypes {
+ urn
+ type
+ info {
+ type
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
mutation upsertStructuredProperties($input: UpsertStructuredPropertiesInput!) {
upsertStructuredProperties(input: $input) {
properties {
@@ -5,3 +118,27 @@ mutation upsertStructuredProperties($input: UpsertStructuredPropertiesInput!) {
}
}
}
+
+mutation removeStructuredProperties($input: RemoveStructuredPropertiesInput!) {
+ removeStructuredProperties(input: $input) {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
+}
+
+mutation createStructuredProperty($input: CreateStructuredPropertyInput!) {
+ createStructuredProperty(input: $input) {
+ ...structuredPropertyFields
+ }
+}
+
+mutation updateStructuredProperty($input: UpdateStructuredPropertyInput!) {
+ updateStructuredProperty(input: $input) {
+ ...structuredPropertyFields
+ }
+}
+
+mutation deleteStructuredProperty($input: DeleteStructuredPropertyInput!) {
+ deleteStructuredProperty(input: $input)
+}
diff --git a/datahub-web-react/src/images/empty-forms.svg b/datahub-web-react/src/images/empty-forms.svg
new file mode 100644
index 0000000000000..e803bd4ccee2b
--- /dev/null
+++ b/datahub-web-react/src/images/empty-forms.svg
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/datahub-web-react/src/images/table-icon.svg b/datahub-web-react/src/images/table-icon.svg
new file mode 100644
index 0000000000000..e9535061ab87e
--- /dev/null
+++ b/datahub-web-react/src/images/table-icon.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/datahub-web-react/src/utils/test-utils/TestPageContainer.tsx b/datahub-web-react/src/utils/test-utils/TestPageContainer.tsx
index eaca969bb524e..f03fc3b43f320 100644
--- a/datahub-web-react/src/utils/test-utils/TestPageContainer.tsx
+++ b/datahub-web-react/src/utils/test-utils/TestPageContainer.tsx
@@ -26,6 +26,7 @@ import { DataPlatformEntity } from '../../app/entity/dataPlatform/DataPlatformEn
import { ContainerEntity } from '../../app/entity/container/ContainerEntity';
import AppConfigProvider from '../../AppConfigProvider';
import { BusinessAttributeEntity } from '../../app/entity/businessAttribute/BusinessAttributeEntity';
+import { SchemaFieldPropertiesEntity } from '../../app/entity/schemaField/SchemaFieldPropertiesEntity';
type Props = {
children: React.ReactNode;
@@ -49,6 +50,7 @@ export function getTestEntityRegistry() {
entityRegistry.register(new DataPlatformEntity());
entityRegistry.register(new ContainerEntity());
entityRegistry.register(new BusinessAttributeEntity());
+ entityRegistry.register(new SchemaFieldPropertiesEntity());
return entityRegistry;
}
diff --git a/docs-website/src/pages/champions/index.js b/docs-website/src/pages/champions/index.js
index 79ecf915e174d..6fd938344bbe1 100644
--- a/docs-website/src/pages/champions/index.js
+++ b/docs-website/src/pages/champions/index.js
@@ -10,240 +10,108 @@ const championSections = [
{
people: [
{
- name: "Siladitya Chakraborty",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/datahub-champions/siladitya_chakraborty.jpeg",
- position: "Data Engineer, Adevinta",
- bio: (
- <>
-
- Submiitted 6 pull requests including improvements on graphQL and search API.
-
- >
- ),
- social: {
- linkedin: "https://www.linkedin.com/in/aditya-0bab9a84/",
- github: "https://github.com/siladitya2",
- },
- location: "Barcelona, Spain"
- },
- {
- name: "Sergio GĆ³mez Villamor",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/champ-img/imgs/datahub-champions/sergio_gomez_villamor.jpg",
- position: "Tech Lead, Adevinta",
- bio: (
- <>
-
- Submitted 26 pull requests and raised 4 issues, also featured in "Humans of DataHub."
-
- >
- ),
- social: {
- linkedin: "https://www.linkedin.com/in/sgomezvillamor/",
- github: "https://github.com/sgomezvillamor/",
- },
- location: "Barcelona,Spain"
- },
- {
- name: "Amanda Ng",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/data-practitioners-guild/amanda-ng.png",
- position: "Lead Software Engineer, Grab",
+ name: "Patrick Braz",
+ image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/data-practitioners-guild/patrick-franco-braz.jpeg",
+ position: "Data Engineering Specialist, Grupo BoticƔrio",
bio: (
- <>
-
- Submitted 9 pull requests and shared Grab's experience adopting and implementing DataHub during the October 2022 Town Hall.
-
- >
- ),
- social: {
- linkedin: "https://sg.linkedin.com/in/amandang19",
- github: "https://github.com/ngamanda",
- web: "https://ngamanda.com/",
- },
- location: "Singapore"
- },
- {
- name: "Patrick Braz",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/data-practitioners-guild/patrick-franco-braz.jpeg",
- position: "Data Engineering Specialist, Grupo BoticƔrio",
- bio: (
<>
- Submitted 16 pull requests and 3 issues and regularly provided guidance to Community Members in Slack channels.
+ Submitted 16 pull requests and 3 issues and regularly provides guidance to Community Members in Slack channels.
>
- ),
- social: {
+ ),
+ social: {
linkedin: "https://www.linkedin.com/in/patrick-franco-braz/",
github: "https://github.com/PatrickfBraz",
- },
- location: "Rio de Janeiro, Brazil"
- },
- {
- name: "Steve Pham",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/data-practitioners-guild/cuong-pham.jpeg",
- bio: (
- <>
-
- Submitted 4 pull requests and reliably provided direction to Community Members across all support channels in Slack.
-
- >
- ),
- social: {
- },
- },
- {
- name: "Piotr Skrydalewicz",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/data-practitioners-guild/initials/ps_white.jpg",
- position: "Data Engineer",
- bio: (
- <>
-
- Contributed 5 commits in 2022 to the main DataHub Project and Helm repositories, including Stateful Ingestion support for Presto-on-Hive.
-
- >
- ),
- social: {
- linkedin: "https://www.linkedin.com/in/skrydal",
- },
- location: "Lodz, Poland"
- },
- {
- name: "Harvey Li",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/data-practitioners-guild/initials/hl_black.jpg",
- position: "Lead Data Engineer, Grab",
- bio: (
- <>
-
- Shared Grab's experience adopting and implementing DataHub during the October 2022 Town Hall and featured in Humans of DataHub.
-
- >
- ),
- social: {
- linkedin: "https://www.linkedin.com/in/li-haihui",
- github: "https://github.com/HarveyLeo",
- },
- location: "Singapore"
},
+ location: "Rio de Janeiro, Brazil"
+ },
{
- name: "Fredrik Sannholm",
- position: "Wolt",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/data-practitioners-guild/initials/fs_black.jpg",
+ name: "Mike Burke",
+ image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/datahub-champions/initials/mb.jpg",
position: "",
bio: (
<>
- Drove DataHub adoption at Wolt and featured in Humans of DataHub.
-
+ Regularly provides support to Community Members and amplifies DataHub across his network.
+
>
- ),
+ ),
social: {
},
- location: "Finland"
- },
- {
- name: "Scott Maciej",
- position: "Optum",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/champ-img/imgs/datahub-champions/initials/sm.jpg",
- bio: (
- <>
-
- Drove DataHub's adaptation and implementation at Optum.
-
- >
- ),
- social: {
- linkedin: "https://www.linkedin.com/in/scottmaciej/",
- github: "https://github.com/sgm44",
- web: "https://www.badhabitbeer.com/",
- },
- location: "USA"
},
{
- name: "Tim Bossenmaier",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/champ-img/imgs/datahub-champions/tim_bossenmaier.jpeg",
- position: "Data & Software Engineer, Cloudflight",
+ name: "Siladitya Chakraborty",
+ image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/datahub-champions/siladitya_chakraborty.jpeg",
+ position: "Data Engineer, Adevinta",
bio: (
<>
- Reliably provides direction to community members and submitted 5 pull request, including improvements to Athena ingestion (support for nested schemas) and the REST emitter.
+ Submitted 16 pull requests including improvements on graphQL and search API.
>
),
social: {
- linkedin: "https://www.linkedin.com/in/tim-bossenmaier/",
- github: "https://github.com/bossenti",
- },
- location: "Innsbruck, Austria"
+ linkedin: "https://www.linkedin.com/in/aditya-0bab9a84/",
+ github: "https://github.com/siladitya2",
},
+ location: "Barcelona, Spain"
+ },
{
- name: "Nikola Kasev",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/champ-img/imgs/datahub-champions/nikola_kasev.jpeg",
- position: "Data Engineer, KPN",
+ name: "Tim Drahn",
+ position: "Optum",
+ image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/datahub-champions/initials/td.jpg",
+ position: "Solution Architect, Optum Technologies",
bio: (
<>
- Reliably provided direction to Community Members across all support channels in Slack.
+ Submitted 2 pull requests and 1 issue while reliably providing direction to Community Members across all support channels in Slack.
>
),
social: {
- linkedin: "https://www.linkedin.com/in/nikolakasev",
- github: "https://github.com/nikolakasev",
+ linkedin: "https://www.linkedin.com/in/tim-drahn-a873532b/",
+ github: "https://github.com/tkdrahn",
},
- location: "Haarlem, Noord-holland"
- },
+ location: "MA, USA"
+ },
{
- name: "Nidhin Nandhakumar",
- position: "Coursera",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/datahub-champions/initials/nn.jpg",
+ name: "Steve Pham",
+ image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/data-practitioners-guild/cuong-pham.jpeg",
bio: (
<>
- Drove DataHub's adaptation and implementation on Coursera.
+ Submitted 4 pull requests and reliably provided direction to Community Members across all support channels in Slack.
>
),
social: {
},
- },
+ },
{
- name: "Wu Teng",
- position: "CashApp",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/datahub-champions/initials/wt.jpg",
+ name: "David Schmidt",
+ image: "https://raw.githubusercontent.com/datahub-project/static-assets/refs/heads/main/imgs/datahub-champions/david_schmidt.jpeg",
+ position: "Data Engineer, inovex GmbH",
bio: (
<>
- Reliably provided direction to Community Members across all support channels in Slack.
-
+ Regularly provides support to Community Members in Slack, submitted 4 pull requests and 6 issies.
+
>
),
social: {
+ linkedin: "https://www.linkedin.com/in/david-schmidt-de/",
+ github: "https://github.com/DSchmidtDev",
},
- location: "Australia"
},
{
- name: "Felipe Gusmao",
- position: "Zynga",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/datahub-champions/initials/fg.jpg",
- bio: (
- <>
-
- Shared Zynga's experience adopting and implementing DataHub during the September 2023 Town Hall.
-
- >
- ),
- social: {
- },
- location: "Toronto, Canada"
- },
- {
name: "Sudhakara ST",
image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/datahub-champions/initials/ss.jpg",
position: "Engineer, Zynga",
bio: (
<>
- Reliably provided direction to Community Members across all support channels in Slack and shared Zynga's experience adopting and implementing DataHub during the September 2023 Town Hall.
+ Reliably provides direction to Community Members across all support channels in Slack and shared Zynga's experience adopting and implementing DataHub during the September 2023 Town Hall.
>
),
@@ -253,83 +121,21 @@ const championSections = [
location: "Bengaluru, India"
},
{
- name: "Bobbie-Jean Nowak",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/datahub-champions/initials/bn.jpg",
- position: "Technical Product Manager, Optum ",
- bio: (
- <>
-
- Drove DataHub's adaptation and implementation at Optum.
-
- >
- ),
- social: {
- linkedin: "https://www.linkedin.com/in/bobbie-jean-nowak-a0076b77/",
- },
- location: "Minnesota, USA"
- },
- {
- name: "Dima Korendovych",
- position: "Optum",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/datahub-champions/initials/dk.jpg",
- bio: (
- <>
-
- Drove DataHub's adaptation and implementation at Optum.
-
- >
- ),
- social: {
- },
- location: "USA"
- },
- {
- name: "Tim Drahn",
- position: "Optum",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/datahub-champions/initials/td.jpg",
- position: "Solution Architect, Optum Technologies",
- bio: (
- <>
-
- Submitted 2 pull requests and 1 issue while reliably providing direction to Community Members across all support channels in Slack.
-
- >
- ),
- social: {
- linkedin: "https://www.linkedin.com/in/tim-drahn-a873532b/",
- github: "https://github.com/tkdrahn",
- },
- location: "MA, USA"
- },
- {
- name: "Kate Koy",
- position: "Optum",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/datahub-champions/initials/kk.jpg",
- bio: (
- <>
-
- Drove DataHub's adaptation and implementation at Optum.
-
- >
- ),
- social: {
- },
- location: "USA"
- },
- {
- name: "Anjali Arora",
- position: "Optum",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/datahub-champions/initials/aa.jpg",
+ name: "Tim Bossenmaier",
+ image: "https://raw.githubusercontent.com/datahub-project/static-assets/champ-img/imgs/datahub-champions/tim_bossenmaier.jpeg",
+ position: "Data & Software Engineer, Cloudflight",
bio: (
<>
- Drove DataHub's adaptation and implementation at Optum.
+ Reliably provides direction to community members and submitted 9 pull request, including improvements to Athena ingestion (support for nested schemas) and the REST emitter.
>
),
social: {
+ linkedin: "https://www.linkedin.com/in/tim-bossenmaier/",
+ github: "https://github.com/bossenti",
},
- location: "USA"
+ location: "Innsbruck, Austria"
},
{
name: "Raj Tekal",
diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/StructuredPropertyUtils.java b/entity-registry/src/main/java/com/linkedin/metadata/models/StructuredPropertyUtils.java
index e9ee7789550c6..612f923d5d68b 100644
--- a/entity-registry/src/main/java/com/linkedin/metadata/models/StructuredPropertyUtils.java
+++ b/entity-registry/src/main/java/com/linkedin/metadata/models/StructuredPropertyUtils.java
@@ -20,6 +20,7 @@
import com.linkedin.structured.PrimitivePropertyValue;
import com.linkedin.structured.StructuredProperties;
import com.linkedin.structured.StructuredPropertyDefinition;
+import com.linkedin.structured.StructuredPropertySettings;
import com.linkedin.structured.StructuredPropertyValueAssignment;
import com.linkedin.structured.StructuredPropertyValueAssignmentArray;
import com.linkedin.util.Pair;
@@ -32,6 +33,7 @@
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
+import java.util.UUID;
import java.util.stream.Collectors;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
@@ -45,6 +47,11 @@ private StructuredPropertyUtils() {}
static final Date MIN_DATE = Date.valueOf("1000-01-01");
static final Date MAX_DATE = Date.valueOf("9999-12-31");
+ public static final String INVALID_SETTINGS_MESSAGE =
+ "Cannot have property isHidden = true while other display location settings are also true.";
+ public static final String ONLY_ONE_BADGE =
+ "Cannot have more than one property set with show as badge. Property urns currently set: ";
+
public static LogicalValueType getLogicalValueType(
StructuredPropertyDefinition structuredPropertyDefinition) {
return getLogicalValueType(structuredPropertyDefinition.getValueType());
@@ -355,4 +362,48 @@ private static Pair filterValue
true);
}
}
+
+ /*
+ * We accept both ID and qualifiedName as inputs when creating a structured property. However,
+ * these two fields should ALWAYS be the same. If they don't provide either, use a UUID for both.
+ * If they provide both, ensure they are the same otherwise throw. Otherwise, use what is provided.
+ */
+ public static String getPropertyId(
+ @Nullable final String inputId, @Nullable final String inputQualifiedName) {
+ if (inputId != null && inputQualifiedName != null && !inputId.equals(inputQualifiedName)) {
+ throw new IllegalArgumentException(
+ "Qualified name and the ID of a structured property must match");
+ }
+
+ String id = UUID.randomUUID().toString();
+
+ if (inputQualifiedName != null) {
+ id = inputQualifiedName;
+ } else if (inputId != null) {
+ id = inputId;
+ }
+
+ return id;
+ }
+
+ /*
+ * Ensure that a structured property settings aspect is valid by ensuring that if isHidden is true,
+ * the other fields concerning display locations are false;
+ */
+ public static boolean validatePropertySettings(
+ StructuredPropertySettings settings, boolean shouldThrow) {
+ if (settings.isIsHidden()) {
+ if (settings.isShowInSearchFilters()
+ || settings.isShowInAssetSummary()
+ || settings.isShowAsAssetBadge()
+ || settings.isShowInColumnsTable()) {
+ if (shouldThrow) {
+ throw new IllegalArgumentException(INVALID_SETTINGS_MESSAGE);
+ } else {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
}
diff --git a/li-utils/src/main/java/com/linkedin/metadata/Constants.java b/li-utils/src/main/java/com/linkedin/metadata/Constants.java
index 9c608187342e8..797055d5fb6a9 100644
--- a/li-utils/src/main/java/com/linkedin/metadata/Constants.java
+++ b/li-utils/src/main/java/com/linkedin/metadata/Constants.java
@@ -363,6 +363,8 @@ public class Constants {
// Structured Property
public static final String STRUCTURED_PROPERTY_DEFINITION_ASPECT_NAME = "propertyDefinition";
public static final String STRUCTURED_PROPERTY_KEY_ASPECT_NAME = "structuredPropertyKey";
+ public static final String STRUCTURED_PROPERTY_SETTINGS_ASPECT_NAME =
+ "structuredPropertySettings";
// Form
public static final String FORM_INFO_ASPECT_NAME = "formInfo";
diff --git a/metadata-ingestion/src/datahub/api/entities/structuredproperties/structuredproperties.py b/metadata-ingestion/src/datahub/api/entities/structuredproperties/structuredproperties.py
index 181c70adc640a..013efbdf6a2f6 100644
--- a/metadata-ingestion/src/datahub/api/entities/structuredproperties/structuredproperties.py
+++ b/metadata-ingestion/src/datahub/api/entities/structuredproperties/structuredproperties.py
@@ -118,11 +118,13 @@ def validate_entity_types(cls, v):
@property
def fqn(self) -> str:
assert self.urn is not None
- return (
- self.qualified_name
- or self.id
- or Urn.from_string(self.urn).get_entity_id()[0]
- )
+ id = Urn.create_from_string(self.urn).get_entity_id()[0]
+ if self.qualified_name is not None:
+ # ensure that qualified name and ID match
+ assert (
+ self.qualified_name == id
+ ), "ID in the urn and the qualified_name must match"
+ return id
@validator("urn", pre=True, always=True)
def urn_must_be_present(cls, v, values):
diff --git a/metadata-ingestion/src/datahub/configuration/common.py b/metadata-ingestion/src/datahub/configuration/common.py
index 4fdf564162410..7df007e087979 100644
--- a/metadata-ingestion/src/datahub/configuration/common.py
+++ b/metadata-ingestion/src/datahub/configuration/common.py
@@ -258,7 +258,7 @@ def allow_all(cls) -> "AllowDenyPattern":
return AllowDenyPattern()
def allowed(self, string: str) -> bool:
- if self._denied(string):
+ if self.denied(string):
return False
return any(
@@ -266,7 +266,7 @@ def allowed(self, string: str) -> bool:
for allow_pattern in self.allow
)
- def _denied(self, string: str) -> bool:
+ def denied(self, string: str) -> bool:
for deny_pattern in self.deny:
if re.match(deny_pattern, string, self.regex_flags):
return True
@@ -290,7 +290,7 @@ def get_allowed_list(self) -> List[str]:
raise ValueError(
"allow list must be fully specified to get list of allowed strings"
)
- return [a for a in self.allow if not self._denied(a)]
+ return [a for a in self.allow if not self.denied(a)]
def __eq__(self, other): # type: ignore
return isinstance(other, self.__class__) and self.__dict__ == other.__dict__
diff --git a/metadata-ingestion/src/datahub/ingestion/source/tableau/tableau.py b/metadata-ingestion/src/datahub/ingestion/source/tableau/tableau.py
index 68c38d4d06461..6844b8a425a7b 100644
--- a/metadata-ingestion/src/datahub/ingestion/source/tableau/tableau.py
+++ b/metadata-ingestion/src/datahub/ingestion/source/tableau/tableau.py
@@ -353,7 +353,7 @@ class TableauConfig(
project_path_separator: str = Field(
default="/",
- description="The separator used for the project_pattern field between project names. By default, we use a slash. "
+ description="The separator used for the project_path_pattern field between project names. By default, we use a slash. "
"You can change this if your Tableau projects contain slashes in their names, and you'd like to filter by project.",
)
@@ -959,19 +959,36 @@ def _is_allowed_project(self, project: TableauProject) -> bool:
return is_allowed
def _is_denied_project(self, project: TableauProject) -> bool:
- # Either project name or project path should exist in deny
- for deny_pattern in self.config.project_pattern.deny:
- # Either name or project path is denied
- if re.match(
- deny_pattern, project.name, self.config.project_pattern.regex_flags
- ) or re.match(
- deny_pattern,
- self._get_project_path(project),
- self.config.project_pattern.regex_flags,
- ):
- return True
- logger.info(f"project({project.name}) is not denied as per project_pattern")
- return False
+ """
+ Why use an explicit denial check instead of the `AllowDenyPattern.allowed` method?
+
+ Consider a scenario where a Tableau site contains four projects: A, B, C, and D, with the following hierarchical relationship:
+
+ - **A**
+ - **B** (Child of A)
+ - **C** (Child of A)
+ - **D**
+
+ In this setup:
+
+ - `project_pattern` is configured with `allow: ["A"]` and `deny: ["B"]`.
+ - `extract_project_hierarchy` is set to `True`.
+
+ The goal is to extract assets from project A and its children while explicitly denying the child project B.
+
+ If we rely solely on the `project_pattern.allowed()` method, project C's assets will not be ingested.
+ This happens because project C is not explicitly included in the `allow` list, nor is it part of the `deny` list.
+ However, since `extract_project_hierarchy` is enabled, project C should ideally be included in the ingestion process unless explicitly denied.
+
+ To address this, the function explicitly checks the deny regex to ensure that project Cās assets are ingested if it is not specifically denied in the deny list. This approach ensures that the hierarchy is respected while adhering to the configured allow/deny rules.
+ """
+
+ # Either project_pattern or project_path_pattern is set in a recipe
+ # TableauConfig.projects_backward_compatibility ensures that at least one of these properties is configured.
+
+ return self.config.project_pattern.denied(
+ project.name
+ ) or self.config.project_path_pattern.denied(self._get_project_path(project))
def _init_tableau_project_registry(self, all_project_map: dict) -> None:
list_of_skip_projects: List[TableauProject] = []
@@ -999,9 +1016,11 @@ def _init_tableau_project_registry(self, all_project_map: dict) -> None:
for project in list_of_skip_projects:
if (
project.parent_id in projects_to_ingest
- and self._is_denied_project(project) is False
+ and not self._is_denied_project(project)
):
- logger.debug(f"Project {project.name} is added in project registry")
+ logger.debug(
+ f"Project {project.name} is added in project registry as it's a child project and not explicitly denied in `deny` list"
+ )
projects_to_ingest[project.id] = project
# We rely on automatic browse paths (v2) when creating containers. That's why we need to sort the projects here.
diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/AggregationQueryBuilder.java b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/AggregationQueryBuilder.java
index 60ca7649331a0..8b83439a3008c 100644
--- a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/AggregationQueryBuilder.java
+++ b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/AggregationQueryBuilder.java
@@ -5,6 +5,7 @@
import static com.linkedin.metadata.search.utils.ESUtils.toParentField;
import static com.linkedin.metadata.utils.SearchUtil.*;
+import com.linkedin.common.urn.UrnUtils;
import com.linkedin.data.template.LongMap;
import com.linkedin.metadata.aspect.AspectRetriever;
import com.linkedin.metadata.config.search.SearchConfiguration;
@@ -22,10 +23,13 @@
import com.linkedin.util.Pair;
import io.datahubproject.metadata.context.OperationContext;
import io.opentelemetry.extension.annotations.WithSpan;
+import java.time.OffsetDateTime;
+import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -48,6 +52,7 @@
@Slf4j
public class AggregationQueryBuilder {
private static final String URN_FILTER = "urn";
+ private static final String STRUCTURED_PROPERTIES_PREFIX = "structuredProperties.";
private final SearchConfiguration configs;
private final Set defaultFacetFields;
private final Set allFacetFields;
@@ -80,12 +85,13 @@ public List getAggregations(@Nonnull OperationContext opCont
*/
public List getAggregations(
@Nonnull OperationContext opContext, @Nullable List facets) {
- final Set facetsToAggregate;
+ final Set facetsToAggregate = new HashSet<>();
+ if (Boolean.TRUE.equals(
+ opContext.getSearchContext().getSearchFlags().isIncludeDefaultFacets())) {
+ facetsToAggregate.addAll(defaultFacetFields);
+ }
if (facets != null) {
- facetsToAggregate =
- facets.stream().filter(this::isValidAggregate).collect(Collectors.toSet());
- } else {
- facetsToAggregate = defaultFacetFields;
+ facets.stream().filter(this::isValidAggregate).forEach(facetsToAggregate::add);
}
return facetsToAggregate.stream()
.map(f -> facetToAggregationBuilder(opContext, f))
@@ -247,7 +253,7 @@ List extractAggregationMetadata(
return addFiltersToAggregationMetadata(aggregationMetadataList, filter, aspectRetriever);
}
- private void processTermAggregations(
+ public void processTermAggregations(
final Map.Entry entry,
final List aggregationMetadataList) {
final Map oneTermAggResult =
@@ -264,6 +270,7 @@ private void processTermAggregations(
.setFilterValues(
new FilterValueArray(
SearchUtil.convertToFilters(oneTermAggResult, Collections.emptySet())));
+ updateAggregationEntity(aggregationMetadata);
aggregationMetadataList.add(aggregationMetadata);
}
@@ -300,7 +307,15 @@ private static void recurseTermsAgg(
private static void processTermBucket(
Terms.Bucket bucket, Map aggResult, boolean includeZeroes) {
- String key = bucket.getKeyAsString();
+ final String key = bucket.getKeyAsString();
+ String finalKey = key;
+ try {
+ // if the value is a date string, convert to milliseconds since epoch
+ OffsetDateTime time = OffsetDateTime.parse(key);
+ finalKey = String.valueOf(time.toEpochSecond() * 1000);
+ } catch (DateTimeParseException e) {
+ // do nothing, this is expected if the value is not a date
+ }
// Gets filtered sub aggregation doc count if exist
Map subAggs = recursivelyAddNestedSubAggs(bucket.getAggregations());
subAggs.forEach(
@@ -309,7 +324,7 @@ private static void processTermBucket(
String.format("%s%s%s", key, AGGREGATION_SEPARATOR_CHAR, entryKey), entryValue));
long docCount = bucket.getDocCount();
if (includeZeroes || docCount > 0) {
- aggResult.put(key, docCount);
+ aggResult.put(finalKey, docCount);
}
}
@@ -474,11 +489,24 @@ private AggregationMetadata buildAggregationMetadata(
@Nonnull final String displayName,
@Nonnull final LongMap aggValues,
@Nonnull final FilterValueArray filterValues) {
- return new AggregationMetadata()
- .setName(facetField)
- .setDisplayName(displayName)
- .setAggregations(aggValues)
- .setFilterValues(filterValues);
+ AggregationMetadata aggregationMetadata =
+ new AggregationMetadata()
+ .setName(facetField)
+ .setDisplayName(displayName)
+ .setAggregations(aggValues)
+ .setFilterValues(filterValues);
+ updateAggregationEntity(aggregationMetadata);
+ return aggregationMetadata;
+ }
+
+ public void updateAggregationEntity(@Nonnull final AggregationMetadata aggregationMetadata) {
+ if (aggregationMetadata.getName().startsWith(STRUCTURED_PROPERTIES_PREFIX)) {
+ aggregationMetadata.setEntity(
+ UrnUtils.getUrn(
+ String.format(
+ "urn:li:structuredProperty:%s",
+ aggregationMetadata.getName().replaceFirst(STRUCTURED_PROPERTIES_PREFIX, ""))));
+ }
}
private List>> getFacetFieldDisplayNameFromAnnotation(
diff --git a/metadata-io/src/test/java/com/linkedin/metadata/search/fixtures/SampleDataFixtureTestBase.java b/metadata-io/src/test/java/com/linkedin/metadata/search/fixtures/SampleDataFixtureTestBase.java
index fd663de40e005..476f0114817be 100644
--- a/metadata-io/src/test/java/com/linkedin/metadata/search/fixtures/SampleDataFixtureTestBase.java
+++ b/metadata-io/src/test/java/com/linkedin/metadata/search/fixtures/SampleDataFixtureTestBase.java
@@ -1124,9 +1124,10 @@ public void testFacets() {
@Test
public void testNestedAggregation() {
Set expectedFacets = Set.of("platform");
+ OperationContext context =
+ getOperationContext().withSearchFlags(flags -> flags.setIncludeDefaultFacets(false));
SearchResult testResult =
- facetAcrossEntities(
- getOperationContext(), getSearchService(), "cypress", List.copyOf(expectedFacets));
+ facetAcrossEntities(context, getSearchService(), "cypress", List.copyOf(expectedFacets));
assertEquals(testResult.getMetadata().getAggregations().size(), 1);
expectedFacets.forEach(
facet -> {
@@ -1143,8 +1144,7 @@ public void testNestedAggregation() {
expectedFacets = Set.of("platform", "typeNames", "_entityType", "entity");
SearchResult testResult2 =
- facetAcrossEntities(
- getOperationContext(), getSearchService(), "cypress", List.copyOf(expectedFacets));
+ facetAcrossEntities(context, getSearchService(), "cypress", List.copyOf(expectedFacets));
assertEquals(testResult2.getMetadata().getAggregations().size(), 4);
expectedFacets.forEach(
facet -> {
@@ -1191,8 +1191,7 @@ public void testNestedAggregation() {
expectedFacets = Set.of("platform", "typeNames", "entity");
SearchResult testResult3 =
- facetAcrossEntities(
- getOperationContext(), getSearchService(), "cypress", List.copyOf(expectedFacets));
+ facetAcrossEntities(context, getSearchService(), "cypress", List.copyOf(expectedFacets));
assertEquals(testResult3.getMetadata().getAggregations().size(), 4);
expectedFacets.forEach(
facet -> {
@@ -1222,8 +1221,7 @@ public void testNestedAggregation() {
String singleNestedFacet = String.format("_entityType%sowners", AGGREGATION_SEPARATOR_CHAR);
expectedFacets = Set.of(singleNestedFacet);
SearchResult testResultSingleNested =
- facetAcrossEntities(
- getOperationContext(), getSearchService(), "cypress", List.copyOf(expectedFacets));
+ facetAcrossEntities(context, getSearchService(), "cypress", List.copyOf(expectedFacets));
assertEquals(testResultSingleNested.getMetadata().getAggregations().size(), 1);
Map expectedNestedFacetCounts = new HashMap<>();
expectedNestedFacetCounts.put("datajobāurn:li:corpuser:datahub", 2L);
@@ -1245,8 +1243,7 @@ public void testNestedAggregation() {
expectedFacets = Set.of("platform", singleNestedFacet, "typeNames", "origin");
SearchResult testResultNested =
- facetAcrossEntities(
- getOperationContext(), getSearchService(), "cypress", List.copyOf(expectedFacets));
+ facetAcrossEntities(context, getSearchService(), "cypress", List.copyOf(expectedFacets));
assertEquals(testResultNested.getMetadata().getAggregations().size(), 4);
expectedFacets.forEach(
facet -> {
diff --git a/metadata-io/src/test/java/com/linkedin/metadata/search/query/request/AggregationQueryBuilderTest.java b/metadata-io/src/test/java/com/linkedin/metadata/search/query/request/AggregationQueryBuilderTest.java
index 3969223981ec3..c68997e25bcff 100644
--- a/metadata-io/src/test/java/com/linkedin/metadata/search/query/request/AggregationQueryBuilderTest.java
+++ b/metadata-io/src/test/java/com/linkedin/metadata/search/query/request/AggregationQueryBuilderTest.java
@@ -3,9 +3,7 @@
import static com.linkedin.metadata.Constants.DATA_TYPE_URN_PREFIX;
import static com.linkedin.metadata.Constants.STRUCTURED_PROPERTY_DEFINITION_ASPECT_NAME;
import static com.linkedin.metadata.utils.SearchUtil.*;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anySet;
-import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -53,6 +51,7 @@ public class AggregationQueryBuilderTest {
private static AspectRetriever aspectRetriever;
private static AspectRetriever aspectRetrieverV1;
+ private static String DEFAULT_FILTER = "_index";
@BeforeClass
public void setup() throws RemoteInvocationException, URISyntaxException {
@@ -267,16 +266,17 @@ public void testGetSpecificAggregationsHasFields() {
builder.getAggregations(
TestOperationContexts.systemContextNoSearchAuthorization(),
ImmutableList.of("test1", "test2", "hasTest1"));
- Assert.assertEquals(aggs.size(), 3);
+ Assert.assertEquals(aggs.size(), 4);
Set facets = aggs.stream().map(AggregationBuilder::getName).collect(Collectors.toSet());
- Assert.assertEquals(ImmutableSet.of("test1", "test2", "hasTest1"), facets);
+ Assert.assertEquals(ImmutableSet.of("test1", "test2", "hasTest1", "_entityType"), facets);
// Case 2: Ask for fields that should NOT exist.
aggs =
builder.getAggregations(
TestOperationContexts.systemContextNoSearchAuthorization(),
ImmutableList.of("hasTest2"));
- Assert.assertEquals(aggs.size(), 0);
+ Assert.assertEquals(
+ aggs.size(), 1); // default has one field already, hasTest2 will not be in there
}
@Test
@@ -292,7 +292,7 @@ public void testAggregateOverStructuredProperty() {
builder.getAggregations(
TestOperationContexts.systemContextNoSearchAuthorization(aspectRetriever),
List.of("structuredProperties.ab.fgh.ten"));
- Assert.assertEquals(aggs.size(), 1);
+ Assert.assertEquals(aggs.size(), 2);
AggregationBuilder aggBuilder = aggs.get(0);
Assert.assertTrue(aggBuilder instanceof TermsAggregationBuilder);
TermsAggregationBuilder agg = (TermsAggregationBuilder) aggBuilder;
@@ -307,12 +307,16 @@ public void testAggregateOverStructuredProperty() {
builder.getAggregations(
TestOperationContexts.systemContextNoSearchAuthorization(aspectRetriever),
List.of("structuredProperties.ab.fgh.ten", "structuredProperties.hello"));
- Assert.assertEquals(aggs.size(), 2);
+ Assert.assertEquals(
+ aggs.size(), 3); // has one default filter (_entityType) both get mapped to _index
Assert.assertEquals(
aggs.stream()
.map(aggr -> ((TermsAggregationBuilder) aggr).field())
.collect(Collectors.toSet()),
- Set.of("structuredProperties.ab_fgh_ten.keyword", "structuredProperties.hello.keyword"));
+ Set.of(
+ "structuredProperties.ab_fgh_ten.keyword",
+ "structuredProperties.hello.keyword",
+ DEFAULT_FILTER));
}
@Test
@@ -328,16 +332,12 @@ public void testAggregateOverStructuredPropertyNamespaced() {
builder.getAggregations(
TestOperationContexts.systemContextNoSearchAuthorization(aspectRetriever),
List.of("structuredProperties.under.scores.and.dots_make_a_mess"));
- Assert.assertEquals(aggs.size(), 1);
- AggregationBuilder aggBuilder = aggs.get(0);
- Assert.assertTrue(aggBuilder instanceof TermsAggregationBuilder);
- TermsAggregationBuilder agg = (TermsAggregationBuilder) aggBuilder;
- // Check that field name is sanitized to correct field name
+ Assert.assertEquals(aggs.size(), 2);
Assert.assertEquals(
- agg.field(),
- "structuredProperties.under_scores_and_dots_make_a_mess.keyword",
- "Terms aggregate must be on a keyword or subfield keyword");
-
+ aggs.stream()
+ .map(aggr -> ((TermsAggregationBuilder) aggr).field())
+ .collect(Collectors.toSet()),
+ Set.of("structuredProperties.under_scores_and_dots_make_a_mess.keyword", DEFAULT_FILTER));
// Two structured properties
aggs =
builder.getAggregations(
@@ -345,14 +345,15 @@ public void testAggregateOverStructuredPropertyNamespaced() {
List.of(
"structuredProperties.under.scores.and.dots_make_a_mess",
"structuredProperties.hello"));
- Assert.assertEquals(aggs.size(), 2);
+ Assert.assertEquals(aggs.size(), 3);
Assert.assertEquals(
aggs.stream()
.map(aggr -> ((TermsAggregationBuilder) aggr).field())
.collect(Collectors.toSet()),
Set.of(
"structuredProperties.under_scores_and_dots_make_a_mess.keyword",
- "structuredProperties.hello.keyword"));
+ "structuredProperties.hello.keyword",
+ DEFAULT_FILTER));
}
@Test
@@ -368,7 +369,7 @@ public void testAggregateOverStructuredPropertyV1() {
builder.getAggregations(
TestOperationContexts.systemContextNoSearchAuthorization(aspectRetrieverV1),
List.of("structuredProperties.ab.fgh.ten"));
- Assert.assertEquals(aggs.size(), 1);
+ Assert.assertEquals(aggs.size(), 2);
AggregationBuilder aggBuilder = aggs.get(0);
Assert.assertTrue(aggBuilder instanceof TermsAggregationBuilder);
TermsAggregationBuilder agg = (TermsAggregationBuilder) aggBuilder;
@@ -385,14 +386,16 @@ public void testAggregateOverStructuredPropertyV1() {
List.of(
"structuredProperties.ab.fgh.ten",
"structuredProperties._versioned.hello.00000000000001.string"));
- Assert.assertEquals(aggs.size(), 2);
+ Assert.assertEquals(
+ aggs.size(), 3); // has two one filter (_entityType) both get mapped to _index
Assert.assertEquals(
aggs.stream()
.map(aggr -> ((TermsAggregationBuilder) aggr).field())
.collect(Collectors.toSet()),
Set.of(
"structuredProperties._versioned.ab_fgh_ten.00000000000001.string.keyword",
- "structuredProperties._versioned.hello.00000000000001.string.keyword"));
+ "structuredProperties._versioned.hello.00000000000001.string.keyword",
+ DEFAULT_FILTER));
}
@Test
@@ -408,15 +411,14 @@ public void testAggregateOverStructuredPropertyNamespacedV1() {
builder.getAggregations(
TestOperationContexts.systemContextNoSearchAuthorization(aspectRetrieverV1),
List.of("structuredProperties.under.scores.and.dots_make_a_mess"));
- Assert.assertEquals(aggs.size(), 1);
- AggregationBuilder aggBuilder = aggs.get(0);
- Assert.assertTrue(aggBuilder instanceof TermsAggregationBuilder);
- TermsAggregationBuilder agg = (TermsAggregationBuilder) aggBuilder;
- // Check that field name is sanitized to correct field name
+ Assert.assertEquals(aggs.size(), 2);
Assert.assertEquals(
- agg.field(),
- "structuredProperties._versioned.under_scores_and_dots_make_a_mess.00000000000001.string.keyword",
- "Terms aggregation must be on a keyword field or subfield.");
+ aggs.stream()
+ .map(aggr -> ((TermsAggregationBuilder) aggr).field())
+ .collect(Collectors.toSet()),
+ Set.of(
+ "structuredProperties._versioned.under_scores_and_dots_make_a_mess.00000000000001.string.keyword",
+ DEFAULT_FILTER));
// Two structured properties
aggs =
@@ -425,14 +427,15 @@ public void testAggregateOverStructuredPropertyNamespacedV1() {
List.of(
"structuredProperties.under.scores.and.dots_make_a_mess",
"structuredProperties._versioned.hello.00000000000001.string"));
- Assert.assertEquals(aggs.size(), 2);
+ Assert.assertEquals(aggs.size(), 3);
Assert.assertEquals(
aggs.stream()
.map(aggr -> ((TermsAggregationBuilder) aggr).field())
.collect(Collectors.toSet()),
Set.of(
"structuredProperties._versioned.under_scores_and_dots_make_a_mess.00000000000001.string.keyword",
- "structuredProperties._versioned.hello.00000000000001.string.keyword"));
+ "structuredProperties._versioned.hello.00000000000001.string.keyword",
+ DEFAULT_FILTER));
}
@Test
@@ -489,7 +492,7 @@ public void testAggregateOverFieldsAndStructProp() {
"hasTest1",
"structuredProperties.ab.fgh.ten",
"structuredProperties.hello"));
- Assert.assertEquals(aggs.size(), 5);
+ Assert.assertEquals(aggs.size(), 6);
Set facets =
aggs.stream()
.map(aggB -> ((TermsAggregationBuilder) aggB).field())
@@ -501,7 +504,8 @@ public void testAggregateOverFieldsAndStructProp() {
"test2.keyword",
"hasTest1",
"structuredProperties.ab_fgh_ten.keyword",
- "structuredProperties.hello.keyword"));
+ "structuredProperties.hello.keyword",
+ DEFAULT_FILTER));
}
@Test
@@ -558,7 +562,8 @@ public void testAggregateOverFieldsAndStructPropV1() {
"hasTest1",
"structuredProperties.ab.fgh.ten",
"structuredProperties.hello"));
- Assert.assertEquals(aggs.size(), 5);
+ Assert.assertEquals(
+ aggs.size(), 6); // has one default filter (_entityType) both get mapped to _index
Set facets =
aggs.stream()
.map(aggB -> ((TermsAggregationBuilder) aggB).field())
@@ -570,7 +575,8 @@ public void testAggregateOverFieldsAndStructPropV1() {
"test2.keyword",
"hasTest1",
"structuredProperties._versioned.ab_fgh_ten.00000000000001.string.keyword",
- "structuredProperties._versioned.hello.00000000000001.string.keyword"));
+ "structuredProperties._versioned.hello.00000000000001.string.keyword",
+ DEFAULT_FILTER));
}
@Test
@@ -613,6 +619,39 @@ public void testMissingAggregation() {
MISSING_SPECIAL_TYPE + AGGREGATION_SPECIAL_TYPE_DELIMITER + "test")));
}
+ @Test
+ public void testUpdateAggregationEntityWithStructuredProp() {
+ final AggregationMetadata aggregationMetadata = new AggregationMetadata();
+ aggregationMetadata.setName("structuredProperties.test_me.one");
+
+ SearchConfiguration config = new SearchConfiguration();
+ config.setMaxTermBucketSize(25);
+
+ AggregationQueryBuilder builder =
+ new AggregationQueryBuilder(
+ config, ImmutableMap.of(mock(EntitySpec.class), ImmutableList.of()));
+
+ builder.updateAggregationEntity(aggregationMetadata);
+ Assert.assertEquals(
+ aggregationMetadata.getEntity(), UrnUtils.getUrn("urn:li:structuredProperty:test_me.one"));
+ }
+
+ @Test
+ public void testUpdateAggregationEntityWithRegularFilter() {
+ final AggregationMetadata aggregationMetadata = new AggregationMetadata();
+ aggregationMetadata.setName("domains");
+
+ SearchConfiguration config = new SearchConfiguration();
+ config.setMaxTermBucketSize(25);
+
+ AggregationQueryBuilder builder =
+ new AggregationQueryBuilder(
+ config, ImmutableMap.of(mock(EntitySpec.class), ImmutableList.of()));
+
+ builder.updateAggregationEntity(aggregationMetadata);
+ Assert.assertNull(aggregationMetadata.getEntity());
+ }
+
@Test
public void testAddFiltersToMetadataWithStructuredPropsNoResults() {
final Urn propertyUrn = UrnUtils.getUrn("urn:li:structuredProperty:test_me.one");
@@ -638,7 +677,7 @@ public void testAddFiltersToMetadataWithStructuredPropsNoResults() {
// ensure we add the correct structured prop aggregation here
Assert.assertEquals(aggregationMetadataList.size(), 1);
- // Assert.assertEquals(aggregationMetadataList.get(0).getEntity(), propertyUrn);
+ Assert.assertEquals(aggregationMetadataList.get(0).getEntity(), propertyUrn);
Assert.assertEquals(
aggregationMetadataList.get(0).getName(), "structuredProperties.test_me.one");
Assert.assertEquals(aggregationMetadataList.get(0).getAggregations().size(), 1);
@@ -651,6 +690,7 @@ public void testAddFiltersToMetadataWithStructuredPropsWithAggregations() {
final AggregationMetadata aggregationMetadata = new AggregationMetadata();
aggregationMetadata.setName("structuredProperties.test_me.one");
+ aggregationMetadata.setEntity(propertyUrn);
FilterValue filterValue =
new FilterValue().setValue("test123").setFiltered(false).setFacetCount(1);
aggregationMetadata.setFilterValues(new FilterValueArray(filterValue));
@@ -679,6 +719,7 @@ public void testAddFiltersToMetadataWithStructuredPropsWithAggregations() {
criterion, aggregationMetadataList, mockAspectRetriever);
Assert.assertEquals(aggregationMetadataList.size(), 1);
+ Assert.assertEquals(aggregationMetadataList.get(0).getEntity(), propertyUrn);
Assert.assertEquals(
aggregationMetadataList.get(0).getName(), "structuredProperties.test_me.one");
Assert.assertEquals(aggregationMetadataList.get(0).getAggregations().size(), 1);
diff --git a/metadata-io/src/test/java/com/linkedin/metadata/search/query/request/SearchRequestHandlerTest.java b/metadata-io/src/test/java/com/linkedin/metadata/search/query/request/SearchRequestHandlerTest.java
index 718a00d067ce5..393ca3ca5d4a6 100644
--- a/metadata-io/src/test/java/com/linkedin/metadata/search/query/request/SearchRequestHandlerTest.java
+++ b/metadata-io/src/test/java/com/linkedin/metadata/search/query/request/SearchRequestHandlerTest.java
@@ -298,7 +298,8 @@ public void testAggregationsInSearch() {
String.format("_entityType%stextFieldOverride", AGGREGATION_SEPARATOR_CHAR);
SearchRequest searchRequest =
requestHandler.getSearchRequest(
- operationContext.withSearchFlags(flags -> flags.setFulltext(true)),
+ operationContext.withSearchFlags(
+ flags -> flags.setFulltext(true).setIncludeDefaultFacets(false)),
"*",
null,
null,
diff --git a/metadata-models/src/main/pegasus/com/linkedin/metadata/query/SearchFlags.pdl b/metadata-models/src/main/pegasus/com/linkedin/metadata/query/SearchFlags.pdl
index a3d2067ae5db2..a3a7a8cda58a8 100644
--- a/metadata-models/src/main/pegasus/com/linkedin/metadata/query/SearchFlags.pdl
+++ b/metadata-models/src/main/pegasus/com/linkedin/metadata/query/SearchFlags.pdl
@@ -58,4 +58,10 @@ record SearchFlags {
* invoke query rewrite chain for filters based on configured rewriters
*/
rewriteQuery: optional boolean = true
+
+ /**
+ * Include default facets when getting facets to aggregate on in search requests.
+ * By default we include these, but custom aggregation requests don't need them.
+ */
+ includeDefaultFacets: optional boolean = true
}
diff --git a/metadata-models/src/main/pegasus/com/linkedin/metadata/search/AggregationMetadata.pdl b/metadata-models/src/main/pegasus/com/linkedin/metadata/search/AggregationMetadata.pdl
index 0d3fa1e2a4ecf..d3d25083ff467 100644
--- a/metadata-models/src/main/pegasus/com/linkedin/metadata/search/AggregationMetadata.pdl
+++ b/metadata-models/src/main/pegasus/com/linkedin/metadata/search/AggregationMetadata.pdl
@@ -13,6 +13,11 @@ record AggregationMetadata {
*/
displayName: optional string
+ /**
+ * Entity associated with this facet
+ */
+ entity: optional Urn
+
/**
* List of aggregations showing the number of documents falling into each bucket. e.g, for platform aggregation, the bucket can be hive, kafka, etc
*/
diff --git a/metadata-models/src/main/pegasus/com/linkedin/structured/StructuredPropertyDefinition.pdl b/metadata-models/src/main/pegasus/com/linkedin/structured/StructuredPropertyDefinition.pdl
index 3ddb2d2e571da..416e2c5c11e22 100644
--- a/metadata-models/src/main/pegasus/com/linkedin/structured/StructuredPropertyDefinition.pdl
+++ b/metadata-models/src/main/pegasus/com/linkedin/structured/StructuredPropertyDefinition.pdl
@@ -89,25 +89,25 @@ record StructuredPropertyDefinition {
version: optional string
/**
- * Created Audit stamp
- */
- @Searchable = {
- "/time": {
- "fieldName": "createdTime",
- "fieldType": "DATETIME"
- }
- }
- created: optional AuditStamp
+ * Created Audit stamp
+ */
+ @Searchable = {
+ "/time": {
+ "fieldName": "createdTime",
+ "fieldType": "DATETIME"
+ }
+ }
+ created: optional AuditStamp
- /**
- * Created Audit stamp
- */
- @Searchable = {
- "/time": {
- "fieldName": "lastModified",
- "fieldType": "DATETIME"
- }
- }
- lastModified: optional AuditStamp
+ /**
+ * Last Modified Audit stamp
+ */
+ @Searchable = {
+ "/time": {
+ "fieldName": "lastModified",
+ "fieldType": "DATETIME"
+ }
+ }
+ lastModified: optional AuditStamp
}
diff --git a/metadata-models/src/main/pegasus/com/linkedin/structured/StructuredPropertySettings.pdl b/metadata-models/src/main/pegasus/com/linkedin/structured/StructuredPropertySettings.pdl
new file mode 100644
index 0000000000000..fadcdfa5204e1
--- /dev/null
+++ b/metadata-models/src/main/pegasus/com/linkedin/structured/StructuredPropertySettings.pdl
@@ -0,0 +1,64 @@
+namespace com.linkedin.structured
+
+import com.linkedin.common.AuditStamp
+
+/**
+ * Settings specific to a structured property entity
+ */
+@Aspect = {
+ "name": "structuredPropertySettings"
+}
+record StructuredPropertySettings {
+ /**
+ * Whether or not this asset should be hidden in the main application
+ */
+ @Searchable = {
+ "fieldType": "BOOLEAN"
+ }
+ isHidden: boolean = false
+
+ /**
+ * Whether or not this asset should be displayed as a search filter
+ */
+ @Searchable = {
+ "fieldType": "BOOLEAN"
+ }
+ showInSearchFilters: boolean = false
+
+ /**
+ * Whether or not this asset should be displayed in the asset sidebar
+ */
+ @Searchable = {
+ "fieldType": "BOOLEAN"
+ }
+ showInAssetSummary: boolean = false
+
+ /**
+ * Whether or not this asset should be displayed as an asset badge on other
+ * asset's headers
+ */
+ @Searchable = {
+ "fieldType": "BOOLEAN"
+ }
+ showAsAssetBadge: boolean = false
+
+ /**
+ * Whether or not this asset should be displayed as a column in the schema field table
+ * in a Dataset's "Columns" tab.
+ */
+ @Searchable = {
+ "fieldType": "BOOLEAN"
+ }
+ showInColumnsTable: boolean = false
+
+ /**
+ * Last Modified Audit stamp
+ */
+ @Searchable = {
+ "/time": {
+ "fieldName": "lastModifiedSettings",
+ "fieldType": "DATETIME"
+ }
+ }
+ lastModified: optional AuditStamp
+}
diff --git a/metadata-models/src/main/resources/entity-registry.yml b/metadata-models/src/main/resources/entity-registry.yml
index ee1481f29f7e9..1c3eb5b574e20 100644
--- a/metadata-models/src/main/resources/entity-registry.yml
+++ b/metadata-models/src/main/resources/entity-registry.yml
@@ -602,6 +602,7 @@ entities:
keyAspect: structuredPropertyKey
aspects:
- propertyDefinition
+ - structuredPropertySettings
- institutionalMemory
- status
- name: form
diff --git a/metadata-service/configuration/src/main/java/com/linkedin/datahub/graphql/featureflags/FeatureFlags.java b/metadata-service/configuration/src/main/java/com/linkedin/datahub/graphql/featureflags/FeatureFlags.java
index 0c62bdc196326..28abb26be1f52 100644
--- a/metadata-service/configuration/src/main/java/com/linkedin/datahub/graphql/featureflags/FeatureFlags.java
+++ b/metadata-service/configuration/src/main/java/com/linkedin/datahub/graphql/featureflags/FeatureFlags.java
@@ -24,4 +24,5 @@ public class FeatureFlags {
private boolean editableDatasetNameEnabled = false;
private boolean showSeparateSiblings = false;
private boolean alternateMCPValidation = false;
+ private boolean showManageStructuredProperties = false;
}
diff --git a/metadata-service/configuration/src/main/resources/application.yaml b/metadata-service/configuration/src/main/resources/application.yaml
index 15cd126408a7c..e97120ec75118 100644
--- a/metadata-service/configuration/src/main/resources/application.yaml
+++ b/metadata-service/configuration/src/main/resources/application.yaml
@@ -455,6 +455,7 @@ featureFlags:
alternateMCPValidation: ${ALTERNATE_MCP_VALIDATION:false} # Enables alternate MCP validation flow
showSeparateSiblings: ${SHOW_SEPARATE_SIBLINGS:false} # If turned on, all siblings will be separated with no way to get to a "combined" sibling view
editableDatasetNameEnabled: ${EDITABLE_DATASET_NAME_ENABLED:false} # Enables the ability to edit the dataset name in the UI
+ showManageStructuredProperties: ${SHOW_MANAGE_STRUCTURED_PROPERTIES:true} # If turned on, show the manage structured properties button on the govern dropdown
entityChangeEvents:
enabled: ${ENABLE_ENTITY_CHANGE_EVENTS_HOOK:true}
diff --git a/metadata-service/restli-api/src/main/snapshot/com.linkedin.analytics.analytics.snapshot.json b/metadata-service/restli-api/src/main/snapshot/com.linkedin.analytics.analytics.snapshot.json
index 061feafac1b9b..92abc50abbc4d 100644
--- a/metadata-service/restli-api/src/main/snapshot/com.linkedin.analytics.analytics.snapshot.json
+++ b/metadata-service/restli-api/src/main/snapshot/com.linkedin.analytics.analytics.snapshot.json
@@ -63,10 +63,10 @@
"DESCENDANTS_INCL" : "Represent the relation: URN field any nested children in addition to the given URN",
"END_WITH" : "Represent the relation: String field ends with value, e.g. name ends with Event",
"EQUAL" : "Represent the relation: field = value, e.g. platform = hdfs",
- "IEQUAL" : "Represent the relation: field = value and support case insensitive values, e.g. platform = hdfs",
"EXISTS" : "Represents the relation: field exists and is non-empty, e.g. owners is not null and != [] (empty)",
"GREATER_THAN" : "Represent the relation greater than, e.g. ownerCount > 5",
"GREATER_THAN_OR_EQUAL_TO" : "Represent the relation greater than or equal to, e.g. ownerCount >= 5",
+ "IEQUAL" : "Represent the relation: field = value and support case insensitive values, e.g. platform = hdfs",
"IN" : "Represent the relation: String field is one of the array values to, e.g. name in [\"Profile\", \"Event\"]",
"IS_NULL" : "Represent the relation: field is null, e.g. platform is null",
"LESS_THAN" : "Represent the relation less than, e.g. ownerCount < 3",
diff --git a/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.aspects.snapshot.json b/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.aspects.snapshot.json
index e9e2778a479d3..827789130d8bb 100644
--- a/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.aspects.snapshot.json
+++ b/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.aspects.snapshot.json
@@ -169,10 +169,10 @@
"DESCENDANTS_INCL" : "Represent the relation: URN field any nested children in addition to the given URN",
"END_WITH" : "Represent the relation: String field ends with value, e.g. name ends with Event",
"EQUAL" : "Represent the relation: field = value, e.g. platform = hdfs",
- "IEQUAL" : "Represent the relation: field = value and support case insensitive values, e.g. platform = hdfs",
"EXISTS" : "Represents the relation: field exists and is non-empty, e.g. owners is not null and != [] (empty)",
"GREATER_THAN" : "Represent the relation greater than, e.g. ownerCount > 5",
"GREATER_THAN_OR_EQUAL_TO" : "Represent the relation greater than or equal to, e.g. ownerCount >= 5",
+ "IEQUAL" : "Represent the relation: field = value and support case insensitive values, e.g. platform = hdfs",
"IN" : "Represent the relation: String field is one of the array values to, e.g. name in [\"Profile\", \"Event\"]",
"IS_NULL" : "Represent the relation: field is null, e.g. platform is null",
"LESS_THAN" : "Represent the relation less than, e.g. ownerCount < 3",
@@ -941,15 +941,18 @@
"Searchable" : {
"/actor" : {
"fieldName" : "tagAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/source" : {
"fieldName" : "tagAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/time" : {
"fieldName" : "tagAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
}
}
} ]
@@ -1068,15 +1071,18 @@
"Searchable" : {
"/actor" : {
"fieldName" : "termAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/source" : {
"fieldName" : "termAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/time" : {
"fieldName" : "termAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
}
}
} ]
@@ -2570,7 +2576,7 @@
"Searchable" : {
"fieldName" : "managerLdap",
"fieldType" : "URN",
- "queryByDefault" : true
+ "queryByDefault" : false
}
}, {
"name" : "departmentId",
@@ -2977,7 +2983,7 @@
"type" : "com.linkedin.dataset.SchemaFieldPath",
"doc" : "Flattened name of the field. Field is computed from jsonPath field.",
"Searchable" : {
- "boostScore" : 5.0,
+ "boostScore" : 1.0,
"fieldName" : "fieldPaths",
"fieldType" : "TEXT",
"queryByDefault" : "true"
@@ -3151,15 +3157,18 @@
"Searchable" : {
"/tags/*/attribution/actor" : {
"fieldName" : "fieldTagAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/source" : {
"fieldName" : "fieldTagAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/time" : {
"fieldName" : "fieldTagAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
},
"/tags/*/tag" : {
"boostScore" : 0.5,
@@ -3181,15 +3190,18 @@
"Searchable" : {
"/terms/*/attribution/actor" : {
"fieldName" : "fieldTermAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/source" : {
"fieldName" : "fieldTermAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/time" : {
"fieldName" : "fieldTermAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
},
"/terms/*/urn" : {
"boostScore" : 0.5,
@@ -3362,11 +3374,13 @@
"Searchable" : {
"/tags/*/attribution/actor" : {
"fieldName" : "editedFieldTagAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/source" : {
"fieldName" : "editedFieldTagAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/time" : {
"fieldName" : "editedFieldTagAttributionDates",
@@ -3392,11 +3406,13 @@
"Searchable" : {
"/terms/*/attribution/actor" : {
"fieldName" : "editedFieldTermAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/source" : {
"fieldName" : "editedFieldTermAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/time" : {
"fieldName" : "editedFieldTermAttributionDates",
diff --git a/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.entities.snapshot.json b/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.entities.snapshot.json
index 959cb5381fd9b..b549cef0af84b 100644
--- a/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.entities.snapshot.json
+++ b/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.entities.snapshot.json
@@ -932,15 +932,18 @@
"Searchable" : {
"/actor" : {
"fieldName" : "tagAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/source" : {
"fieldName" : "tagAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/time" : {
"fieldName" : "tagAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
}
}
} ]
@@ -1059,15 +1062,18 @@
"Searchable" : {
"/actor" : {
"fieldName" : "termAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/source" : {
"fieldName" : "termAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/time" : {
"fieldName" : "termAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
}
}
} ]
@@ -2783,7 +2789,7 @@
"Searchable" : {
"fieldName" : "managerLdap",
"fieldType" : "URN",
- "queryByDefault" : true
+ "queryByDefault" : false
}
}, {
"name" : "departmentId",
@@ -3365,7 +3371,7 @@
"type" : "com.linkedin.dataset.SchemaFieldPath",
"doc" : "Flattened name of the field. Field is computed from jsonPath field.",
"Searchable" : {
- "boostScore" : 5.0,
+ "boostScore" : 1.0,
"fieldName" : "fieldPaths",
"fieldType" : "TEXT",
"queryByDefault" : "true"
@@ -3539,15 +3545,18 @@
"Searchable" : {
"/tags/*/attribution/actor" : {
"fieldName" : "fieldTagAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/source" : {
"fieldName" : "fieldTagAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/time" : {
"fieldName" : "fieldTagAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
},
"/tags/*/tag" : {
"boostScore" : 0.5,
@@ -3569,15 +3578,18 @@
"Searchable" : {
"/terms/*/attribution/actor" : {
"fieldName" : "fieldTermAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/source" : {
"fieldName" : "fieldTermAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/time" : {
"fieldName" : "fieldTermAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
},
"/terms/*/urn" : {
"boostScore" : 0.5,
@@ -3750,11 +3762,13 @@
"Searchable" : {
"/tags/*/attribution/actor" : {
"fieldName" : "editedFieldTagAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/source" : {
"fieldName" : "editedFieldTagAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/time" : {
"fieldName" : "editedFieldTagAttributionDates",
@@ -3780,11 +3794,13 @@
"Searchable" : {
"/terms/*/attribution/actor" : {
"fieldName" : "editedFieldTermAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/source" : {
"fieldName" : "editedFieldTermAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/time" : {
"fieldName" : "editedFieldTermAttributionDates",
@@ -5204,9 +5220,9 @@
},
"Searchable" : {
"/*" : {
- "boostScore" : 2.0,
"fieldName" : "isRelatedTerms",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
}
}
}, {
@@ -5225,9 +5241,9 @@
},
"Searchable" : {
"/*" : {
- "boostScore" : 2.0,
"fieldName" : "hasRelatedTerms",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
}
}
}, {
@@ -6068,23 +6084,29 @@
"doc" : "invoke query rewrite chain for filters based on configured rewriters",
"default" : true,
"optional" : true
+ }, {
+ "name" : "includeDefaultFacets",
+ "type" : "boolean",
+ "doc" : "Include default facets when getting facets to aggregate on in search requests.\nBy default we include these, but custom aggregation requests don't need them.",
+ "default" : true,
+ "optional" : true
} ]
}, {
"type" : "enum",
"name" : "Condition",
"namespace" : "com.linkedin.metadata.query.filter",
"doc" : "The matching condition in a filter criterion",
- "symbols" : [ "CONTAIN", "END_WITH", "EQUAL","IEQUAL", "IS_NULL", "EXISTS", "GREATER_THAN", "GREATER_THAN_OR_EQUAL_TO", "IN", "LESS_THAN", "LESS_THAN_OR_EQUAL_TO", "START_WITH", "DESCENDANTS_INCL", "ANCESTORS_INCL", "RELATED_INCL" ],
+ "symbols" : [ "CONTAIN", "END_WITH", "EQUAL", "IEQUAL", "IS_NULL", "EXISTS", "GREATER_THAN", "GREATER_THAN_OR_EQUAL_TO", "IN", "LESS_THAN", "LESS_THAN_OR_EQUAL_TO", "START_WITH", "DESCENDANTS_INCL", "ANCESTORS_INCL", "RELATED_INCL" ],
"symbolDocs" : {
"ANCESTORS_INCL" : "Represent the relation: URN field matches any nested parent in addition to the given URN",
"CONTAIN" : "Represent the relation: String field contains value, e.g. name contains Profile",
"DESCENDANTS_INCL" : "Represent the relation: URN field any nested children in addition to the given URN",
"END_WITH" : "Represent the relation: String field ends with value, e.g. name ends with Event",
"EQUAL" : "Represent the relation: field = value, e.g. platform = hdfs",
- "IEQUAL" : "Represent the relation: field = value and support case insensitive values, e.g. platform = hdfs",
"EXISTS" : "Represents the relation: field exists and is non-empty, e.g. owners is not null and != [] (empty)",
"GREATER_THAN" : "Represent the relation greater than, e.g. ownerCount > 5",
"GREATER_THAN_OR_EQUAL_TO" : "Represent the relation greater than or equal to, e.g. ownerCount >= 5",
+ "IEQUAL" : "Represent the relation: field = value and support case insensitive values, e.g. platform = hdfs",
"IN" : "Represent the relation: String field is one of the array values to, e.g. name in [\"Profile\", \"Event\"]",
"IS_NULL" : "Represent the relation: field is null, e.g. platform is null",
"LESS_THAN" : "Represent the relation less than, e.g. ownerCount < 3",
@@ -6314,6 +6336,11 @@
"type" : "string",
"doc" : "Name of the filter to be displayed in the UI",
"optional" : true
+ }, {
+ "name" : "entity",
+ "type" : "com.linkedin.common.Urn",
+ "doc" : "Entity associated with this facet",
+ "optional" : true
}, {
"name" : "aggregations",
"type" : {
diff --git a/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.runs.snapshot.json b/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.runs.snapshot.json
index 3e0cd46aba0c0..c8be9d063eaea 100644
--- a/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.runs.snapshot.json
+++ b/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.runs.snapshot.json
@@ -674,15 +674,18 @@
"Searchable" : {
"/actor" : {
"fieldName" : "tagAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/source" : {
"fieldName" : "tagAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/time" : {
"fieldName" : "tagAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
}
}
} ]
@@ -801,15 +804,18 @@
"Searchable" : {
"/actor" : {
"fieldName" : "termAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/source" : {
"fieldName" : "termAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/time" : {
"fieldName" : "termAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
}
}
} ]
@@ -2294,7 +2300,7 @@
"Searchable" : {
"fieldName" : "managerLdap",
"fieldType" : "URN",
- "queryByDefault" : true
+ "queryByDefault" : false
}
}, {
"name" : "departmentId",
@@ -2701,7 +2707,7 @@
"type" : "com.linkedin.dataset.SchemaFieldPath",
"doc" : "Flattened name of the field. Field is computed from jsonPath field.",
"Searchable" : {
- "boostScore" : 5.0,
+ "boostScore" : 1.0,
"fieldName" : "fieldPaths",
"fieldType" : "TEXT",
"queryByDefault" : "true"
@@ -2875,15 +2881,18 @@
"Searchable" : {
"/tags/*/attribution/actor" : {
"fieldName" : "fieldTagAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/source" : {
"fieldName" : "fieldTagAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/time" : {
"fieldName" : "fieldTagAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
},
"/tags/*/tag" : {
"boostScore" : 0.5,
@@ -2905,15 +2914,18 @@
"Searchable" : {
"/terms/*/attribution/actor" : {
"fieldName" : "fieldTermAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/source" : {
"fieldName" : "fieldTermAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/time" : {
"fieldName" : "fieldTermAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
},
"/terms/*/urn" : {
"boostScore" : 0.5,
@@ -3086,11 +3098,13 @@
"Searchable" : {
"/tags/*/attribution/actor" : {
"fieldName" : "editedFieldTagAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/source" : {
"fieldName" : "editedFieldTagAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/time" : {
"fieldName" : "editedFieldTagAttributionDates",
@@ -3116,11 +3130,13 @@
"Searchable" : {
"/terms/*/attribution/actor" : {
"fieldName" : "editedFieldTermAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/source" : {
"fieldName" : "editedFieldTermAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/time" : {
"fieldName" : "editedFieldTermAttributionDates",
diff --git a/metadata-service/restli-api/src/main/snapshot/com.linkedin.operations.operations.snapshot.json b/metadata-service/restli-api/src/main/snapshot/com.linkedin.operations.operations.snapshot.json
index 7f651a10139e2..8c7595c5e505d 100644
--- a/metadata-service/restli-api/src/main/snapshot/com.linkedin.operations.operations.snapshot.json
+++ b/metadata-service/restli-api/src/main/snapshot/com.linkedin.operations.operations.snapshot.json
@@ -674,15 +674,18 @@
"Searchable" : {
"/actor" : {
"fieldName" : "tagAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/source" : {
"fieldName" : "tagAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/time" : {
"fieldName" : "tagAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
}
}
} ]
@@ -801,15 +804,18 @@
"Searchable" : {
"/actor" : {
"fieldName" : "termAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/source" : {
"fieldName" : "termAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/time" : {
"fieldName" : "termAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
}
}
} ]
@@ -2288,7 +2294,7 @@
"Searchable" : {
"fieldName" : "managerLdap",
"fieldType" : "URN",
- "queryByDefault" : true
+ "queryByDefault" : false
}
}, {
"name" : "departmentId",
@@ -2695,7 +2701,7 @@
"type" : "com.linkedin.dataset.SchemaFieldPath",
"doc" : "Flattened name of the field. Field is computed from jsonPath field.",
"Searchable" : {
- "boostScore" : 5.0,
+ "boostScore" : 1.0,
"fieldName" : "fieldPaths",
"fieldType" : "TEXT",
"queryByDefault" : "true"
@@ -2869,15 +2875,18 @@
"Searchable" : {
"/tags/*/attribution/actor" : {
"fieldName" : "fieldTagAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/source" : {
"fieldName" : "fieldTagAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/time" : {
"fieldName" : "fieldTagAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
},
"/tags/*/tag" : {
"boostScore" : 0.5,
@@ -2899,15 +2908,18 @@
"Searchable" : {
"/terms/*/attribution/actor" : {
"fieldName" : "fieldTermAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/source" : {
"fieldName" : "fieldTermAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/time" : {
"fieldName" : "fieldTermAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
},
"/terms/*/urn" : {
"boostScore" : 0.5,
@@ -3080,11 +3092,13 @@
"Searchable" : {
"/tags/*/attribution/actor" : {
"fieldName" : "editedFieldTagAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/source" : {
"fieldName" : "editedFieldTagAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/time" : {
"fieldName" : "editedFieldTagAttributionDates",
@@ -3110,11 +3124,13 @@
"Searchable" : {
"/terms/*/attribution/actor" : {
"fieldName" : "editedFieldTermAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/source" : {
"fieldName" : "editedFieldTermAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/time" : {
"fieldName" : "editedFieldTermAttributionDates",
diff --git a/metadata-service/restli-api/src/main/snapshot/com.linkedin.platform.platform.snapshot.json b/metadata-service/restli-api/src/main/snapshot/com.linkedin.platform.platform.snapshot.json
index c3e04add825c9..75e5c9a559076 100644
--- a/metadata-service/restli-api/src/main/snapshot/com.linkedin.platform.platform.snapshot.json
+++ b/metadata-service/restli-api/src/main/snapshot/com.linkedin.platform.platform.snapshot.json
@@ -932,15 +932,18 @@
"Searchable" : {
"/actor" : {
"fieldName" : "tagAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/source" : {
"fieldName" : "tagAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/time" : {
"fieldName" : "tagAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
}
}
} ]
@@ -1059,15 +1062,18 @@
"Searchable" : {
"/actor" : {
"fieldName" : "termAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/source" : {
"fieldName" : "termAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/time" : {
"fieldName" : "termAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
}
}
} ]
@@ -2777,7 +2783,7 @@
"Searchable" : {
"fieldName" : "managerLdap",
"fieldType" : "URN",
- "queryByDefault" : true
+ "queryByDefault" : false
}
}, {
"name" : "departmentId",
@@ -3359,7 +3365,7 @@
"type" : "com.linkedin.dataset.SchemaFieldPath",
"doc" : "Flattened name of the field. Field is computed from jsonPath field.",
"Searchable" : {
- "boostScore" : 5.0,
+ "boostScore" : 1.0,
"fieldName" : "fieldPaths",
"fieldType" : "TEXT",
"queryByDefault" : "true"
@@ -3533,15 +3539,18 @@
"Searchable" : {
"/tags/*/attribution/actor" : {
"fieldName" : "fieldTagAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/source" : {
"fieldName" : "fieldTagAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/time" : {
"fieldName" : "fieldTagAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
},
"/tags/*/tag" : {
"boostScore" : 0.5,
@@ -3563,15 +3572,18 @@
"Searchable" : {
"/terms/*/attribution/actor" : {
"fieldName" : "fieldTermAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/source" : {
"fieldName" : "fieldTermAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/time" : {
"fieldName" : "fieldTermAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
},
"/terms/*/urn" : {
"boostScore" : 0.5,
@@ -3744,11 +3756,13 @@
"Searchable" : {
"/tags/*/attribution/actor" : {
"fieldName" : "editedFieldTagAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/source" : {
"fieldName" : "editedFieldTagAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/time" : {
"fieldName" : "editedFieldTagAttributionDates",
@@ -3774,11 +3788,13 @@
"Searchable" : {
"/terms/*/attribution/actor" : {
"fieldName" : "editedFieldTermAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/source" : {
"fieldName" : "editedFieldTermAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/time" : {
"fieldName" : "editedFieldTermAttributionDates",
@@ -5198,9 +5214,9 @@
},
"Searchable" : {
"/*" : {
- "boostScore" : 2.0,
"fieldName" : "isRelatedTerms",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
}
}
}, {
@@ -5219,9 +5235,9 @@
},
"Searchable" : {
"/*" : {
- "boostScore" : 2.0,
"fieldName" : "hasRelatedTerms",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
}
}
}, {
diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/datahubusage/DataHubUsageEventType.java b/metadata-service/services/src/main/java/com/linkedin/metadata/datahubusage/DataHubUsageEventType.java
index dbe1f45fae29e..43d5e61c47bac 100644
--- a/metadata-service/services/src/main/java/com/linkedin/metadata/datahubusage/DataHubUsageEventType.java
+++ b/metadata-service/services/src/main/java/com/linkedin/metadata/datahubusage/DataHubUsageEventType.java
@@ -78,7 +78,15 @@ public enum DataHubUsageEventType {
EMBED_PROFILE_VIEW_EVENT("EmbedProfileViewEvent"),
EMBED_PROFILE_VIEW_IN_DATAHUB_EVENT("EmbedProfileViewInDataHubEvent"),
EMBED_LOOKUP_NOT_FOUND_EVENT("EmbedLookupNotFoundEvent"),
- CREATE_BUSINESS_ATTRIBUTE("CreateBusinessAttributeEvent");
+ CREATE_BUSINESS_ATTRIBUTE("CreateBusinessAttributeEvent"),
+ CREATE_STRUCTURED_PROPERTY_CLICK_EVENT("CreateStructuredPropertyClickEvent"),
+ CREATE_STRUCTURED_PROPERTY_EVENT("CreateStructuredPropertyEvent"),
+ EDIT_STRUCTURED_PROPERTY_EVENT("EditStructuredPropertyEvent"),
+ DELETE_STRUCTURED_PROPERTY_EVENT("DeleteStructuredPropertyEvent"),
+ VIEW_STRUCTURED_PROPERTY_EVENT("ViewStructuredPropertyEvent"),
+ APPLY_STRUCTURED_PROPERTY_EVENT("ApplyStructuredPropertyEvent"),
+ UPDATE_STRUCTURED_PROPERTY_ON_ASSET_EVENT("UpdateStructuredPropertyOnAssetEvent"),
+ REMOVE_STRUCTURED_PROPERTY_EVENT("RemoveStructuredPropertyEvent");
private final String type;
diff --git a/metadata-service/war/src/main/resources/boot/policies.json b/metadata-service/war/src/main/resources/boot/policies.json
index e0f26b908c499..e01b4f6f47eaa 100644
--- a/metadata-service/war/src/main/resources/boot/policies.json
+++ b/metadata-service/war/src/main/resources/boot/policies.json
@@ -36,6 +36,7 @@
"CREATE_BUSINESS_ATTRIBUTE",
"MANAGE_BUSINESS_ATTRIBUTE",
"MANAGE_STRUCTURED_PROPERTIES",
+ "VIEW_STRUCTURED_PROPERTIES_PAGE",
"MANAGE_DOCUMENTATION_FORMS",
"MANAGE_FEATURES",
"MANAGE_SYSTEM_OPERATIONS"
@@ -185,6 +186,7 @@
"CREATE_BUSINESS_ATTRIBUTE",
"MANAGE_BUSINESS_ATTRIBUTE",
"MANAGE_STRUCTURED_PROPERTIES",
+ "VIEW_STRUCTURED_PROPERTIES_PAGE",
"MANAGE_DOCUMENTATION_FORMS",
"MANAGE_FEATURES"
],
@@ -274,6 +276,7 @@
"MANAGE_TAGS",
"MANAGE_BUSINESS_ATTRIBUTE",
"MANAGE_STRUCTURED_PROPERTIES",
+ "VIEW_STRUCTURED_PROPERTIES_PAGE",
"MANAGE_DOCUMENTATION_FORMS",
"MANAGE_FEATURES"
],
@@ -427,7 +430,8 @@
"GET_TIMESERIES_ASPECT_PRIVILEGE",
"GET_ENTITY_PRIVILEGE",
"GET_TIMELINE_PRIVILEGE",
- "ES_EXPLAIN_QUERY_PRIVILEGE"
+ "ES_EXPLAIN_QUERY_PRIVILEGE",
+ "VIEW_STRUCTURED_PROPERTIES_PAGE"
],
"displayName": "Readers - Metadata Policy",
"description": "Readers can view all assets.",
diff --git a/metadata-utils/src/main/java/com/linkedin/metadata/authorization/PoliciesConfig.java b/metadata-utils/src/main/java/com/linkedin/metadata/authorization/PoliciesConfig.java
index bc676e94ecd4f..d701c8fc8be03 100644
--- a/metadata-utils/src/main/java/com/linkedin/metadata/authorization/PoliciesConfig.java
+++ b/metadata-utils/src/main/java/com/linkedin/metadata/authorization/PoliciesConfig.java
@@ -169,6 +169,12 @@ public class PoliciesConfig {
"Manage Structured Properties",
"Manage structured properties in your instance.");
+ public static final Privilege VIEW_STRUCTURED_PROPERTIES_PAGE_PRIVILEGE =
+ Privilege.of(
+ "VIEW_STRUCTURED_PROPERTIES_PAGE",
+ "View Structured Properties",
+ "View structured properties in your instance.");
+
public static final Privilege MANAGE_DOCUMENTATION_FORMS_PRIVILEGE =
Privilege.of(
"MANAGE_DOCUMENTATION_FORMS",