diff --git a/build.gradle b/build.gradle index c6e14081c6147..569987cf73c6c 100644 --- a/build.gradle +++ b/build.gradle @@ -32,11 +32,11 @@ buildscript { ext.junitJupiterVersion = '5.6.1' // Releases: https://github.com/linkedin/rest.li/blob/master/CHANGELOG.md - ext.pegasusVersion = '29.51.6' + ext.pegasusVersion = '29.57.0' ext.mavenVersion = '3.6.3' - ext.springVersion = '6.1.4' - ext.springBootVersion = '3.2.3' - ext.springKafkaVersion = '3.1.2' + ext.springVersion = '6.1.5' + ext.springBootVersion = '3.2.6' + ext.springKafkaVersion = '3.1.6' ext.openTelemetryVersion = '1.18.0' ext.neo4jVersion = '5.14.0' ext.neo4jTestVersion = '5.14.0' @@ -44,9 +44,9 @@ buildscript { ext.testContainersVersion = '1.17.4' ext.elasticsearchVersion = '2.11.1' // ES 7.10, Opensearch 1.x, 2.x ext.jacksonVersion = '2.15.3' - ext.jettyVersion = '11.0.19' - ext.playVersion = '2.8.21' - ext.log4jVersion = '2.19.0' + ext.jettyVersion = '11.0.21' + ext.playVersion = '2.8.22' + ext.log4jVersion = '2.23.1' ext.slf4jVersion = '1.7.36' ext.logbackClassic = '1.4.14' ext.hadoop3Version = '3.3.5' @@ -54,7 +54,7 @@ buildscript { ext.hazelcastVersion = '5.3.6' ext.ebeanVersion = '12.16.1' ext.googleJavaFormatVersion = '1.18.1' - ext.openLineageVersion = '1.14.0' + ext.openLineageVersion = '1.16.0' ext.logbackClassicJava8 = '1.2.12' ext.docker_registry = 'acryldata' @@ -149,7 +149,6 @@ project.ext.externalDependency = [ 'hazelcastTest':"com.hazelcast:hazelcast:$hazelcastVersion:tests", 'hibernateCore': 'org.hibernate:hibernate-core:5.2.16.Final', 'httpClient': 'org.apache.httpcomponents.client5:httpclient5:5.3', - 'httpAsyncClient': 'org.apache.httpcomponents:httpasyncclient:4.1.5', 'iStackCommons': 'com.sun.istack:istack-commons-runtime:4.0.1', 'jacksonJDK8': "com.fasterxml.jackson.datatype:jackson-datatype-jdk8:$jacksonVersion", 'jacksonDataPropertyFormat': "com.fasterxml.jackson.dataformat:jackson-dataformat-properties:$jacksonVersion", diff --git a/datahub-frontend/app/controllers/Application.java b/datahub-frontend/app/controllers/Application.java index df0cd4f4ff82f..d17e600aadc07 100644 --- a/datahub-frontend/app/controllers/Application.java +++ b/datahub-frontend/app/controllers/Application.java @@ -155,7 +155,7 @@ AuthenticationConstants.LEGACY_X_DATAHUB_ACTOR_HEADER, getDataHubActorHeader(req .setBody( new InMemoryBodyWritable( ByteString.fromByteBuffer(request.body().asBytes().asByteBuffer()), - "application/json")) + request.contentType().orElse("application/json"))) .setRequestTimeout(Duration.ofSeconds(120)) .execute() .thenApply( diff --git a/datahub-frontend/conf/application.conf b/datahub-frontend/conf/application.conf index 045175ba69f02..dc243ecadafd8 100644 --- a/datahub-frontend/conf/application.conf +++ b/datahub-frontend/conf/application.conf @@ -38,8 +38,12 @@ jwt { play.server.provider = server.CustomAkkaHttpServerProvider play.http.server.akka.max-header-count = 64 play.http.server.akka.max-header-count = ${?DATAHUB_AKKA_MAX_HEADER_COUNT} -play.server.akka.max-header-size = 8k +# max-header-size is reportedly no longer used +play.server.akka.max-header-size = 32k play.server.akka.max-header-size = ${?DATAHUB_AKKA_MAX_HEADER_VALUE_LENGTH} +# max header value length seems to impact the actual limit +play.server.akka.max-header-value-length = 32k +play.server.akka.max-header-value-length = ${?DATAHUB_AKKA_MAX_HEADER_VALUE_LENGTH} # Update AUTH_COOKIE_SAME_SITE and AUTH_COOKIE_SECURE in order to change how authentication cookies # are configured. If you wish cookies to be sent in first and third party contexts, set diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/Constants.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/Constants.java index 6fc6edc66f357..f70c46ba943a5 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/Constants.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/Constants.java @@ -1,5 +1,8 @@ package com.linkedin.datahub.graphql; +import com.google.common.collect.ImmutableSet; +import java.util.Set; + /** Constants relating to GraphQL type system & execution. */ public class Constants { @@ -28,4 +31,11 @@ private Constants() {} public static final String BROWSE_PATH_V2_DELIMITER = "␟"; public static final String VERSION_STAMP_FIELD_NAME = "versionStamp"; public static final String ENTITY_FILTER_NAME = "_entityType"; + + public static final Set DEFAULT_PERSONA_URNS = + ImmutableSet.of( + "urn:li:dataHubPersona:technicalUser", + "urn:li:dataHubPersona:businessUser", + "urn:li:dataHubPersona:dataLeader", + "urn:li:dataHubPersona:dataSteward"); } 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 98bf85ebd976a..6f2e250c17c34 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 @@ -42,6 +42,7 @@ import com.linkedin.datahub.graphql.generated.CorpGroup; import com.linkedin.datahub.graphql.generated.CorpGroupInfo; import com.linkedin.datahub.graphql.generated.CorpUser; +import com.linkedin.datahub.graphql.generated.CorpUserEditableProperties; import com.linkedin.datahub.graphql.generated.CorpUserInfo; import com.linkedin.datahub.graphql.generated.CorpUserViewsSettings; import com.linkedin.datahub.graphql.generated.Dashboard; @@ -53,6 +54,7 @@ import com.linkedin.datahub.graphql.generated.DataHubView; import com.linkedin.datahub.graphql.generated.DataJob; import com.linkedin.datahub.graphql.generated.DataJobInputOutput; +import com.linkedin.datahub.graphql.generated.DataPlatform; import com.linkedin.datahub.graphql.generated.DataPlatformInstance; import com.linkedin.datahub.graphql.generated.DataQualityContract; import com.linkedin.datahub.graphql.generated.Dataset; @@ -121,6 +123,8 @@ import com.linkedin.datahub.graphql.resolvers.assertion.AssertionRunEventResolver; import com.linkedin.datahub.graphql.resolvers.assertion.DeleteAssertionResolver; import com.linkedin.datahub.graphql.resolvers.assertion.EntityAssertionsResolver; +import com.linkedin.datahub.graphql.resolvers.assertion.ReportAssertionResultResolver; +import com.linkedin.datahub.graphql.resolvers.assertion.UpsertCustomAssertionResolver; import com.linkedin.datahub.graphql.resolvers.auth.CreateAccessTokenResolver; import com.linkedin.datahub.graphql.resolvers.auth.DebugAccessResolver; import com.linkedin.datahub.graphql.resolvers.auth.GetAccessTokenMetadataResolver; @@ -377,6 +381,7 @@ import com.linkedin.metadata.query.filter.SortCriterion; import com.linkedin.metadata.query.filter.SortOrder; import com.linkedin.metadata.recommendation.RecommendationsService; +import com.linkedin.metadata.service.AssertionService; import com.linkedin.metadata.service.BusinessAttributeService; import com.linkedin.metadata.service.DataProductService; import com.linkedin.metadata.service.ERModelRelationshipService; @@ -454,6 +459,7 @@ public class GmsGraphQLEngine { private final FormService formService; private final RestrictedService restrictedService; private ConnectionService connectionService; + private AssertionService assertionService; private final BusinessAttributeService businessAttributeService; private final FeatureFlags featureFlags; @@ -575,6 +581,7 @@ public GmsGraphQLEngine(final GmsGraphQLEngineArgs args) { this.formService = args.formService; this.restrictedService = args.restrictedService; this.connectionService = args.connectionService; + this.assertionService = args.assertionService; this.businessAttributeService = args.businessAttributeService; this.ingestionConfiguration = Objects.requireNonNull(args.ingestionConfiguration); @@ -1220,6 +1227,10 @@ private void configureMutationResolvers(final RuntimeWiring.Builder builder) { "createTestConnectionRequest", new CreateTestConnectionRequestResolver( this.entityClient, this.ingestionConfiguration)) + .dataFetcher( + "upsertCustomAssertion", new UpsertCustomAssertionResolver(assertionService)) + .dataFetcher( + "reportAssertionResult", new ReportAssertionResultResolver(assertionService)) .dataFetcher( "deleteAssertion", new DeleteAssertionResolver(this.entityClient, this.entityService)) @@ -1814,6 +1825,18 @@ private void configureCorpUserResolvers(final RuntimeWiring.Builder builder) { new LoadableTypeResolver<>( corpUserType, (env) -> ((CorpUserInfo) env.getSource()).getManager().getUrn()))); + builder.type( + "CorpUserEditableProperties", + typeWiring -> + typeWiring.dataFetcher( + "platforms", + new LoadableTypeBatchResolver<>( + dataPlatformType, + (env) -> + ((CorpUserEditableProperties) env.getSource()) + .getPlatforms().stream() + .map(DataPlatform::getUrn) + .collect(Collectors.toList())))); } /** diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngineArgs.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngineArgs.java index d4d4d592d6bca..f6ab3a603dbb7 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngineArgs.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngineArgs.java @@ -25,6 +25,7 @@ import com.linkedin.metadata.graph.SiblingGraphService; import com.linkedin.metadata.models.registry.EntityRegistry; import com.linkedin.metadata.recommendation.RecommendationsService; +import com.linkedin.metadata.service.AssertionService; import com.linkedin.metadata.service.BusinessAttributeService; import com.linkedin.metadata.service.DataProductService; import com.linkedin.metadata.service.ERModelRelationshipService; @@ -86,6 +87,7 @@ public class GmsGraphQLEngineArgs { boolean graphQLQueryIntrospectionEnabled; BusinessAttributeService businessAttributeService; ConnectionService connectionService; + AssertionService assertionService; // any fork specific args should go below this line } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/featureflags/FeatureFlags.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/featureflags/FeatureFlags.java index 2a4f75cf6055a..2a9af37a06ad9 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/featureflags/FeatureFlags.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/featureflags/FeatureFlags.java @@ -20,4 +20,5 @@ public class FeatureFlags { private boolean nestedDomainsEnabled = false; private boolean schemaFieldEntityFetchEnabled = false; private boolean businessAttributeEntityEnabled = false; + private boolean dataContractsEnabled = false; } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/assertion/AssertionUtils.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/assertion/AssertionUtils.java new file mode 100644 index 0000000000000..757ff38de6006 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/assertion/AssertionUtils.java @@ -0,0 +1,27 @@ +package com.linkedin.datahub.graphql.resolvers.assertion; + +import com.datahub.authorization.ConjunctivePrivilegeGroup; +import com.datahub.authorization.DisjunctivePrivilegeGroup; +import com.google.common.collect.ImmutableList; +import com.linkedin.common.urn.Urn; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.authorization.AuthorizationUtils; +import com.linkedin.metadata.authorization.PoliciesConfig; + +public class AssertionUtils { + public static boolean isAuthorizedToEditAssertionFromAssertee( + final QueryContext context, final Urn asserteeUrn) { + final DisjunctivePrivilegeGroup orPrivilegeGroups = + new DisjunctivePrivilegeGroup( + ImmutableList.of( + AuthorizationUtils.ALL_PRIVILEGES_GROUP, + new ConjunctivePrivilegeGroup( + ImmutableList.of(PoliciesConfig.EDIT_ENTITY_ASSERTIONS_PRIVILEGE.getType())))); + return AuthorizationUtils.isAuthorized( + context.getAuthorizer(), + context.getActorUrn(), + asserteeUrn.getEntityType(), + asserteeUrn.toString(), + orPrivilegeGroups); + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/assertion/ReportAssertionResultResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/assertion/ReportAssertionResultResolver.java new file mode 100644 index 0000000000000..b720aa11a8bdc --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/assertion/ReportAssertionResultResolver.java @@ -0,0 +1,117 @@ +package com.linkedin.datahub.graphql.resolvers.assertion; + +import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.*; + +import com.linkedin.assertion.AssertionResult; +import com.linkedin.assertion.AssertionResultError; +import com.linkedin.assertion.AssertionResultErrorType; +import com.linkedin.assertion.AssertionResultType; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.data.template.SetMode; +import com.linkedin.data.template.StringMap; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.exception.AuthorizationException; +import com.linkedin.datahub.graphql.generated.AssertionResultInput; +import com.linkedin.datahub.graphql.generated.StringMapEntryInput; +import com.linkedin.metadata.service.AssertionService; +import graphql.execution.DataFetcherExceptionHandler; +import graphql.execution.DataFetcherResult; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class ReportAssertionResultResolver implements DataFetcher> { + + public static final String ERROR_MESSAGE_KEY = "message"; + private final AssertionService _assertionService; + + public ReportAssertionResultResolver(AssertionService assertionService) { + _assertionService = assertionService; + } + + /** + * This is called by the graphql engine to fetch the value. The {@link DataFetchingEnvironment} is + * a composite context object that tells you all you need to know about how to fetch a data value + * in graphql type terms. + * + * @param environment this is the data fetching environment which contains all the context you + * need to fetch a value + * @return a value of type T. May be wrapped in a {@link DataFetcherResult} + * @throws Exception to relieve the implementations from having to wrap checked exceptions. Any + * exception thrown from a {@code DataFetcher} will eventually be handled by the registered + * {@link DataFetcherExceptionHandler} and the related field will have a value of {@code null} + * in the result. + */ + @Override + public CompletableFuture get(DataFetchingEnvironment environment) throws Exception { + final QueryContext context = environment.getContext(); + final Urn assertionUrn = UrnUtils.getUrn(environment.getArgument("urn")); + final AssertionResultInput input = + bindArgument(environment.getArgument("result"), AssertionResultInput.class); + + return CompletableFuture.supplyAsync( + () -> { + final Urn asserteeUrn = + _assertionService.getEntityUrnForAssertion( + context.getOperationContext(), assertionUrn); + if (asserteeUrn == null) { + throw new RuntimeException( + String.format( + "Failed to report Assertion Run Event. Assertion with urn %s does not exist or is not associated with any entity.", + assertionUrn)); + } + + // Check whether the current user is allowed to update the assertion. + if (AssertionUtils.isAuthorizedToEditAssertionFromAssertee(context, asserteeUrn)) { + AssertionResult assertionResult = mapAssertionResult(input); + _assertionService.addAssertionRunEvent( + context.getOperationContext(), + assertionUrn, + asserteeUrn, + input.getTimestampMillis() != null + ? input.getTimestampMillis() + : System.currentTimeMillis(), + assertionResult); + return true; + } + throw new AuthorizationException( + "Unauthorized to perform this action. Please contact your DataHub administrator."); + }); + } + + private static StringMap mapContextParameters(List input) { + + if (input == null || input.isEmpty()) { + return null; + } + StringMap entries = new StringMap(); + input.forEach(entry -> entries.put(entry.getKey(), entry.getValue())); + return entries; + } + + private AssertionResult mapAssertionResult(AssertionResultInput input) { + AssertionResult assertionResult = new AssertionResult(); + assertionResult.setType(AssertionResultType.valueOf(input.getType().toString())); + assertionResult.setExternalUrl(input.getExternalUrl(), SetMode.IGNORE_NULL); + if (assertionResult.getType() == AssertionResultType.ERROR && input.getError() != null) { + assertionResult.setError(mapAssertionResultError(input)); + } + if (input.getProperties() != null) { + assertionResult.setNativeResults(mapContextParameters(input.getProperties())); + } + return assertionResult; + } + + private static AssertionResultError mapAssertionResultError(AssertionResultInput input) { + AssertionResultError error = new AssertionResultError(); + error.setType(AssertionResultErrorType.valueOf(input.getError().getType().toString())); + StringMap errorProperties = new StringMap(); + errorProperties.put(ERROR_MESSAGE_KEY, input.getError().getMessage()); + error.setProperties(errorProperties); + return error; + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/assertion/UpsertCustomAssertionResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/assertion/UpsertCustomAssertionResolver.java new file mode 100644 index 0000000000000..026f486e32c11 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/assertion/UpsertCustomAssertionResolver.java @@ -0,0 +1,108 @@ +package com.linkedin.datahub.graphql.resolvers.assertion; + +import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.*; +import static com.linkedin.metadata.Constants.*; + +import com.linkedin.assertion.CustomAssertionInfo; +import com.linkedin.common.DataPlatformInstance; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.data.template.SetMode; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.exception.AuthorizationException; +import com.linkedin.datahub.graphql.generated.Assertion; +import com.linkedin.datahub.graphql.generated.PlatformInput; +import com.linkedin.datahub.graphql.generated.UpsertCustomAssertionInput; +import com.linkedin.datahub.graphql.types.assertion.AssertionMapper; +import com.linkedin.metadata.key.DataPlatformKey; +import com.linkedin.metadata.service.AssertionService; +import com.linkedin.metadata.utils.EntityKeyUtils; +import com.linkedin.metadata.utils.SchemaFieldUtils; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import javax.annotation.Nonnull; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class UpsertCustomAssertionResolver implements DataFetcher> { + + private final AssertionService _assertionService; + + public UpsertCustomAssertionResolver(@Nonnull final AssertionService assertionService) { + _assertionService = Objects.requireNonNull(assertionService, "assertionService is required"); + } + + @Override + public CompletableFuture get(DataFetchingEnvironment environment) throws Exception { + final QueryContext context = environment.getContext(); + final String maybeAssertionUrn = environment.getArgument("urn"); + final UpsertCustomAssertionInput input = + bindArgument(environment.getArgument("input"), UpsertCustomAssertionInput.class); + + final Urn entityUrn = UrnUtils.getUrn(input.getEntityUrn()); + final Urn assertionUrn; + + if (maybeAssertionUrn == null) { + assertionUrn = _assertionService.generateAssertionUrn(); + } else { + assertionUrn = UrnUtils.getUrn(maybeAssertionUrn); + } + + return CompletableFuture.supplyAsync( + () -> { + // Check whether the current user is allowed to update the assertion. + if (AssertionUtils.isAuthorizedToEditAssertionFromAssertee(context, entityUrn)) { + _assertionService.upsertCustomAssertion( + context.getOperationContext(), + assertionUrn, + entityUrn, + input.getDescription(), + input.getExternalUrl(), + mapAssertionPlatform(input.getPlatform()), + createCustomAssertionInfo(input, entityUrn)); + + return AssertionMapper.map( + context, + _assertionService.getAssertionEntityResponse( + context.getOperationContext(), assertionUrn)); + } + throw new AuthorizationException( + "Unauthorized to perform this action. Please contact your DataHub administrator."); + }); + } + + @SneakyThrows + private DataPlatformInstance mapAssertionPlatform(PlatformInput platformInput) { + DataPlatformInstance platform = new DataPlatformInstance(); + if (platformInput.getUrn() != null) { + platform.setPlatform(Urn.createFromString(platformInput.getUrn())); + } else if (platformInput.getName() != null) { + platform.setPlatform( + EntityKeyUtils.convertEntityKeyToUrn( + new DataPlatformKey().setPlatformName(platformInput.getName()), + DATA_PLATFORM_ENTITY_NAME)); + } else { + throw new IllegalArgumentException( + "Failed to upsert Custom Assertion. Platform Name or Platform Urn must be specified."); + } + + return platform; + } + + private CustomAssertionInfo createCustomAssertionInfo( + UpsertCustomAssertionInput input, Urn entityUrn) { + CustomAssertionInfo customAssertionInfo = new CustomAssertionInfo(); + customAssertionInfo.setType(input.getType()); + customAssertionInfo.setEntity(entityUrn); + customAssertionInfo.setLogic(input.getLogic(), SetMode.IGNORE_NULL); + + if (input.getFieldPath() != null) { + customAssertionInfo.setField( + SchemaFieldUtils.generateSchemaFieldUrn(entityUrn.toString(), input.getFieldPath())); + } + return customAssertionInfo; + } +} 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 c05009e146308..caa469003c22e 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 @@ -185,6 +185,7 @@ public CompletableFuture get(final DataFetchingEnvironment environmen .setShowAccessManagement(_featureFlags.isShowAccessManagement()) .setNestedDomainsEnabled(_featureFlags.isNestedDomainsEnabled()) .setPlatformBrowseV2(_featureFlags.isPlatformBrowseV2()) + .setDataContractsEnabled(_featureFlags.isDataContractsEnabled()) .build(); appConfig.setFeatureFlags(featureFlagsConfig); diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/assertion/AssertionMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/assertion/AssertionMapper.java index 1e7fac2edbc9a..a5f6cadb41566 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/assertion/AssertionMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/assertion/AssertionMapper.java @@ -22,6 +22,7 @@ import com.linkedin.datahub.graphql.generated.AssertionStdParameters; import com.linkedin.datahub.graphql.generated.AssertionType; import com.linkedin.datahub.graphql.generated.AuditStamp; +import com.linkedin.datahub.graphql.generated.CustomAssertionInfo; import com.linkedin.datahub.graphql.generated.DataPlatform; import com.linkedin.datahub.graphql.generated.DatasetAssertionInfo; import com.linkedin.datahub.graphql.generated.DatasetAssertionScope; @@ -162,10 +163,20 @@ public static com.linkedin.datahub.graphql.generated.AssertionInfo mapAssertionI mapSchemaAssertionInfo(context, gmsAssertionInfo.getSchemaAssertion()); assertionInfo.setSchemaAssertion(schemaAssertionInfo); } + if (gmsAssertionInfo.hasCustomAssertion()) { + CustomAssertionInfo customAssertionInfo = + mapCustomAssertionInfo(context, gmsAssertionInfo.getCustomAssertion()); + assertionInfo.setCustomAssertion(customAssertionInfo); + } + // Source Type if (gmsAssertionInfo.hasSource()) { assertionInfo.setSource(mapSource(gmsAssertionInfo.getSource())); } + + if (gmsAssertionInfo.hasExternalUrl()) { + assertionInfo.setExternalUrl(gmsAssertionInfo.getExternalUrl().toString()); + } return assertionInfo; } @@ -320,6 +331,22 @@ private static SchemaAssertionInfo mapSchemaAssertionInfo( return result; } + private static CustomAssertionInfo mapCustomAssertionInfo( + @Nullable final QueryContext context, + final com.linkedin.assertion.CustomAssertionInfo gmsCustomAssertionInfo) { + CustomAssertionInfo result = new CustomAssertionInfo(); + result.setType(gmsCustomAssertionInfo.getType()); + result.setEntityUrn(gmsCustomAssertionInfo.getEntity().toString()); + if (gmsCustomAssertionInfo.hasField()) { + result.setField(AssertionMapper.mapDatasetSchemaField(gmsCustomAssertionInfo.getField())); + } + if (gmsCustomAssertionInfo.hasLogic()) { + result.setLogic(gmsCustomAssertionInfo.getLogic()); + } + + return result; + } + private static SchemaAssertionField mapSchemaField(final SchemaField gmsField) { SchemaAssertionField result = new SchemaAssertionField(); result.setPath(gmsField.getFieldPath()); diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpuser/CorpUserType.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpuser/CorpUserType.java index b1ce42e72482a..3c2bfd7225edf 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpuser/CorpUserType.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpuser/CorpUserType.java @@ -1,11 +1,13 @@ package com.linkedin.datahub.graphql.types.corpuser; +import static com.linkedin.datahub.graphql.Constants.DEFAULT_PERSONA_URNS; import static com.linkedin.datahub.graphql.resolvers.mutate.MutationUtils.*; import static com.linkedin.metadata.Constants.*; import com.datahub.authorization.ConjunctivePrivilegeGroup; import com.datahub.authorization.DisjunctivePrivilegeGroup; import com.google.common.collect.ImmutableList; +import com.linkedin.common.UrnArray; import com.linkedin.common.url.Url; import com.linkedin.common.urn.Urn; import com.linkedin.common.urn.UrnUtils; @@ -14,6 +16,8 @@ import com.linkedin.datahub.graphql.QueryContext; import com.linkedin.datahub.graphql.authorization.AuthorizationUtils; import com.linkedin.datahub.graphql.exception.AuthorizationException; +import com.linkedin.datahub.graphql.exception.DataHubGraphQLErrorCode; +import com.linkedin.datahub.graphql.exception.DataHubGraphQLException; import com.linkedin.datahub.graphql.featureflags.FeatureFlags; import com.linkedin.datahub.graphql.generated.AutoCompleteResults; import com.linkedin.datahub.graphql.generated.CorpUser; @@ -246,7 +250,20 @@ private RecordTemplate mapCorpUserEditableInfo( if (input.getEmail() != null) { result.setEmail(input.getEmail()); } - + if (input.getPlatformUrns() != null) { + result.setPlatforms( + new UrnArray( + input.getPlatformUrns().stream().map(UrnUtils::getUrn).collect(Collectors.toList()))); + } + if (input.getPersonaUrn() != null) { + if (DEFAULT_PERSONA_URNS.contains(input.getPersonaUrn())) { + result.setPersona(UrnUtils.getUrn(input.getPersonaUrn())); + } else { + throw new DataHubGraphQLException( + String.format("Provided persona urn %s does not exist", input.getPersonaUrn()), + DataHubGraphQLErrorCode.NOT_FOUND); + } + } return result; } } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpuser/mappers/CorpUserEditableInfoMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpuser/mappers/CorpUserEditableInfoMapper.java index 1ff2f069b8112..38f3c75d7a9fa 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpuser/mappers/CorpUserEditableInfoMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpuser/mappers/CorpUserEditableInfoMapper.java @@ -2,7 +2,10 @@ import com.linkedin.datahub.graphql.QueryContext; import com.linkedin.datahub.graphql.generated.CorpUserEditableProperties; +import com.linkedin.datahub.graphql.generated.DataHubPersona; +import com.linkedin.datahub.graphql.generated.DataPlatform; import com.linkedin.datahub.graphql.types.mappers.ModelMapper; +import java.util.stream.Collectors; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -38,6 +41,22 @@ public CorpUserEditableProperties apply( if (info.hasPictureLink()) { result.setPictureLink(info.getPictureLink().toString()); } + if (info.hasPlatforms()) { + result.setPlatforms( + info.getPlatforms().stream() + .map( + urn -> { + DataPlatform platform = new DataPlatform(); + platform.setUrn(urn.toString()); + return platform; + }) + .collect(Collectors.toList())); + } + if (info.hasPersona()) { + DataHubPersona persona = new DataHubPersona(); + persona.setUrn(info.getPersona().toString()); + result.setPersona(persona); + } return result; } } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/ermodelrelationship/CreateERModelRelationshipResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/ermodelrelationship/CreateERModelRelationshipResolver.java index 61896ed1a0659..cafd0b5ab082b 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/ermodelrelationship/CreateERModelRelationshipResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/ermodelrelationship/CreateERModelRelationshipResolver.java @@ -54,14 +54,15 @@ public CompletableFuture get(DataFetchingEnvironment enviro highDataset = source; } // The following sequence mimics datahub.emitter.mce_builder.datahub_guid + // Keys have to be in alphabetical order - Destination, ERModelRelationName and Source String ermodelrelationKey = - "{\"Source\":\"" + "{\"Destination\":\"" + lowDataset - + "\",\"Destination\":\"" - + highDataset + "\",\"ERModelRelationName\":\"" + ermodelrelationName + + "\",\"Source\":\"" + + highDataset + "\"}"; byte[] mybytes = ermodelrelationKey.getBytes(StandardCharsets.UTF_8); diff --git a/datahub-graphql-core/src/main/resources/app.graphql b/datahub-graphql-core/src/main/resources/app.graphql index d84a86a3bedd3..b3a965981c366 100644 --- a/datahub-graphql-core/src/main/resources/app.graphql +++ b/datahub-graphql-core/src/main/resources/app.graphql @@ -492,6 +492,11 @@ type FeatureFlagsConfig { Whether business attribute entity should be shown """ businessAttributeEntityEnabled: Boolean! + + """ + Whether data contracts should be enabled + """ + dataContractsEnabled: Boolean! } """ diff --git a/datahub-graphql-core/src/main/resources/assertions.graphql b/datahub-graphql-core/src/main/resources/assertions.graphql index 3014289e51178..ff182089ad7ff 100644 --- a/datahub-graphql-core/src/main/resources/assertions.graphql +++ b/datahub-graphql-core/src/main/resources/assertions.graphql @@ -1,3 +1,138 @@ +extend type Mutation { + """ + Upsert a Custom Assertion + """ + upsertCustomAssertion( + """ + Urn of custom assertion. If not provided, one will be generated. + """ + urn: String + + """ + Input for upserting a custom assertion. + """ + input: UpsertCustomAssertionInput! + ): Assertion! + + """ + Report result for an assertion + """ + reportAssertionResult( + """ + Urn of custom assertion. + """ + urn: String! + + """ + Input for reporting result of the assertion + """ + result: AssertionResultInput! + ): Boolean! +} + +""" +Input for upserting a Custom Assertion. +""" +input UpsertCustomAssertionInput { + """ + The entity targeted by this assertion. + """ + entityUrn: String! + + """ + The type of the custom assertion. + """ + type: String! + + """ + The description of this assertion. + """ + description: String! + + """ + The dataset field targeted by this assertion, if any. + """ + fieldPath: String + + """ + The external Platform associated with the assertion + """ + platform: PlatformInput! + + """ + Native platform URL of the Assertion + """ + externalUrl: String + + """ + Logic comprising a raw, unstructured assertion. for example - custom SQL query for the assertion. + """ + logic: String + +} + +""" +Input for reporting result of the assertion +""" +input AssertionResultInput { + """ + Optional: Provide a timestamp associated with the run event. If not provided, one will be generated for you based + on the current time. + """ + timestampMillis: Long + + """ + The final result of assertion, e.g. either SUCCESS or FAILURE. + """ + type: AssertionResultType! + + """ + Additional metadata representing about the native results of the assertion. + These will be displayed alongside the result. + It should be used to capture additional context that is useful for the user. + """ + properties: [StringMapEntryInput!] + + """ + Native platform URL of the Assertion Run Event + """ + externalUrl: String + + """ + Error details, if type is ERROR + """ + error: AssertionResultErrorInput +} + +""" +Input for reporting an Error during Assertion Run +""" +input AssertionResultErrorInput { + """ + The type of error encountered + """ + type: AssertionResultErrorType! + + """ + The error message with details of error encountered + """ + message: String! +} +""" +Input representing A Data Platform +""" +input PlatformInput { + """ + Urn of platform + """ + urn: String + + """ + Name of platform + """ + name: String +} + """ Defines a schema field, each with a specified path and type. """ @@ -96,6 +231,11 @@ extend type AssertionInfo { """ schemaAssertion: SchemaAssertionInfo + """ + Information about Custom assertion + """ + customAssertion: CustomAssertionInfo + """ The source or origin of the Assertion definition. """ @@ -899,3 +1039,28 @@ type SchemaAssertionInfo { """ compatibility: SchemaAssertionCompatibility! } + +""" +Information about a custom assertion +""" +type CustomAssertionInfo { + """ + The type of custom assertion. + """ + type: String! + + """ + The entity targeted by this custom assertion. + """ + entityUrn: String! + + """ + The field serving as input to the assertion, if any. + """ + field: SchemaFieldRef + + """ + Logic comprising a raw, unstructured assertion. + """ + logic: String +} \ No newline at end of file diff --git a/datahub-graphql-core/src/main/resources/entity.graphql b/datahub-graphql-core/src/main/resources/entity.graphql index fa774d34ed7a4..89c7b4a4cd055 100644 --- a/datahub-graphql-core/src/main/resources/entity.graphql +++ b/datahub-graphql-core/src/main/resources/entity.graphql @@ -4139,6 +4139,16 @@ type CorpUserEditableProperties { Email address for the user """ email: String + + """ + User persona, if present + """ + persona: DataHubPersona + + """ + Platforms commonly used by the user, if present. + """ + platforms: [DataPlatform!] } """ @@ -4189,6 +4199,16 @@ input CorpUserUpdateInput { Email address for the user """ email: String + + """ + The platforms that the user frequently works with + """ + platformUrns: [String!] + + """ + The user's persona urn" + """ + personaUrn: String } """ @@ -7328,6 +7348,11 @@ type AssertionInfo { An optional human-readable description of the assertion """ description: String + + """ + URL where assertion details are available + """ + externalUrl: String } """ @@ -7490,6 +7515,75 @@ type AssertionResult { """ nativeResults: [StringMapEntry!] + """ + Error details, if type is ERROR + """ + error: AssertionResultError +} + +""" +An error encountered when evaluating an AssertionResult +""" +type AssertionResultError { + """ + The type of error encountered + """ + type: AssertionResultErrorType! + + """ + Additional metadata depending on the type of error + """ + properties: [StringMapEntry!] +} + +""" +The type of error encountered when evaluating an AssertionResult +""" +enum AssertionResultErrorType { + """ + Source is unreachable + """ + SOURCE_CONNECTION_ERROR + + """ + Source query failed to execute + """ + SOURCE_QUERY_FAILED + + """ + Invalid parameters were detected + """ + INVALID_PARAMETERS + + """ + Insufficient data to evaluate assertion + """ + INSUFFICIENT_DATA + + """ + Event type not supported by the specified source + """ + INVALID_SOURCE_TYPE + + """ + Platform not supported + """ + UNSUPPORTED_PLATFORM + + """ + Error while executing a custom SQL assertion + """ + CUSTOM_SQL_ERROR + + """ + Error while executing a field assertion + """ + FIELD_ASSERTION_ERROR + + """ + Unknown error + """ + UNKNOWN_ERROR } type BatchSpec { @@ -7843,6 +7937,11 @@ enum AssertionType { A schema or structural assertion. """ DATA_SCHEMA + + """ + A custom assertion. + """ + CUSTOM } """ @@ -12063,6 +12162,7 @@ input CreateDataProductPropertiesInput { description: String } + """ Input properties required for update a DataProduct """ @@ -12228,6 +12328,16 @@ input UpdateOwnershipTypeInput { description: String } +""" +A standardized type of a user +""" +type DataHubPersona { + """ + The urn of the persona type + """ + urn: String! +} + """ Describes a generic filter on a dataset """ @@ -12586,3 +12696,18 @@ type ListBusinessAttributesResult { """ businessAttributes: [BusinessAttribute!]! } + +""" +A cron schedule +""" +type CronSchedule { + """ + A cron-formatted execution interval, as a cron string, e.g. 1 * * * * + """ + cron: String! + + """ + Timezone in which the cron interval applies, e.g. America/Los_Angeles + """ + timezone: String! +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/assertion/ReportAssertionResultResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/assertion/ReportAssertionResultResolverTest.java new file mode 100644 index 0000000000000..cf3c833cbba23 --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/assertion/ReportAssertionResultResolverTest.java @@ -0,0 +1,160 @@ +package com.linkedin.datahub.graphql.resolvers.assertion; + +import static com.linkedin.datahub.graphql.TestUtils.*; +import static org.mockito.ArgumentMatchers.*; +import static org.testng.Assert.*; + +import com.google.common.collect.ImmutableList; +import com.linkedin.assertion.AssertionResult; +import com.linkedin.assertion.AssertionResultError; +import com.linkedin.assertion.AssertionRunEvent; +import com.linkedin.assertion.AssertionRunStatus; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.data.template.StringMap; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.AssertionResultErrorInput; +import com.linkedin.datahub.graphql.generated.AssertionResultErrorType; +import com.linkedin.datahub.graphql.generated.AssertionResultInput; +import com.linkedin.datahub.graphql.generated.AssertionResultType; +import com.linkedin.datahub.graphql.generated.StringMapEntryInput; +import com.linkedin.metadata.service.AssertionService; +import graphql.schema.DataFetchingEnvironment; +import io.datahubproject.metadata.context.OperationContext; +import java.util.Map; +import java.util.concurrent.CompletionException; +import org.mockito.Mockito; +import org.testng.annotations.Test; + +public class ReportAssertionResultResolverTest { + + private static final Urn TEST_DATASET_URN = + UrnUtils.getUrn("urn:li:dataset:(urn:li:dataPlatform:hive,name,PROD)"); + + private static final Urn TEST_ASSERTION_URN = UrnUtils.getUrn("urn:li:assertion:test"); + + private static final String customAssertionUrl = "https://dq-platform-native-url"; + + private static final AssertionResultInput TEST_INPUT = + new AssertionResultInput( + 0L, + AssertionResultType.ERROR, + ImmutableList.of(new StringMapEntryInput("prop1", "value1")), + customAssertionUrl, + new AssertionResultErrorInput( + AssertionResultErrorType.UNKNOWN_ERROR, "an unknown error occurred")); + + ; + + private static final AssertionRunEvent TEST_ASSERTION_RUN_EVENT = + new AssertionRunEvent() + .setAssertionUrn(TEST_ASSERTION_URN) + .setAsserteeUrn(TEST_DATASET_URN) + .setTimestampMillis(0L) + .setRunId("0") + .setStatus(AssertionRunStatus.COMPLETE) + .setResult( + new AssertionResult() + .setType(com.linkedin.assertion.AssertionResultType.ERROR) + .setError( + new AssertionResultError() + .setType(com.linkedin.assertion.AssertionResultErrorType.UNKNOWN_ERROR) + .setProperties( + new StringMap(Map.of("message", "an unknown error occurred")))) + .setExternalUrl(customAssertionUrl) + .setNativeResults(new StringMap(Map.of("prop1", "value1")))); + + @Test + public void testGetSuccessReportAssertionRunEvent() throws Exception { + // Update resolver + AssertionService mockedService = Mockito.mock(AssertionService.class); + ReportAssertionResultResolver resolver = new ReportAssertionResultResolver(mockedService); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("urn"))).thenReturn(TEST_ASSERTION_URN.toString()); + Mockito.when(mockEnv.getArgument(Mockito.eq("result"))).thenReturn(TEST_INPUT); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + Mockito.when( + mockedService.getEntityUrnForAssertion( + any(OperationContext.class), Mockito.eq(TEST_ASSERTION_URN))) + .thenReturn(TEST_DATASET_URN); + + resolver.get(mockEnv).get(); + + // Validate that we created the assertion + Mockito.verify(mockedService, Mockito.times(1)) + .addAssertionRunEvent( + any(OperationContext.class), + Mockito.eq(TEST_ASSERTION_URN), + Mockito.eq(TEST_DATASET_URN), + Mockito.eq(TEST_ASSERTION_RUN_EVENT.getTimestampMillis()), + Mockito.eq(TEST_ASSERTION_RUN_EVENT.getResult())); + } + + @Test + public void testGetUpdateAssertionUnauthorized() throws Exception { + // Update resolver + AssertionService mockedService = Mockito.mock(AssertionService.class); + ReportAssertionResultResolver resolver = new ReportAssertionResultResolver(mockedService); + + // Execute resolver + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + QueryContext mockContext = getMockDenyContext(); + Mockito.when(mockEnv.getArgument(Mockito.eq("urn"))).thenReturn(TEST_ASSERTION_URN.toString()); + Mockito.when(mockEnv.getArgument(Mockito.eq("result"))).thenReturn(TEST_INPUT); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + Mockito.when( + mockedService.getEntityUrnForAssertion( + any(OperationContext.class), Mockito.eq(TEST_ASSERTION_URN))) + .thenReturn(TEST_DATASET_URN); + + CompletionException e = + expectThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + assert e.getMessage() + .contains( + "Unauthorized to perform this action. Please contact your DataHub administrator."); + + // Validate that we created the assertion + Mockito.verify(mockedService, Mockito.times(0)) + .addAssertionRunEvent( + any(OperationContext.class), + Mockito.any(), + Mockito.any(), + Mockito.any(), + Mockito.any()); + } + + @Test + public void testGetAssertionServiceException() { + // Update resolver + AssertionService mockService = Mockito.mock(AssertionService.class); + + Mockito.when( + mockService.getEntityUrnForAssertion( + any(OperationContext.class), Mockito.eq(TEST_ASSERTION_URN))) + .thenReturn(TEST_DATASET_URN); + Mockito.doThrow(RuntimeException.class) + .when(mockService) + .addAssertionRunEvent( + any(OperationContext.class), + Mockito.any(), + Mockito.any(), + Mockito.any(), + Mockito.any()); + + ReportAssertionResultResolver resolver = new ReportAssertionResultResolver(mockService); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("urn"))).thenReturn(TEST_ASSERTION_URN.toString()); + Mockito.when(mockEnv.getArgument(Mockito.eq("result"))).thenReturn(TEST_INPUT); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + } +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/assertion/UpsertCustomAssertionResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/assertion/UpsertCustomAssertionResolverTest.java new file mode 100644 index 0000000000000..2ac6335ba9fea --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/assertion/UpsertCustomAssertionResolverTest.java @@ -0,0 +1,345 @@ +package com.linkedin.datahub.graphql.resolvers.assertion; + +import static com.linkedin.datahub.graphql.TestUtils.*; +import static org.mockito.ArgumentMatchers.*; +import static org.testng.Assert.*; + +import com.google.common.collect.ImmutableMap; +import com.linkedin.assertion.AssertionInfo; +import com.linkedin.assertion.AssertionSource; +import com.linkedin.assertion.AssertionSourceType; +import com.linkedin.assertion.AssertionType; +import com.linkedin.assertion.CustomAssertionInfo; +import com.linkedin.common.AuditStamp; +import com.linkedin.common.DataPlatformInstance; +import com.linkedin.common.url.Url; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.Assertion; +import com.linkedin.datahub.graphql.generated.PlatformInput; +import com.linkedin.datahub.graphql.generated.UpsertCustomAssertionInput; +import com.linkedin.entity.Aspect; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.EnvelopedAspect; +import com.linkedin.entity.EnvelopedAspectMap; +import com.linkedin.metadata.Constants; +import com.linkedin.metadata.service.AssertionService; +import graphql.schema.DataFetchingEnvironment; +import io.datahubproject.metadata.context.OperationContext; +import java.util.concurrent.CompletionException; +import org.mockito.Mockito; +import org.testng.annotations.Test; + +public class UpsertCustomAssertionResolverTest { + + private static final Urn TEST_DATASET_URN = + UrnUtils.getUrn("urn:li:dataset:(urn:li:dataPlatform:hive,name,PROD)"); + + private static final String TEST_INVALID_DATASET_URN = "dataset.name"; + + private static final Urn TEST_FIELD_URN = + UrnUtils.getUrn( + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:hive,name,PROD),field1)"); + private static final Urn TEST_ASSERTION_URN = UrnUtils.getUrn("urn:li:assertion:test"); + + private static final String TEST_INVALID_ASSERTION_URN = "test"; + private static final Urn TEST_ACTOR_URN = UrnUtils.getUrn("urn:li:actor:test"); + + private static final Urn TEST_PLATFORM_URN = UrnUtils.getUrn("urn:li:dataPlatform:DQplatform"); + + private static final String customAssertionType = "My custom category"; + private static final String customAssertionDescription = "Description of custom assertion"; + private static final String customAssertionUrl = "https://dq-platform-native-url"; + + private static final String customAssertionLogic = "custom script of assertion"; + + private static final UpsertCustomAssertionInput TEST_INPUT = + new UpsertCustomAssertionInput( + TEST_DATASET_URN.toString(), + customAssertionType, + customAssertionDescription, + "field1", + new PlatformInput(null, "DQplatform"), + customAssertionUrl, + customAssertionLogic); + + private static final UpsertCustomAssertionInput TEST_INPUT_MISSING_PLATFORM = + new UpsertCustomAssertionInput( + TEST_DATASET_URN.toString(), + customAssertionType, + customAssertionDescription, + "field1", + new PlatformInput(null, null), + customAssertionUrl, + customAssertionLogic); + + private static final UpsertCustomAssertionInput TEST_INPUT_INVALID_ENTITY_URN = + new UpsertCustomAssertionInput( + TEST_INVALID_DATASET_URN, + customAssertionType, + customAssertionDescription, + "field1", + new PlatformInput(null, "DQplatform"), + customAssertionUrl, + customAssertionLogic); + + private static final AssertionInfo TEST_ASSERTION_INFO = + new AssertionInfo() + .setType(AssertionType.CUSTOM) + .setDescription(customAssertionDescription) + .setExternalUrl(new Url(customAssertionUrl)) + .setSource( + new AssertionSource() + .setType(AssertionSourceType.EXTERNAL) + .setCreated( + new AuditStamp() + .setTime(System.currentTimeMillis()) + .setActor(TEST_ACTOR_URN))) + .setCustomAssertion( + new CustomAssertionInfo() + .setEntity(TEST_DATASET_URN) + .setType(customAssertionType) + .setField(TEST_FIELD_URN) + .setLogic(customAssertionLogic)); + + private static final DataPlatformInstance TEST_DATA_PLATFORM_INSTANCE = + new DataPlatformInstance().setPlatform(TEST_PLATFORM_URN); + + @Test + public void testGetSuccessCreateAssertion() throws Exception { + // Update resolver + AssertionService mockedService = Mockito.mock(AssertionService.class); + UpsertCustomAssertionResolver resolver = new UpsertCustomAssertionResolver(mockedService); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("urn"))).thenReturn(null); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + Mockito.when(mockedService.generateAssertionUrn()).thenReturn(TEST_ASSERTION_URN); + Mockito.when( + mockedService.getAssertionEntityResponse( + any(OperationContext.class), Mockito.eq(TEST_ASSERTION_URN))) + .thenReturn( + new EntityResponse() + .setAspects( + new EnvelopedAspectMap( + ImmutableMap.of( + Constants.ASSERTION_INFO_ASPECT_NAME, + new EnvelopedAspect().setValue(new Aspect(TEST_ASSERTION_INFO.data())), + Constants.DATA_PLATFORM_INSTANCE_ASPECT_NAME, + new EnvelopedAspect() + .setValue(new Aspect(TEST_DATA_PLATFORM_INSTANCE.data()))))) + .setEntityName(Constants.ASSERTION_ENTITY_NAME) + .setUrn(TEST_ASSERTION_URN)); + + Assertion assertion = resolver.get(mockEnv).get(); + + // Don't validate each field since we have mapper tests already. + assertNotNull(assertion); + assertEquals(assertion.getUrn(), TEST_ASSERTION_URN.toString()); + + // Validate that we created the assertion + Mockito.verify(mockedService, Mockito.times(1)) + .upsertCustomAssertion( + any(OperationContext.class), + Mockito.eq(TEST_ASSERTION_URN), + Mockito.eq(TEST_ASSERTION_INFO.getCustomAssertion().getEntity()), + Mockito.eq(TEST_ASSERTION_INFO.getDescription()), + Mockito.eq(TEST_ASSERTION_INFO.getExternalUrl().toString()), + Mockito.eq(TEST_DATA_PLATFORM_INSTANCE), + Mockito.eq(TEST_ASSERTION_INFO.getCustomAssertion())); + } + + @Test + public void testGetSuccessUpdateAssertion() throws Exception { + // Update resolver + AssertionService mockedService = Mockito.mock(AssertionService.class); + UpsertCustomAssertionResolver resolver = new UpsertCustomAssertionResolver(mockedService); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("urn"))).thenReturn(TEST_ASSERTION_URN.toString()); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + Mockito.when( + mockedService.getAssertionEntityResponse( + any(OperationContext.class), Mockito.eq(TEST_ASSERTION_URN))) + .thenReturn( + new EntityResponse() + .setAspects( + new EnvelopedAspectMap( + ImmutableMap.of( + Constants.ASSERTION_INFO_ASPECT_NAME, + new EnvelopedAspect().setValue(new Aspect(TEST_ASSERTION_INFO.data())), + Constants.DATA_PLATFORM_INSTANCE_ASPECT_NAME, + new EnvelopedAspect() + .setValue(new Aspect(TEST_DATA_PLATFORM_INSTANCE.data()))))) + .setEntityName(Constants.ASSERTION_ENTITY_NAME) + .setUrn(TEST_ASSERTION_URN)); + + Assertion assertion = resolver.get(mockEnv).get(); + + // Don't validate each field since we have mapper tests already. + assertNotNull(assertion); + assertEquals(assertion.getUrn(), TEST_ASSERTION_URN.toString()); + + // Validate that we created the assertion + Mockito.verify(mockedService, Mockito.times(1)) + .upsertCustomAssertion( + any(OperationContext.class), + Mockito.eq(TEST_ASSERTION_URN), + Mockito.eq(TEST_ASSERTION_INFO.getCustomAssertion().getEntity()), + Mockito.eq(TEST_ASSERTION_INFO.getDescription()), + Mockito.eq(TEST_ASSERTION_INFO.getExternalUrl().toString()), + Mockito.eq(TEST_DATA_PLATFORM_INSTANCE), + Mockito.eq(TEST_ASSERTION_INFO.getCustomAssertion())); + } + + @Test + public void testGetUpdateAssertionUnauthorized() throws Exception { + // Update resolver + AssertionService mockedService = Mockito.mock(AssertionService.class); + UpsertCustomAssertionResolver resolver = new UpsertCustomAssertionResolver(mockedService); + + // Execute resolver + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + QueryContext mockContext = getMockDenyContext(); + Mockito.when(mockEnv.getArgument(Mockito.eq("urn"))).thenReturn(TEST_ASSERTION_URN.toString()); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + CompletionException e = + expectThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + assert e.getMessage() + .contains( + "Unauthorized to perform this action. Please contact your DataHub administrator."); + + Mockito.verify(mockedService, Mockito.times(0)) + .upsertCustomAssertion( + any(OperationContext.class), + Mockito.any(), + Mockito.any(), + Mockito.any(), + Mockito.any(), + Mockito.any(), + Mockito.any()); + } + + @Test + public void testGetUpsertAssertionMissingPlatformFailure() throws Exception { + // Update resolver + AssertionService mockedService = Mockito.mock(AssertionService.class); + UpsertCustomAssertionResolver resolver = new UpsertCustomAssertionResolver(mockedService); + + // Execute resolver + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + QueryContext mockContext = getMockAllowContext(); + Mockito.when(mockEnv.getArgument(Mockito.eq("urn"))).thenReturn(TEST_ASSERTION_URN.toString()); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT_MISSING_PLATFORM); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + CompletionException e = + expectThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + assert e.getMessage() + .contains( + "Failed to upsert Custom Assertion. Platform Name or Platform Urn must be specified."); + + Mockito.verify(mockedService, Mockito.times(0)) + .upsertCustomAssertion( + any(OperationContext.class), + Mockito.any(), + Mockito.any(), + Mockito.any(), + Mockito.any(), + Mockito.any(), + Mockito.any()); + } + + @Test + public void testGetUpsertAssertionInvalidAssertionUrn() throws Exception { + // Update resolver + AssertionService mockedService = Mockito.mock(AssertionService.class); + UpsertCustomAssertionResolver resolver = new UpsertCustomAssertionResolver(mockedService); + + // Execute resolver + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + QueryContext mockContext = getMockAllowContext(); + Mockito.when(mockEnv.getArgument(Mockito.eq("urn"))).thenReturn(TEST_INVALID_ASSERTION_URN); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + RuntimeException e = expectThrows(RuntimeException.class, () -> resolver.get(mockEnv).join()); + assert e.getMessage().contains("invalid urn"); + + Mockito.verify(mockedService, Mockito.times(0)) + .upsertCustomAssertion( + any(OperationContext.class), + Mockito.any(), + Mockito.any(), + Mockito.any(), + Mockito.any(), + Mockito.any(), + Mockito.any()); + } + + @Test + public void testGetUpsertAssertionInvalidEntityUrn() throws Exception { + // Update resolver + AssertionService mockedService = Mockito.mock(AssertionService.class); + UpsertCustomAssertionResolver resolver = new UpsertCustomAssertionResolver(mockedService); + + // Execute resolver + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + QueryContext mockContext = getMockAllowContext(); + Mockito.when(mockEnv.getArgument(Mockito.eq("urn"))).thenReturn(TEST_ASSERTION_URN.toString()); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))) + .thenReturn(TEST_INPUT_INVALID_ENTITY_URN); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + RuntimeException e = expectThrows(RuntimeException.class, () -> resolver.get(mockEnv).join()); + assert e.getMessage().contains("invalid urn"); + + Mockito.verify(mockedService, Mockito.times(0)) + .upsertCustomAssertion( + any(OperationContext.class), + Mockito.any(), + Mockito.any(), + Mockito.any(), + Mockito.any(), + Mockito.any(), + Mockito.any()); + } + + @Test + public void testGetAssertionServiceException() { + // Update resolver + AssertionService mockService = Mockito.mock(AssertionService.class); + Mockito.doThrow(RuntimeException.class) + .when(mockService) + .upsertCustomAssertion( + any(OperationContext.class), + Mockito.any(), + Mockito.any(), + Mockito.any(), + Mockito.any(), + Mockito.any(), + Mockito.any()); + + UpsertCustomAssertionResolver resolver = new UpsertCustomAssertionResolver(mockService); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("urn"))).thenReturn(TEST_ASSERTION_URN.toString()); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + } +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/types/assertion/AssertionMapperTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/types/assertion/AssertionMapperTest.java index 376af14af08f6..82f4fe687bf76 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/types/assertion/AssertionMapperTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/types/assertion/AssertionMapperTest.java @@ -5,12 +5,14 @@ import com.google.common.collect.ImmutableList; import com.linkedin.assertion.AssertionInfo; import com.linkedin.assertion.AssertionSource; +import com.linkedin.assertion.AssertionSourceType; import com.linkedin.assertion.AssertionStdAggregation; import com.linkedin.assertion.AssertionStdOperator; import com.linkedin.assertion.AssertionStdParameter; import com.linkedin.assertion.AssertionStdParameterType; import com.linkedin.assertion.AssertionStdParameters; import com.linkedin.assertion.AssertionType; +import com.linkedin.assertion.CustomAssertionInfo; import com.linkedin.assertion.DatasetAssertionInfo; import com.linkedin.assertion.DatasetAssertionScope; import com.linkedin.assertion.FreshnessAssertionInfo; @@ -23,6 +25,7 @@ import com.linkedin.common.GlobalTags; import com.linkedin.common.TagAssociationArray; import com.linkedin.common.UrnArray; +import com.linkedin.common.url.Url; import com.linkedin.common.urn.TagUrn; import com.linkedin.common.urn.UrnUtils; import com.linkedin.data.DataMap; @@ -115,6 +118,22 @@ public void testMapDataSchemaAssertion() { verifyAssertionInfo(input, output); } + @Test + public void testMapCustomAssertion() { + // Case 1: Without nullable fields + AssertionInfo input = createCustomAssertionInfoWithoutNullableFields(); + EntityResponse customAssertionEntityResponse = createAssertionInfoEntityResponse(input); + Assertion output = AssertionMapper.map(null, customAssertionEntityResponse); + verifyAssertionInfo(input, output); + + // Case 2: With nullable fields + input = createCustomAssertionInfoWithNullableFields(); + EntityResponse customAssertionEntityResponseWithNullables = + createAssertionInfoEntityResponse(input); + output = AssertionMapper.map(null, customAssertionEntityResponseWithNullables); + verifyAssertionInfo(input, output); + } + private void verifyAssertionInfo(AssertionInfo input, Assertion output) { Assert.assertNotNull(output); Assert.assertNotNull(output.getInfo()); @@ -125,6 +144,10 @@ private void verifyAssertionInfo(AssertionInfo input, Assertion output) { verifyDatasetAssertion(input.getDatasetAssertion(), output.getInfo().getDatasetAssertion()); } + if (input.hasExternalUrl()) { + Assert.assertEquals(input.getExternalUrl().toString(), output.getInfo().getExternalUrl()); + } + if (input.hasFreshnessAssertion()) { verifyFreshnessAssertion( input.getFreshnessAssertion(), output.getInfo().getFreshnessAssertion()); @@ -137,6 +160,10 @@ private void verifyAssertionInfo(AssertionInfo input, Assertion output) { if (input.hasSource()) { verifySource(input.getSource(), output.getInfo().getSource()); } + + if (input.hasCustomAssertion()) { + verifyCustomAssertion(input.getCustomAssertion(), output.getInfo().getCustomAssertion()); + } } private void verifyDatasetAssertion( @@ -184,6 +211,19 @@ private void verifySchemaAssertion( output.getSchema().getFields().size(), input.getSchema().getFields().size()); } + private void verifyCustomAssertion( + CustomAssertionInfo input, + com.linkedin.datahub.graphql.generated.CustomAssertionInfo output) { + Assert.assertEquals(output.getEntityUrn(), input.getEntity().toString()); + Assert.assertEquals(output.getType(), input.getType()); + if (input.hasLogic()) { + Assert.assertEquals(output.getLogic(), input.getLogic()); + } + if (input.hasField()) { + Assert.assertEquals(output.getField().getPath(), input.getField().getEntityKey().get(1)); + } + } + private void verifyCronSchedule( FreshnessCronSchedule input, com.linkedin.datahub.graphql.generated.FreshnessCronSchedule output) { @@ -315,6 +355,35 @@ private AssertionInfo createSchemaAssertion() { return info; } + private AssertionInfo createCustomAssertionInfoWithoutNullableFields() { + AssertionInfo info = new AssertionInfo(); + info.setType(AssertionType.CUSTOM); + CustomAssertionInfo customAssertionInfo = new CustomAssertionInfo(); + customAssertionInfo.setType("Custom Type 1"); + customAssertionInfo.setEntity(UrnUtils.getUrn("urn:li:dataset:1")); + info.setCustomAssertion(customAssertionInfo); + return info; + } + + private AssertionInfo createCustomAssertionInfoWithNullableFields() { + AssertionInfo info = new AssertionInfo(); + info.setType(AssertionType.CUSTOM); + info.setExternalUrl(new Url("https://xyz.com")); + info.setDescription("Description of custom assertion"); + CustomAssertionInfo customAssertionInfo = new CustomAssertionInfo(); + customAssertionInfo.setType("Custom Type 1"); + customAssertionInfo.setEntity( + UrnUtils.getUrn("urn:li:dataset:(urn:li:dataPlatform:hive,name,PROD)")); + customAssertionInfo.setField( + UrnUtils.getUrn( + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:hive,name,PROD),field)")); + customAssertionInfo.setLogic("custom logic"); + info.setCustomAssertion(customAssertionInfo); + info.setSource(new AssertionSource().setType(AssertionSourceType.EXTERNAL)); + + return info; + } + private AssertionStdParameters createAssertionStdParameters() { AssertionStdParameters parameters = new AssertionStdParameters(); parameters.setValue(createAssertionStdParameter()); diff --git a/datahub-upgrade/build.gradle b/datahub-upgrade/build.gradle index 4b46996d30685..304bf3a67a5b2 100644 --- a/datahub-upgrade/build.gradle +++ b/datahub-upgrade/build.gradle @@ -46,6 +46,9 @@ dependencies { implementation(externalDependency.guava) { because("CVE-2023-2976") } + implementation('io.airlift:aircompressor:0.27') { + because("CVE-2024-36114") + } } diff --git a/datahub-web-react/package.json b/datahub-web-react/package.json index 50a74bb0f4259..ca53932eba518 100644 --- a/datahub-web-react/package.json +++ b/datahub-web-react/package.json @@ -4,7 +4,7 @@ "private": true, "dependencies": { "@analytics/amplitude": "0.0.3", - "@analytics/google-analytics": "^0.5.2", + "@analytics/google-analytics": "^1.0.7", "@analytics/mixpanel": "^0.3.1", "@ant-design/colors": "^5.0.0", "@ant-design/icons": "^4.3.0", @@ -71,7 +71,7 @@ "react-router-dom": "^5.3", "react-syntax-highlighter": "^15.4.4", "react-visibility-sensor": "^5.1.1", - "reactour": "1.18.7", + "reactour": "^1.19.3", "remirror": "^2.0.23", "styled-components": "^5.2.1", "turndown-plugin-gfm": "^1.0.2", @@ -88,8 +88,10 @@ "build": "yarn run generate && NODE_OPTIONS='--max-old-space-size=3072 --openssl-legacy-provider' CI=false vite build", "test": "vitest", "generate": "graphql-codegen --config codegen.yml", - "lint": "eslint . --ext .ts,.tsx --quiet && yarn type-check", + "lint": "eslint . --ext .ts,.tsx --quiet && yarn format-check && yarn type-check", "lint-fix": "eslint '*/**/*.{ts,tsx}' --quiet --fix", + "format-check": "prettier --check src", + "format": "prettier --write src", "type-check": "tsc --noEmit", "type-watch": "tsc -w --noEmit" }, @@ -130,7 +132,7 @@ "less": "^4.2.0", "prettier": "^2.8.8", "source-map-explorer": "^2.5.2", - "vite": "^4.5.2", + "vite": "^4.5.3", "vite-plugin-babel-macros": "^1.0.6", "vite-plugin-static-copy": "^0.17.0", "vite-plugin-svgr": "^4.1.0", diff --git a/datahub-web-react/src/App.less b/datahub-web-react/src/App.less index 62ccac85a8fe1..5837d77a5a4e5 100644 --- a/datahub-web-react/src/App.less +++ b/datahub-web-react/src/App.less @@ -5,7 +5,7 @@ // less preprocessor configuration. @font-face { - font-family: 'Manrope'; - font-style: normal; - src: local('Manrope'), url('./fonts/manrope.woff2') format('woff2'), + font-family: 'Manrope'; + font-style: normal; + src: local('Manrope'), url('./fonts/manrope.woff2') format('woff2'); } diff --git a/datahub-web-react/src/Mocks.tsx b/datahub-web-react/src/Mocks.tsx index 9f9107865aac4..de471b6b9f2fb 100644 --- a/datahub-web-react/src/Mocks.tsx +++ b/datahub-web-react/src/Mocks.tsx @@ -1453,10 +1453,10 @@ export const businessAttribute = { name: 'TestBusinessAtt-2', description: 'lorem upsum updated 12', created: { - time: 1705857132786 + time: 1705857132786, }, lastModified: { - time: 1705857132786 + time: 1705857132786, }, glossaryTerms: { terms: [ @@ -1465,10 +1465,10 @@ export const businessAttribute = { urn: 'urn:li:glossaryTerm:1', type: EntityType.GlossaryTerm, hierarchicalName: 'SampleHierarchicalName', - name: 'SampleName', + name: 'SampleName', }, - associatedUrn: 'urn:li:businessAttribute:ba1' - } + associatedUrn: 'urn:li:businessAttribute:ba1', + }, ], __typename: 'GlossaryTerms', }, @@ -1483,7 +1483,7 @@ export const businessAttribute = { name: 'abc-sample-tag', }, __typename: 'TagAssociation', - associatedUrn: 'urn:li:businessAttribute:ba1' + associatedUrn: 'urn:li:businessAttribute:ba1', }, { tag: { @@ -1493,30 +1493,30 @@ export const businessAttribute = { name: 'TestTag', }, __typename: 'TagAssociation', - associatedUrn: 'urn:li:businessAttribute:ba1' - } - ] + associatedUrn: 'urn:li:businessAttribute:ba1', + }, + ], }, customProperties: [ { key: 'prop2', value: 'val2', associatedUrn: 'urn:li:businessAttribute:ba1', - __typename: 'CustomPropertiesEntry' + __typename: 'CustomPropertiesEntry', }, { key: 'prop1', value: 'val1', associatedUrn: 'urn:li:businessAttribute:ba1', - __typename: 'CustomPropertiesEntry' + __typename: 'CustomPropertiesEntry', }, { key: 'prop3', value: 'val3', associatedUrn: 'urn:li:businessAttribute:ba1', - __typename: 'CustomPropertiesEntry' - } - ] + __typename: 'CustomPropertiesEntry', + }, + ], }, ownership: { owners: [ diff --git a/datahub-web-react/src/app/SearchRoutes.tsx b/datahub-web-react/src/app/SearchRoutes.tsx index 4ebcc6f090a4b..3343260c72bcf 100644 --- a/datahub-web-react/src/app/SearchRoutes.tsx +++ b/datahub-web-react/src/app/SearchRoutes.tsx @@ -53,15 +53,18 @@ export const SearchRoutes = (): JSX.Element => { } /> } /> } /> - { - if (!appConfigContextLoaded) { - return null; - } - if (businessAttributesFlag) { - return ; - } - return ; - }}/> + { + if (!appConfigContextLoaded) { + return null; + } + if (businessAttributesFlag) { + return ; + } + return ; + }} + /> diff --git a/datahub-web-react/src/app/analytics/README.md b/datahub-web-react/src/app/analytics/README.md index 79b82bcc2a756..881fffd59fb2c 100644 --- a/datahub-web-react/src/app/analytics/README.md +++ b/datahub-web-react/src/app/analytics/README.md @@ -48,20 +48,17 @@ const config: any = { ### Google Analytics -**Disclaimers** - -- This plugin requires use of Universal Analytics and does not yet support GA4. To create a Universal Analytics Property, follow [this guide](https://www.analyticsmania.com/other-posts/how-to-create-a-universal-analytics-property/). -- Google Analytics lacks robust support for custom event properties. For that reason many of the DataHub events discussed above will not be fully populated. Instead, we map certain fields of the DataHub event to the standard `category`, `action`, `label` fields required by GA. - 1. Open `datahub-web-react/src/conf/analytics.ts` -2. Uncomment the `googleAnalytics` field within the `config` object. -3. Replace the sample `trackingId` with the one provided by Google Analytics. +2. Uncomment the `googleAnalytics` field within the `config`. +3. Replace the sample `measurementIds` with the one provided by Google Analytics. 4. Rebuild & redeploy `datahub-frontend-react` to start tracking. +Example: + ```typescript const config: any = { googleAnalytics: { - trackingId: 'UA-24123123-01', + measurementIds: ['G-ATV123'], }, }; ``` diff --git a/datahub-web-react/src/app/analytics/plugin/googleAnalytics.ts b/datahub-web-react/src/app/analytics/plugin/googleAnalytics.ts index f60f46513272b..727258ee8d40f 100644 --- a/datahub-web-react/src/app/analytics/plugin/googleAnalytics.ts +++ b/datahub-web-react/src/app/analytics/plugin/googleAnalytics.ts @@ -2,9 +2,9 @@ import googleAnalytics from '@analytics/google-analytics'; import { Event, EventType } from '../event'; import analyticsConfig from '../../../conf/analytics'; -const gaConfigs = analyticsConfig.googleAnalytics; -const isEnabled: boolean = gaConfigs || false; -const trackingId = isEnabled ? gaConfigs.trackingId : undefined; +const ga4Configs = analyticsConfig.googleAnalytics; +const isEnabled: boolean = ga4Configs || false; +const measurementIds = isEnabled ? ga4Configs.measurementIds : undefined; const getLabelFromEvent = (event: Event) => { switch (event.type) { @@ -21,11 +21,7 @@ const getLabelFromEvent = (event: Event) => { let wrappedGoogleAnalyticsPlugin; if (isEnabled) { - /** - * Init default GA plugin - */ - const googleAnalyticsPlugin = googleAnalytics({ trackingId }); - + const googleAnalyticsPlugin = googleAnalytics({ measurementIds }); /** * Lightweight wrapper on top of the default google analytics plugin * to transform DataHub Analytics Events into the Google Analytics event diff --git a/datahub-web-react/src/app/analyticsDashboard/components/__tests__/timeSeriesChart.test.tsx b/datahub-web-react/src/app/analyticsDashboard/components/__tests__/timeSeriesChart.test.tsx index eadbb60959a0b..c528e4e627a1c 100644 --- a/datahub-web-react/src/app/analyticsDashboard/components/__tests__/timeSeriesChart.test.tsx +++ b/datahub-web-react/src/app/analyticsDashboard/components/__tests__/timeSeriesChart.test.tsx @@ -36,7 +36,7 @@ describe('timeSeriesChart', () => { }); it('compute lines works works correctly for monthly case', () => { - const chartData:TimeSeriesChart = { + const chartData: TimeSeriesChart = { title: 'Weekly Active Users', lines: [ { diff --git a/datahub-web-react/src/app/auth/ResetCredentials.tsx b/datahub-web-react/src/app/auth/ResetCredentials.tsx index 30d7f99d99d84..77f41489fcfc9 100644 --- a/datahub-web-react/src/app/auth/ResetCredentials.tsx +++ b/datahub-web-react/src/app/auth/ResetCredentials.tsx @@ -41,7 +41,9 @@ const FormInput = styled(Input)` `; const StyledFormItem = styled(Form.Item)` - .ant-input-affix-wrapper-status-error:not(.ant-input-affix-wrapper-disabled):not(.ant-input-affix-wrapper-borderless).ant-input-affix-wrapper { + .ant-input-affix-wrapper-status-error:not(.ant-input-affix-wrapper-disabled):not( + .ant-input-affix-wrapper-borderless + ).ant-input-affix-wrapper { background-color: transparent; } `; diff --git a/datahub-web-react/src/app/auth/SignUp.tsx b/datahub-web-react/src/app/auth/SignUp.tsx index e57a5930ce1ff..2eaa74946682f 100644 --- a/datahub-web-react/src/app/auth/SignUp.tsx +++ b/datahub-web-react/src/app/auth/SignUp.tsx @@ -55,7 +55,9 @@ const TitleSelector = styled(Select)` `; const StyledFormItem = styled(Form.Item)` - .ant-input-affix-wrapper-status-error:not(.ant-input-affix-wrapper-disabled):not(.ant-input-affix-wrapper-borderless).ant-input-affix-wrapper { + .ant-input-affix-wrapper-status-error:not(.ant-input-affix-wrapper-disabled):not( + .ant-input-affix-wrapper-borderless + ).ant-input-affix-wrapper { background-color: transparent; } `; diff --git a/datahub-web-react/src/app/auth/login.module.css b/datahub-web-react/src/app/auth/login.module.css index 37cc067c9dd20..81b933062a1a7 100644 --- a/datahub-web-react/src/app/auth/login.module.css +++ b/datahub-web-react/src/app/auth/login.module.css @@ -8,7 +8,7 @@ position: absolute; top: 40%; left: 50%; - transform: translate(-50%,-50%); + transform: translate(-50%, -50%); } .login_logo_box { @@ -28,7 +28,7 @@ .login_form_box { width: 100%; - background-color: #1C1C1C; + background-color: #1c1c1c; border: 1px solid #555555; border-radius: 5px; padding: 2em; @@ -36,8 +36,8 @@ .login_button { color: #171717; - background-color: #EEEEEE; - border: 1px solid #555555; + background-color: #eeeeee; + border: 1px solid #555555; height: 40px; font-size: 14px; } @@ -45,11 +45,11 @@ .login_button:hover { color: white; background-color: transparent; - border: 1px solid #555555; + border: 1px solid #555555; } .sso_button { - color: #EEEEEE; + color: #eeeeee; background-color: #171717; border: 1px solid #555555; height: 40px; @@ -60,4 +60,4 @@ color: black; background-color: white; border: 1px solid #555555; -} \ No newline at end of file +} diff --git a/datahub-web-react/src/app/buildEntityRegistry.ts b/datahub-web-react/src/app/buildEntityRegistry.ts index ed20722083032..0b70986672be5 100644 --- a/datahub-web-react/src/app/buildEntityRegistry.ts +++ b/datahub-web-react/src/app/buildEntityRegistry.ts @@ -19,10 +19,10 @@ import GlossaryNodeEntity from './entity/glossaryNode/GlossaryNodeEntity'; import { DataPlatformEntity } from './entity/dataPlatform/DataPlatformEntity'; import { DataProductEntity } from './entity/dataProduct/DataProductEntity'; import { DataPlatformInstanceEntity } from './entity/dataPlatformInstance/DataPlatformInstanceEntity'; -import { ERModelRelationshipEntity } from './entity/ermodelrelationships/ERModelRelationshipEntity' +import { ERModelRelationshipEntity } from './entity/ermodelrelationships/ERModelRelationshipEntity'; import { RoleEntity } from './entity/Access/RoleEntity'; import { RestrictedEntity } from './entity/restricted/RestrictedEntity'; -import {BusinessAttributeEntity} from "./entity/businessAttribute/BusinessAttributeEntity"; +import { BusinessAttributeEntity } from './entity/businessAttribute/BusinessAttributeEntity'; import { SchemaFieldPropertiesEntity } from './entity/schemaField/SchemaFieldPropertiesEntity'; export default function buildEntityRegistry() { @@ -48,9 +48,9 @@ export default function buildEntityRegistry() { registry.register(new DataPlatformEntity()); registry.register(new DataProductEntity()); registry.register(new DataPlatformInstanceEntity()); - registry.register(new ERModelRelationshipEntity()) + registry.register(new ERModelRelationshipEntity()); registry.register(new RestrictedEntity()); registry.register(new BusinessAttributeEntity()); registry.register(new SchemaFieldPropertiesEntity()); return registry; -} \ No newline at end of file +} diff --git a/datahub-web-react/src/app/businessAttribute/CreateBusinessAttributeModal.tsx b/datahub-web-react/src/app/businessAttribute/CreateBusinessAttributeModal.tsx index 61595045646c4..1ee0ca030748e 100644 --- a/datahub-web-react/src/app/businessAttribute/CreateBusinessAttributeModal.tsx +++ b/datahub-web-react/src/app/businessAttribute/CreateBusinessAttributeModal.tsx @@ -223,9 +223,9 @@ export default function CreateBusinessAttributeModal({ visible, onClose, onCreat > By default, a random UUID will be generated to uniquely identify this entity. If - you'd like to provide a custom id, you may provide it here. Note that it should be - unique across the entire Business Attributes. Be careful, you cannot easily change the id after - creation. + you'd like to provide a custom id, you may provide it here. Note that it should + be unique across the entire Business Attributes. Be careful, you cannot easily + change the id after creation. { it('renders', () => { diff --git a/datahub-web-react/src/app/entity/chart/ChartEntity.tsx b/datahub-web-react/src/app/entity/chart/ChartEntity.tsx index 2a54a4a96c639..913d502972fe1 100644 --- a/datahub-web-react/src/app/entity/chart/ChartEntity.tsx +++ b/datahub-web-react/src/app/entity/chart/ChartEntity.tsx @@ -28,6 +28,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 { ChartQueryTab } from './ChartQueryTab'; /** * Definition of the DataHub Chart entity. @@ -110,6 +111,14 @@ export class ChartEntity implements Entity { component: ChartStatsSummarySubHeader, }} tabs={[ + { + name: 'Query', + component: ChartQueryTab, + display: { + visible: (_, chart: GetChartQuery) => (chart?.chart?.query?.rawQuery && true) || false, + enabled: (_, chart: GetChartQuery) => (chart?.chart?.query?.rawQuery && true) || false, + }, + }, { name: 'Documentation', component: DocumentationTab, diff --git a/datahub-web-react/src/app/entity/chart/ChartQueryTab.tsx b/datahub-web-react/src/app/entity/chart/ChartQueryTab.tsx new file mode 100644 index 0000000000000..7c28f4be88d8d --- /dev/null +++ b/datahub-web-react/src/app/entity/chart/ChartQueryTab.tsx @@ -0,0 +1,61 @@ +import { Typography } from 'antd'; +import React from 'react'; +import styled from 'styled-components'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { GetChartQuery } from '../../../graphql/chart.generated'; +import { ANTD_GRAY } from '../shared/constants'; +import { useBaseEntity } from '../shared/EntityContext'; +import { InfoItem } from '../shared/components/styled/InfoItem'; + +const InfoSection = styled.div` + border-bottom: 1px solid ${ANTD_GRAY[4.5]}; + padding: 16px 20px; +`; + +const InfoItemContainer = styled.div<{ justifyContent }>` + display: flex; + position: relative; + justify-content: ${(props) => props.justifyContent}; + padding: 12px 2px; +`; + +const InfoItemContent = styled.div` + padding-top: 8px; +`; + +const QueryText = styled(Typography.Paragraph)` + margin-top: 20px; + background-color: ${ANTD_GRAY[2]}; +`; + +// NOTE: Yes, using `!important` is a shame. However, the SyntaxHighlighter is applying styles directly +// to the component, so there's no way around this +const NestedSyntax = styled(SyntaxHighlighter)` + background-color: transparent !important; + border: none !important; +`; + +export function ChartQueryTab() { + const baseEntity = useBaseEntity(); + const query = baseEntity?.chart?.query?.rawQuery || 'UNKNOWN'; + const type = baseEntity?.chart?.query?.type || 'UNKNOWN'; + + return ( + <> + + Details + + + {type.toUpperCase()} + + + + + Query + + {query} + + + + ); +} diff --git a/datahub-web-react/src/app/entity/dataset/DatasetEntity.tsx b/datahub-web-react/src/app/entity/dataset/DatasetEntity.tsx index 0caafb5523a20..6074bcc2f2f40 100644 --- a/datahub-web-react/src/app/entity/dataset/DatasetEntity.tsx +++ b/datahub-web-react/src/app/entity/dataset/DatasetEntity.tsx @@ -119,11 +119,11 @@ export class DatasetEntity implements Entity { component: ViewDefinitionTab, display: { visible: (_, dataset: GetDatasetQuery) => - dataset?.dataset?.subTypes?.typeNames + !!dataset?.dataset?.viewProperties?.logic || + !!dataset?.dataset?.subTypes?.typeNames ?.map((t) => t.toLocaleLowerCase()) - .includes(SUBTYPES.VIEW.toLocaleLowerCase()) || false, - enabled: (_, dataset: GetDatasetQuery) => - (dataset?.dataset?.viewProperties?.logic && true) || false, + .includes(SUBTYPES.VIEW.toLocaleLowerCase()), + enabled: (_, dataset: GetDatasetQuery) => !!dataset?.dataset?.viewProperties?.logic, }, }, { @@ -178,8 +178,7 @@ export class DatasetEntity implements Entity { }, }, { - name: 'Runs', - // TODO: Rename this to DatasetRunsTab. + name: 'Runs', // TODO: Rename this to DatasetRunsTab. component: OperationsTab, display: { visible: (_, dataset: GetDatasetQuery) => { @@ -234,7 +233,7 @@ export class DatasetEntity implements Entity { { component: SidebarViewDefinitionSection, display: { - visible: (_, dataset: GetDatasetQuery) => (dataset?.dataset?.viewProperties?.logic && true) || false, + visible: (_, dataset: GetDatasetQuery) => !!dataset?.dataset?.viewProperties?.logic, }, }, { @@ -249,8 +248,7 @@ export class DatasetEntity implements Entity { }, { component: DataProductSection, - }, - // TODO: Add back once entity-level recommendations are complete. + }, // TODO: Add back once entity-level recommendations are complete. // { // component: SidebarRecommendationsSection, // }, diff --git a/datahub-web-react/src/app/entity/domain/DataProductsTab/CreateDataProductModal.tsx b/datahub-web-react/src/app/entity/domain/DataProductsTab/CreateDataProductModal.tsx index 0610fbfa7a770..a2347928136e5 100644 --- a/datahub-web-react/src/app/entity/domain/DataProductsTab/CreateDataProductModal.tsx +++ b/datahub-web-react/src/app/entity/domain/DataProductsTab/CreateDataProductModal.tsx @@ -50,7 +50,7 @@ export default function CreateDataProductModal({ domain, onCreateDataProduct, on onClose(); } }) - .catch(( error ) => { + .catch((error) => { onClose(); message.destroy(); message.error({ content: `Failed to create Data Product: ${error.message}.` }); diff --git a/datahub-web-react/src/app/entity/domain/DataProductsTab/DataProductAdvancedOption.tsx b/datahub-web-react/src/app/entity/domain/DataProductsTab/DataProductAdvancedOption.tsx index a077a0308af1f..c3952fbaf5cb0 100644 --- a/datahub-web-react/src/app/entity/domain/DataProductsTab/DataProductAdvancedOption.tsx +++ b/datahub-web-react/src/app/entity/domain/DataProductsTab/DataProductAdvancedOption.tsx @@ -1,9 +1,8 @@ -import React from "react"; -import { Collapse, Form, Input, Typography } from "antd"; -import styled from "styled-components"; +import React from 'react'; +import { Collapse, Form, Input, Typography } from 'antd'; +import styled from 'styled-components'; import { validateCustomUrnId } from '../../../shared/textUtil'; -import { DataProductBuilderFormProps } from "./types"; - +import { DataProductBuilderFormProps } from './types'; const FormItem = styled(Form.Item)` .ant-form-item-label { @@ -23,8 +22,7 @@ const AdvancedLabel = styled(Typography.Text)` color: #373d44; `; -export function DataProductAdvancedOption({builderState, updateBuilderState }: DataProductBuilderFormProps){ - +export function DataProductAdvancedOption({ builderState, updateBuilderState }: DataProductBuilderFormProps) { function updateDataProductId(id: string) { updateBuilderState({ ...builderState, @@ -54,9 +52,9 @@ export function DataProductAdvancedOption({builderState, updateBuilderState }: D }), ]} > - updateDataProductId(e.target.value)} /> @@ -64,5 +62,5 @@ export function DataProductAdvancedOption({builderState, updateBuilderState }: D - ) -} \ No newline at end of file + ); +} diff --git a/datahub-web-react/src/app/entity/domain/DataProductsTab/DataProductBuilderForm.tsx b/datahub-web-react/src/app/entity/domain/DataProductsTab/DataProductBuilderForm.tsx index 98bb09098a36e..695267080c92f 100644 --- a/datahub-web-react/src/app/entity/domain/DataProductsTab/DataProductBuilderForm.tsx +++ b/datahub-web-react/src/app/entity/domain/DataProductsTab/DataProductBuilderForm.tsx @@ -43,7 +43,7 @@ export default function DataProductBuilderForm({ builderState, updateBuilderStat Description}> - + ); } diff --git a/datahub-web-react/src/app/entity/domain/DataProductsTab/types.ts b/datahub-web-react/src/app/entity/domain/DataProductsTab/types.ts index fe22e3ed9a2a4..2015b97f1433b 100644 --- a/datahub-web-react/src/app/entity/domain/DataProductsTab/types.ts +++ b/datahub-web-react/src/app/entity/domain/DataProductsTab/types.ts @@ -7,4 +7,4 @@ export type DataProductBuilderState = { export type DataProductBuilderFormProps = { builderState: DataProductBuilderState; updateBuilderState: (newState: DataProductBuilderState) => void; -}; \ No newline at end of file +}; diff --git a/datahub-web-react/src/app/entity/ermodelrelationships/ERModelRelationshipEntity.tsx b/datahub-web-react/src/app/entity/ermodelrelationships/ERModelRelationshipEntity.tsx index 91005f17b80c7..aece3db1312af 100644 --- a/datahub-web-react/src/app/entity/ermodelrelationships/ERModelRelationshipEntity.tsx +++ b/datahub-web-react/src/app/entity/ermodelrelationships/ERModelRelationshipEntity.tsx @@ -7,7 +7,10 @@ import { GenericEntityProperties } from '../shared/types'; import { ERModelRelationshipPreviewCard } from './preview/ERModelRelationshipPreviewCard'; import ermodelrelationshipIcon from '../../../images/ermodelrelationshipIcon.svg'; import { ERModelRelationshipTab } from '../shared/tabs/ERModelRelationship/ERModelRelationshipTab'; -import { useGetErModelRelationshipQuery, useUpdateErModelRelationshipMutation } from '../../../graphql/ermodelrelationship.generated'; +import { + useGetErModelRelationshipQuery, + useUpdateErModelRelationshipMutation, +} from '../../../graphql/ermodelrelationship.generated'; import { DocumentationTab } from '../shared/tabs/Documentation/DocumentationTab'; import { PropertiesTab } from '../shared/tabs/Properties/PropertiesTab'; import { SidebarAboutSection } from '../shared/containers/profile/sidebar/AboutSection/SidebarAboutSection'; @@ -108,7 +111,9 @@ export class ERModelRelationshipEntity implements Entity { {data.properties?.name || data.editableProperties?.name || ''} + + {data.properties?.name || data.editableProperties?.name || ''} + } description={data?.editableProperties?.description || ''} owners={data.ownership?.owners} diff --git a/datahub-web-react/src/app/entity/ermodelrelationships/preview/ERModelRelationshipAction.less b/datahub-web-react/src/app/entity/ermodelrelationships/preview/ERModelRelationshipAction.less index 7ac539d7a6a1e..0f63ee197fecb 100644 --- a/datahub-web-react/src/app/entity/ermodelrelationships/preview/ERModelRelationshipAction.less +++ b/datahub-web-react/src/app/entity/ermodelrelationships/preview/ERModelRelationshipAction.less @@ -1,4 +1,4 @@ -@import "../../../../../node_modules/antd/dist/antd.less"; +@import '../../../../../node_modules/antd/dist/antd.less'; .joinName { width: 385px; @@ -9,4 +9,4 @@ line-height: 24px; align-items: center; color: #262626; -} \ No newline at end of file +} diff --git a/datahub-web-react/src/app/entity/ermodelrelationships/preview/ERModelRelationshipPreviewCard.tsx b/datahub-web-react/src/app/entity/ermodelrelationships/preview/ERModelRelationshipPreviewCard.tsx index 33669485f18c6..715f935685d54 100644 --- a/datahub-web-react/src/app/entity/ermodelrelationships/preview/ERModelRelationshipPreviewCard.tsx +++ b/datahub-web-react/src/app/entity/ermodelrelationships/preview/ERModelRelationshipPreviewCard.tsx @@ -32,7 +32,9 @@ export const ERModelRelationshipPreviewCard = ({ name={name || ''} urn={urn} description={description || ''} - logoComponent={ERModelRelationship} + logoComponent={ + ERModelRelationship + } tags={globalTags || undefined} glossaryTerms={glossaryTerms || undefined} owners={owners} diff --git a/datahub-web-react/src/app/entity/group/EditGroupDescriptionModal.tsx b/datahub-web-react/src/app/entity/group/EditGroupDescriptionModal.tsx index a898a73c254ef..356daf584d9f7 100644 --- a/datahub-web-react/src/app/entity/group/EditGroupDescriptionModal.tsx +++ b/datahub-web-react/src/app/entity/group/EditGroupDescriptionModal.tsx @@ -22,19 +22,18 @@ export default function EditGroupDescriptionModal({ stagedDescription, }: Props) { const [form] = Form.useForm(); - const [aboutText,setAboutText] = useState(stagedDescription) + const [aboutText, setAboutText] = useState(stagedDescription); function updateDescription(description: string) { - setAboutText(aboutText) + setAboutText(aboutText); setStagedDescription(description); - } const saveDescription = () => { onSaveAboutMe(); onClose(); }; - + return ( { + const { updateTitle } = useBrowserTitle(); + + useEffect(() => { // You can use the title and updateTitle function here // For example, updating the title when the component mounts - if(name){ + if (name) { updateTitle(`Group | ${name}`); } // // Don't forget to clean up the title when the component unmounts return () => { - if(name){ // added to condition for rerendering issue + if (name) { + // added to condition for rerendering issue updateTitle(''); } }; @@ -216,7 +217,7 @@ export default function GroupInfoSidebar({ sideBarData, refetch }: Props) { urn, email, slack, - photoUrl + photoUrl, }; // About Text save diff --git a/datahub-web-react/src/app/entity/ownership/table/ActionsColumn.tsx b/datahub-web-react/src/app/entity/ownership/table/ActionsColumn.tsx index e08853ad150bf..cf4bf9a0fddf4 100644 --- a/datahub-web-react/src/app/entity/ownership/table/ActionsColumn.tsx +++ b/datahub-web-react/src/app/entity/ownership/table/ActionsColumn.tsx @@ -48,9 +48,9 @@ export const ActionsColumn = ({ ownershipType, setIsOpen, setOwnershipType, refe setOwnershipType(ownershipType); }; - const onCopy=() => { + const onCopy = () => { navigator.clipboard.writeText(ownershipType.urn); - } + }; const [deleteOwnershipTypeMutation] = useDeleteOwnershipTypeMutation(); @@ -125,8 +125,7 @@ export const ActionsColumn = ({ ownershipType, setIsOpen, setOwnershipType, refe const key = e.key as string; if (key === 'edit') { editOnClick(); - } - else if( key === 'copy') { + } else if (key === 'copy') { onCopy(); } }; diff --git a/datahub-web-react/src/app/entity/schemaField/SchemaFieldPropertiesEntity.tsx b/datahub-web-react/src/app/entity/schemaField/SchemaFieldPropertiesEntity.tsx index 91638d4997003..7e74b43e68afb 100644 --- a/datahub-web-react/src/app/entity/schemaField/SchemaFieldPropertiesEntity.tsx +++ b/datahub-web-react/src/app/entity/schemaField/SchemaFieldPropertiesEntity.tsx @@ -34,11 +34,7 @@ export class SchemaFieldPropertiesEntity implements Entity { renderProfile = (_: string) => <>; renderPreview = (previewType: PreviewType, data: SchemaFieldEntity) => ( - + ); renderSearch = (result: SearchResult) => this.renderPreview(PreviewType.SEARCH, result.entity as SchemaFieldEntity); diff --git a/datahub-web-react/src/app/entity/schemaField/preview/Preview.tsx b/datahub-web-react/src/app/entity/schemaField/preview/Preview.tsx index 3f24b3a06e3a4..b22e988c76672 100644 --- a/datahub-web-react/src/app/entity/schemaField/preview/Preview.tsx +++ b/datahub-web-react/src/app/entity/schemaField/preview/Preview.tsx @@ -20,7 +20,9 @@ export const Preview = ({ }): JSX.Element => { const entityRegistry = useEntityRegistry(); - const url = `${entityRegistry.getEntityUrl(EntityType.Dataset, datasetUrn)}/${encodeURIComponent('Schema')}?schemaFilter=${encodeURIComponent(name)}`; + const url = `${entityRegistry.getEntityUrl(EntityType.Dataset, datasetUrn)}/${encodeURIComponent( + 'Schema', + )}?schemaFilter=${encodeURIComponent(name)}`; return ( ); -}; \ No newline at end of file +}; diff --git a/datahub-web-react/src/app/entity/shared/EntityContext.ts b/datahub-web-react/src/app/entity/shared/EntityContext.ts index c564d73c7441f..abc7fcfa6cced 100644 --- a/datahub-web-react/src/app/entity/shared/EntityContext.ts +++ b/datahub-web-react/src/app/entity/shared/EntityContext.ts @@ -22,17 +22,17 @@ export function useEntityContext() { return useContext(EntityContext); } -export const useBaseEntity = (): T => { +export const useBaseEntity = (): T => { const { baseEntity } = useContext(EntityContext); return baseEntity as T; }; -export const useDataNotCombinedWithSiblings = (): T => { +export const useDataNotCombinedWithSiblings = (): T => { const { dataNotCombinedWithSiblings } = useContext(EntityContext); return dataNotCombinedWithSiblings as T; }; -export const useEntityUpdate = (): UpdateEntityType | null | undefined => { +export const useEntityUpdate = (): UpdateEntityType | null | undefined => { const { updateEntity } = useContext(EntityContext); return updateEntity; }; diff --git a/datahub-web-react/src/app/entity/shared/EntityDropdown/UpdateDeprecationModal.tsx b/datahub-web-react/src/app/entity/shared/EntityDropdown/UpdateDeprecationModal.tsx index 25527497b33a8..01287c2b367bf 100644 --- a/datahub-web-react/src/app/entity/shared/EntityDropdown/UpdateDeprecationModal.tsx +++ b/datahub-web-react/src/app/entity/shared/EntityDropdown/UpdateDeprecationModal.tsx @@ -71,11 +71,11 @@ export const UpdateDeprecationModal = ({ urns, onClose, refetch }: Props) => { } - width='40%' + width="40%" >
- + diff --git a/datahub-web-react/src/app/entity/shared/components/styled/DeprecationPill.tsx b/datahub-web-react/src/app/entity/shared/components/styled/DeprecationPill.tsx index 9ec2aab193aa0..08e9636f760de 100644 --- a/datahub-web-react/src/app/entity/shared/components/styled/DeprecationPill.tsx +++ b/datahub-web-react/src/app/entity/shared/components/styled/DeprecationPill.tsx @@ -165,8 +165,7 @@ export const DeprecationPill = ({ deprecation, urn, refetch, showUndeprecate }: {expanded || !overLimit ? ( <> - { - deprecation?.note && deprecation?.note !== '' && + {deprecation?.note && deprecation?.note !== '' && ( <> @@ -181,7 +180,7 @@ export const DeprecationPill = ({ deprecation, urn, refetch, showUndeprecate }: )} - } + )} ) : ( <> diff --git a/datahub-web-react/src/app/entity/shared/components/styled/ERModelRelationship/CreateERModelRelationModal.less b/datahub-web-react/src/app/entity/shared/components/styled/ERModelRelationship/CreateERModelRelationModal.less index b50d3debaf1ef..8c1f29aa7fc77 100644 --- a/datahub-web-react/src/app/entity/shared/components/styled/ERModelRelationship/CreateERModelRelationModal.less +++ b/datahub-web-react/src/app/entity/shared/components/styled/ERModelRelationship/CreateERModelRelationModal.less @@ -1,12 +1,12 @@ -@import "../../../../../../../node_modules/antd/dist/antd.less"; +@import '../../../../../../../node_modules/antd/dist/antd.less'; .CreateERModelRelationModal { .ermodelrelation-name { padding: 8px 16px; width: 948.5px !important; height: 40px !important; - background: #FFFFFF; - border: 1px solid #D9D9D9; + background: #ffffff; + border: 1px solid #d9d9d9; box-shadow: 0px 2px 0px rgba(0, 0, 0, 0.016); border-radius: 2px; align-items: center; @@ -18,8 +18,8 @@ max-width: 370px; min-width: 370px; height: 38px; - background: #FFFFFF; - border: 1px solid #D9D9D9; + background: #ffffff; + border: 1px solid #d9d9d9; box-shadow: 0px 2px 0px rgba(0, 0, 0, 0.016); border-radius: 2px; } @@ -27,8 +27,8 @@ box-sizing: border-box; width: 1000px; height: 765px; - background: #FFFFFF; - border: 1px solid #ADC0D7; + background: #ffffff; + border: 1px solid #adc0d7; box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.15); border-radius: 8px; left: -215px; @@ -58,7 +58,7 @@ color: #000000; padding-top: 4px; } - .all-content-heading{ + .all-content-heading { width: 380px; height: 16px; margin-top: 16px; @@ -68,10 +68,10 @@ font-weight: 700; font-size: 14px; line-height: 16px; - color: #1B2F41; + color: #1b2f41; flex: none; } - .all-table-heading{ + .all-table-heading { width: 380px; height: 16px; margin-bottom: 8px; @@ -80,11 +80,11 @@ font-weight: 700; font-size: 14px; line-height: 16px; - color: #1B2F41; + color: #1b2f41; flex: none; } - - .field-heading{ + + .field-heading { height: 16px; margin-top: 32px; margin-bottom: 8px; @@ -93,16 +93,16 @@ font-weight: 700; font-size: 14px; line-height: 16px; - color: #1B2F41; + color: #1b2f41; } - .all-information{ + .all-information { width: 680px; height: 24px; font-family: 'Arial'; font-style: normal; font-weight: 400; font-size: 16px; - color: #1B2F41; + color: #1b2f41; } .techNameDisplay { font-size: 14px; @@ -127,7 +127,7 @@ padding-right: 25px; border-top: 0px; } - + .ant-btn-link { padding-left: 0px !important; padding-right: 1px !important; @@ -135,7 +135,7 @@ font-style: normal !important; font-weight: 400 !important; font-size: 14px !important; - color: #1890FF !important; + color: #1890ff !important; } .add-btn-link { padding-left: 865px !important; @@ -146,7 +146,7 @@ font-style: normal !important; font-weight: 700 !important; font-size: 12px !important; - color: #1890FF !important; + color: #1890ff !important; line-height: 20px; } @@ -155,20 +155,21 @@ margin-left: 440px; width: 85px; height: 40px !important; - background: #FFFFFF; - border: 1px solid #D9D9D9 !important; + background: #ffffff; + border: 1px solid #d9d9d9 !important; border-radius: 5px; color: #262626; } - - .submit-btn, .submit-btn:hover { + + .submit-btn, + .submit-btn:hover { margin-left: 28px; //margin-top: 6px; width: 86px; height: 40px; - background: #1890FF; + background: #1890ff; border: none; - color: #FFFFFF; + color: #ffffff; } .footer-parent-div { padding-left: 8px; @@ -183,11 +184,11 @@ min-width: 373px !important; font-size: 14px; line-height: 22px; - font-family: 'Roboto Mono',monospace; + font-family: 'Roboto Mono', monospace; font-weight: 400; background: white; font-style: normal; - color: #000000D9; + color: #000000d9; } .ermodelrelation-details-ta { height: 95px; @@ -213,13 +214,13 @@ font-weight: 500; font-size: 14px; line-height: 22px; - color: #1B2F41; + color: #1b2f41; align-items: center; padding: 16px; gap: 4px; isolation: isolate; height: 56px !important; - background: #FFFFFF; + background: #ffffff; border-color: rgba(0, 0, 0, 0.12); } .ant-table-tbody > tr td { @@ -233,11 +234,12 @@ color: rgba(0, 0, 0, 0.85); border-color: rgba(0, 0, 0, 0.12); } - td:nth-child(1), td:nth-child(3){ + td:nth-child(1), + td:nth-child(3) { max-width: 400px !important; min-width: 400px !important; } - .titleNameDisplay{ + .titleNameDisplay { max-width: 360px; overflow: hidden; white-space: nowrap; @@ -247,11 +249,11 @@ font-size: 14px; padding: 4px 0; } - .firstRow{ + .firstRow { display: flex; justify-content: left; } - + .editableNameDisplay { display: block; overflow-wrap: break-word; @@ -267,11 +269,13 @@ line-height: 16px; color: #595959; } - td:nth-child(2), th:nth-child(2){ + td:nth-child(2), + th:nth-child(2) { min-width: 44px !important; max-width: 44px !important; } - td:nth-child(4), th:nth-child(4){ + td:nth-child(4), + th:nth-child(4) { min-width: 75px !important; max-width: 75px !important; } @@ -280,15 +284,15 @@ border-collapse: collapse; } .SelectedRow { - background-color: #ECF2F8; + background-color: #ecf2f8; } } } .cancel-modal { .ant-btn-primary { - color: #FFFFFF; - background: #1890FF; + color: #ffffff; + background: #1890ff; border: none; box-shadow: none; } -} \ No newline at end of file +} diff --git a/datahub-web-react/src/app/entity/shared/components/styled/ERModelRelationship/CreateERModelRelationModal.tsx b/datahub-web-react/src/app/entity/shared/components/styled/ERModelRelationship/CreateERModelRelationModal.tsx index a6f84b8c8fc5c..dd6cbc3698705 100644 --- a/datahub-web-react/src/app/entity/shared/components/styled/ERModelRelationship/CreateERModelRelationModal.tsx +++ b/datahub-web-react/src/app/entity/shared/components/styled/ERModelRelationship/CreateERModelRelationModal.tsx @@ -5,11 +5,19 @@ import { PlusOutlined } from '@ant-design/icons'; import arrow from '../../../../../../images/Arrow.svg'; import './CreateERModelRelationModal.less'; import { EntityType, ErModelRelationship, OwnerEntityType } from '../../../../../../types.generated'; -import { useCreateErModelRelationshipMutation, useUpdateErModelRelationshipMutation } from '../../../../../../graphql/ermodelrelationship.generated'; +import { + useCreateErModelRelationshipMutation, + useUpdateErModelRelationshipMutation, +} from '../../../../../../graphql/ermodelrelationship.generated'; import { useUserContext } from '../../../../../context/useUserContext'; import { EditableRow } from './EditableRow'; import { EditableCell } from './EditableCell'; -import { checkDuplicateERModelRelation, getDatasetName, ERModelRelationDataType, validateERModelRelation } from './ERModelRelationUtils'; +import { + checkDuplicateERModelRelation, + getDatasetName, + ERModelRelationDataType, + validateERModelRelation, +} from './ERModelRelationUtils'; import { useGetSearchResultsQuery } from '../../../../../../graphql/search.generated'; import { useAddOwnerMutation } from '../../../../../../graphql/mutations.generated'; @@ -52,7 +60,10 @@ export const CreateERModelRelationModal = ({ const [details, setDetails] = useState(editERModelRelation?.editableProperties?.description || ''); const [ermodelrelationName, setERModelRelationName] = useState( - editERModelRelation?.editableProperties?.name || editERModelRelation?.properties?.name || editERModelRelation?.id || '', + editERModelRelation?.editableProperties?.name || + editERModelRelation?.properties?.name || + editERModelRelation?.id || + '', ); const [tableData, setTableData] = useState( editERModelRelation?.properties?.relationshipFieldMappings?.map((item, index) => { @@ -116,11 +127,11 @@ export const CreateERModelRelationModal = ({ destination: table2Dataset?.urn || '', name: ermodelrelationName, relationshipFieldmappings: tableData.map((r) => { - return { - sourceField: r.field1Name, - destinationField: r.field2Name, - }; - }), + return { + sourceField: r.field1Name, + destinationField: r.field2Name, + }; + }), created: true, }, editableProperties: { @@ -171,12 +182,12 @@ export const CreateERModelRelationModal = ({ createdBy: editERModelRelation?.properties?.createdActor?.urn || user?.urn, createdAt: editERModelRelation?.properties?.createdTime || 0, relationshipFieldmappings: tableData.map((r) => { - return { - sourceField: r.field1Name, - destinationField: r.field2Name, - }; - }), - }, + return { + sourceField: r.field1Name, + destinationField: r.field2Name, + }; + }), + }, editableProperties: { name: ermodelrelationName, description: details, @@ -203,7 +214,12 @@ export const CreateERModelRelationModal = ({ }); }; const onSubmit = async () => { - const errors = validateERModelRelation(ermodelrelationName, tableData, isEditing, getSearchResultsERModelRelations); + const errors = validateERModelRelation( + ermodelrelationName, + tableData, + isEditing, + getSearchResultsERModelRelations, + ); if ((await errors).length > 0) { const err = (await errors).join(`, `); message.error(err); @@ -368,19 +384,25 @@ export const CreateERModelRelationModal = ({ }, { validator: (_, value) => - checkDuplicateERModelRelation(getSearchResultsERModelRelations, value?.trim()).then((result) => { - return result === true && !isEditing - ? Promise.reject( - new Error( - 'This ER-Model-Relationship name already exists. A unique name for each ER-Model-Relationship is required.', - ), - ) - : Promise.resolve(); - }), + checkDuplicateERModelRelation(getSearchResultsERModelRelations, value?.trim()).then( + (result) => { + return result === true && !isEditing + ? Promise.reject( + new Error( + 'This ER-Model-Relationship name already exists. A unique name for each ER-Model-Relationship is required.', + ), + ) + : Promise.resolve(); + }, + ), }, ]} > - setERModelRelationName(e.target.value)} /> + setERModelRelationName(e.target.value)} + />

Fields

tr th { padding: 16px; gap: 4px; - background: #FFFFFF; + background: #ffffff; border-width: 1px 0px 1px 1px; border-style: solid; border-color: rgba(0, 0, 0, 0.12); @@ -133,17 +133,17 @@ .ant-table-tbody > tr td { font-size: 14px; line-height: 22px; - font-family: 'Roboto Mono',monospace; + font-family: 'Roboto Mono', monospace; font-weight: 400; background: white; font-style: normal; - color: #000000D9; + color: #000000d9; padding: 16px; border-width: 0px 0px 1px 1px; border-style: solid; border-color: rgba(0, 0, 0, 0.12); } - .firstRow{ + .firstRow { display: flex; justify-content: left; } @@ -152,7 +152,7 @@ font-style: normal; font-weight: 600; line-height: 22px; - color: #1B2F41; + color: #1b2f41; } .editableNameDisplay { color: #595959; @@ -163,14 +163,13 @@ line-height: normal; } .SelectedRow { - background-color: #ECF2F8; + background-color: #ecf2f8; } } } -.radioButton{ - .ant-radio-inner{ +.radioButton { + .ant-radio-inner { margin-top: 20px; border-color: #4a5568; } } - diff --git a/datahub-web-react/src/app/entity/shared/components/styled/ERModelRelationship/ERModelRelationPreview.tsx b/datahub-web-react/src/app/entity/shared/components/styled/ERModelRelationship/ERModelRelationPreview.tsx index a033f9d4a3574..b360f03bb5b28 100644 --- a/datahub-web-react/src/app/entity/shared/components/styled/ERModelRelationship/ERModelRelationPreview.tsx +++ b/datahub-web-react/src/app/entity/shared/components/styled/ERModelRelationship/ERModelRelationPreview.tsx @@ -51,7 +51,8 @@ export const ERModelRelationPreview = ({ ermodelrelationData, baseEntityUrn, pre shuffleFlag && prePageType !== 'ERModelRelationship' ? ermodelrelationData?.properties?.source?.urn : ermodelrelationData?.properties?.destination?.urn; - const ermodelrelationHeader = ermodelrelationData?.editableProperties?.name || ermodelrelationData?.properties?.name || ''; + const ermodelrelationHeader = + ermodelrelationData?.editableProperties?.name || ermodelrelationData?.properties?.name || ''; function getFieldMap(): ERModelRelationRecord[] { const newData = [] as ERModelRelationRecord[]; if (shuffleFlag && prePageType !== 'ERModelRelationship') { @@ -147,7 +148,12 @@ export const ERModelRelationPreview = ({ ermodelrelationData, baseEntityUrn, pre

{ermodelrelationHeader}

{prePageType === 'Dataset' && ( - - } trigger={['click']}> - - - + <> + {showMenu && ( + + + + {(record.platform.properties?.logoUrl && ( + + )) || ( + + {record.platform.properties?.displayName || + capitalizeFirstLetterOnly(record.platform.name)} + + )} + + + + } trigger={['click']}> + + + + )} + ), }, ]; @@ -168,18 +263,36 @@ export const DatasetAssertionsList = ({ assertions, onDelete }: Props) => { locale={{ emptyText: , }} - expandable={{ - defaultExpandAllRows: false, - expandRowByClick: true, - expandedRowRender: (record) => { - return ; - }, - expandIcon: ({ expanded, onExpand, record }: any) => - expanded ? ( - onExpand(record, e)} /> - ) : ( - onExpand(record, e)} /> - ), + expandable={ + showSelect + ? {} + : { + defaultExpandAllRows: false, + expandRowByClick: true, + expandedRowRender: (record) => { + return ( + + ); + }, + expandIcon: ({ expanded, onExpand, record }: any) => + expanded ? ( + onExpand(record, e)} /> + ) : ( + onExpand(record, e)} /> + ), + } + } + onRow={(record) => { + return { + onClick: (_) => { + if (showSelect) { + onSelect?.(record.urn as string); + } + }, + }; }} showHeader={false} pagination={false} diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/FieldAssertionDescription.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/FieldAssertionDescription.tsx new file mode 100644 index 0000000000000..a104903dc7bc2 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/FieldAssertionDescription.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { Typography } from 'antd'; +import { FieldAssertionInfo } from '../../../../../../types.generated'; +import { + getFieldDescription, + getFieldOperatorDescription, + getFieldParametersDescription, + getFieldTransformDescription, +} from './fieldDescriptionUtils'; + +type Props = { + assertionInfo: FieldAssertionInfo; +}; + +/** + * A human-readable description of a Field Assertion. + */ +export const FieldAssertionDescription = ({ assertionInfo }: Props) => { + const field = getFieldDescription(assertionInfo); + const operator = getFieldOperatorDescription(assertionInfo); + const transform = getFieldTransformDescription(assertionInfo); + const parameters = getFieldParametersDescription(assertionInfo); + + return ( + + {transform} + {transform ? ' of ' : ''} + {field} {operator} {parameters} + + ); +}; diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/SqlAssertionDescription.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/SqlAssertionDescription.tsx new file mode 100644 index 0000000000000..047f7c7db28f6 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/SqlAssertionDescription.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { Typography } from 'antd'; +import { AssertionInfo } from '../../../../../../types.generated'; + +type Props = { + assertionInfo: AssertionInfo; +}; + +/** + * A human-readable description of a SQL Assertion. + */ +export const SqlAssertionDescription = ({ assertionInfo }: Props) => { + const { description } = assertionInfo; + + return {description}; +}; diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/ValidationsTab.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/ValidationsTab.tsx index b4f77196edbb1..92af9bfc2b567 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/ValidationsTab.tsx +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/ValidationsTab.tsx @@ -2,13 +2,15 @@ import React, { useEffect } from 'react'; import { Button } from 'antd'; import { useHistory, useLocation } from 'react-router'; import styled from 'styled-components'; -import { FileDoneOutlined, FileProtectOutlined } from '@ant-design/icons'; +import { AuditOutlined, FileDoneOutlined, FileProtectOutlined } from '@ant-design/icons'; import { useEntityData } from '../../../EntityContext'; import { TestResults } from './TestResults'; import { Assertions } from './Assertions'; import TabToolbar from '../../../components/styled/TabToolbar'; import { useGetValidationsTab } from './useGetValidationsTab'; import { ANTD_GRAY } from '../../../constants'; +import { useAppConfig } from '../../../../../useAppConfig'; +import { DataContractTab } from './contract/DataContractTab'; const TabTitle = styled.span` margin-left: 4px; @@ -22,6 +24,7 @@ const TabButton = styled(Button)<{ selected: boolean }>` enum TabPaths { ASSERTIONS = 'Assertions', TESTS = 'Tests', + DATA_CONTRACT = 'Data Contract', } const DEFAULT_TAB = TabPaths.ASSERTIONS; @@ -33,6 +36,7 @@ export const ValidationsTab = () => { const { entityData } = useEntityData(); const history = useHistory(); const { pathname } = useLocation(); + const appConfig = useAppConfig(); const totalAssertions = (entityData as any)?.assertions?.total; const passingTests = (entityData as any)?.testResults?.passing || []; @@ -77,6 +81,22 @@ export const ValidationsTab = () => { }, ]; + if (appConfig.config.featureFlags?.dataContractsEnabled) { + // If contracts feature is enabled, add to list. + tabs.push({ + title: ( + <> + + + Data Contract + + ), + path: TabPaths.DATA_CONTRACT, + content: , + disabled: false, + }); + } + return ( <> diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/VolumeAssertionDescription.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/VolumeAssertionDescription.tsx new file mode 100644 index 0000000000000..26634c459df0d --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/VolumeAssertionDescription.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Typography } from 'antd'; +import { + IncrementingSegmentRowCountChange, + RowCountChange, + VolumeAssertionInfo, +} from '../../../../../../types.generated'; +import { + getIsRowCountChange, + getOperatorDescription, + getParameterDescription, + getValueChangeTypeDescription, + getVolumeTypeDescription, + getVolumeTypeInfo, +} from './utils'; + +type Props = { + assertionInfo: VolumeAssertionInfo; +}; + +/** + * A human-readable description of a Volume Assertion. + */ +export const VolumeAssertionDescription = ({ assertionInfo }: Props) => { + const volumeType = assertionInfo.type; + const volumeTypeInfo = getVolumeTypeInfo(assertionInfo); + const volumeTypeDescription = getVolumeTypeDescription(volumeType); + const operatorDescription = volumeTypeInfo ? getOperatorDescription(volumeTypeInfo.operator) : ''; + const parameterDescription = volumeTypeInfo ? getParameterDescription(volumeTypeInfo.parameters) : ''; + const valueChangeTypeDescription = getIsRowCountChange(volumeType) + ? getValueChangeTypeDescription((volumeTypeInfo as RowCountChange | IncrementingSegmentRowCountChange).type) + : 'rows'; + + return ( +
+ + Table {volumeTypeDescription} {operatorDescription} {parameterDescription} {valueChangeTypeDescription} + +
+ ); +}; diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/assertionUtils.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/assertionUtils.tsx index 1eaacb36515a1..341742f407f73 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/assertionUtils.tsx +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/assertionUtils.tsx @@ -18,7 +18,7 @@ export const sortAssertions = (a, b) => { if (!b.runEvents?.runEvents?.length) { return -1; } - return b.runEvents.runEvents[0].timestampMillis - a.runEvents.runEvents[0].timestampMillis; + return b.runEvents?.runEvents[0]?.timestampMillis - a.runEvents?.runEvents[0]?.timestampMillis; }; /** diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/DataContractAssertionStatus.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/DataContractAssertionStatus.tsx new file mode 100644 index 0000000000000..c36bef09cdb68 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/DataContractAssertionStatus.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import styled from 'styled-components'; +import { Tooltip } from 'antd'; +import { StopOutlined } from '@ant-design/icons'; +import { Assertion, AssertionResultType } from '../../../../../../../types.generated'; +import { + StyledCheckOutlined, + StyledClockCircleOutlined, + StyledCloseOutlined, + StyledExclamationOutlined, +} from '../shared/styledComponents'; + +const StatusContainer = styled.div` + width: 100%; + display: flex; + justify-content: center; +`; + +type Props = { + assertion: Assertion; +}; + +export const DataContractAssertionStatus = ({ assertion }: Props) => { + const latestRun = (assertion.runEvents?.runEvents?.length && assertion.runEvents?.runEvents[0]) || undefined; + const latestResultType = latestRun?.result?.type || undefined; + + return ( + + {latestResultType === undefined && } + + {latestResultType === AssertionResultType.Success && } + + + {latestResultType === AssertionResultType.Failure && } + + + {latestResultType === AssertionResultType.Error && } + + + {latestResultType === AssertionResultType.Init && } + + + ); +}; diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/DataContractEmptyState.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/DataContractEmptyState.tsx new file mode 100644 index 0000000000000..36004cb1351f3 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/DataContractEmptyState.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import styled from 'styled-components'; +import { Button, Typography } from 'antd'; +import { PlusOutlined } from '@ant-design/icons'; + +import { ANTD_GRAY } from '../../../../constants'; + +const Container = styled.div``; + +const Summary = styled.div` + width: 100%; + padding-left: 40px; + padding-top: 20px; + padding-bottom: 20px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid ${ANTD_GRAY[4.5]}; + box-shadow: 0px 2px 6px 0px #0000000d; +`; + +const SummaryDescription = styled.div` + display: flex; + align-items: center; +`; + +const SummaryMessage = styled.div` + display: inline-block; + margin-left: 20px; + max-width: 350px; +`; + +const SummaryTitle = styled(Typography.Title)` + && { + padding-bottom: 0px; + margin-bottom: 4px; + } +`; + +const Actions = styled.div` + margin: 12px; + margin-right: 20px; +`; + +const CreateButton = styled(Button)` + margin-right: 12px; + border-color: ${(props) => props.theme.styles['primary-color']}; + color: ${(props) => props.theme.styles['primary-color']}; + letter-spacing: 2px; + &&:hover { + color: white; + background-color: ${(props) => props.theme.styles['primary-color']}; + border-color: ${(props) => props.theme.styles['primary-color']}; + } +`; + +type Props = { + showContractBuilder: () => void; +}; + +export const DataContractEmptyState = ({ showContractBuilder }: Props) => { + return ( + + + + + + No contract found +
+ + A contract does not yet exist for this dataset + +
+
+
+
+ + + + CREATE + + +
+
+ ); +}; diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/DataContractSummary.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/DataContractSummary.tsx new file mode 100644 index 0000000000000..9b684486cb5ce --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/DataContractSummary.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import styled from 'styled-components'; +import { EditFilled } from '@ant-design/icons'; +import { Button, Typography } from 'antd'; +import { DataContractState } from '../../../../../../../types.generated'; +import { AssertionStatusSummary } from '../types'; +import { getContractSummaryIcon, getContractSummaryTitle, getContractSummaryMessage } from './utils'; +import { ANTD_GRAY } from '../../../../constants'; + +const SummaryHeader = styled.div` + width: 100%; + padding-left: 40px; + padding-top: 20px; + padding-bottom: 20px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid ${ANTD_GRAY[4.5]}; +`; + +const SummaryContainer = styled.div``; + +const SummaryDescription = styled.div` + display: flex; + align-items: center; +`; + +const SummaryMessage = styled.div` + display: inline-block; + margin-left: 20px; +`; + +const SummaryTitle = styled(Typography.Title)` + && { + padding-bottom: 0px; + margin-bottom: 0px; + } +`; + +const Actions = styled.div` + margin: 12px; + margin-right: 20px; +`; + +const CreateButton = styled(Button)` + display: flex; + align-items: center; + gap: 0.3rem; + margin-right: 12px; + border-color: ${(props) => props.theme.styles['primary-color']}; + color: ${(props) => props.theme.styles['primary-color']}; + letter-spacing: 2px; + &&:hover { + color: white; + background-color: ${(props) => props.theme.styles['primary-color']}; + border-color: ${(props) => props.theme.styles['primary-color']}; + } +`; + +const EditIconStyle = styled(EditFilled)` + && { + font-size: 12px; + } +`; + +type Props = { + state: DataContractState; + summary: AssertionStatusSummary; + showContractBuilder: () => void; +}; + +export const DataContractSummary = ({ state, summary, showContractBuilder }: Props) => { + const summaryIcon = getContractSummaryIcon(state, summary); + const summaryTitle = getContractSummaryTitle(state, summary); + const summaryMessage = getContractSummaryMessage(state, summary); + return ( + + + + {summaryIcon} + + {summaryTitle} + {summaryMessage} + + + + + + + EDIT + + + + ); +}; diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/DataContractSummaryFooter.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/DataContractSummaryFooter.tsx new file mode 100644 index 0000000000000..6a892ebe2417a --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/DataContractSummaryFooter.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import styled from 'styled-components'; +import { ArrowRightOutlined } from '@ant-design/icons'; +import { Button } from 'antd'; +import { Assertion } from '../../../../../../../types.generated'; +import { StyledCheckOutlined, StyledCloseOutlined, StyledExclamationOutlined } from '../shared/styledComponents'; +import { getAssertionsSummary } from '../utils'; +import { ANTD_GRAY, REDESIGN_COLORS } from '../../../../constants'; + +const Container = styled.div` + display: flex; + align-items: center; + justify-content: space-between; +`; + +const StatusContainer = styled.div` + display: flex; + align-items: center; +`; + +const StatusText = styled.div` + color: ${ANTD_GRAY[8]}; + margin-left: 4px; +`; + +const ActionButton = styled(Button)` + color: ${REDESIGN_COLORS.BLUE}; +`; + +const StyledArrowRightOutlined = styled(ArrowRightOutlined)` + font-size: 8px; +`; + +type Props = { + assertions: Assertion[]; + passingText: string; + failingText: string; + errorText: string; + actionText?: string; + showAction?: boolean; +}; + +export const DataContractSummaryFooter = ({ + assertions, + actionText, + passingText, + errorText, + failingText, + showAction = true, +}: Props) => { + const summary = getAssertionsSummary(assertions); + const isFailing = summary.failing > 0; + const isPassing = summary.passing && summary.passing === summary.total; + const isErroring = summary.erroring > 0; + return ( + + + {(isFailing && ) || undefined} + {(isPassing && ) || undefined} + {(isErroring && !isFailing && ) || undefined} + + {(isFailing && failingText) || undefined} + {(isPassing && passingText) || undefined} + {(isErroring && errorText) || undefined} + + + {showAction && ( + + {actionText} + + + )} + + ); +}; diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/DataContractTab.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/DataContractTab.tsx new file mode 100644 index 0000000000000..52a7eca8730be --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/DataContractTab.tsx @@ -0,0 +1,117 @@ +import React, { useState } from 'react'; +import styled from 'styled-components'; +import { useGetDatasetContractQuery } from '../../../../../../../graphql/contract.generated'; +import { DataContractState } from '../../../../../../../types.generated'; +import { useEntityData } from '../../../../EntityContext'; +import { DataContractEmptyState } from './DataContractEmptyState'; +import { DataContractSummary } from './DataContractSummary'; +import { DataQualityContractSummary } from './DataQualityContractSummary'; +import { SchemaContractSummary } from './SchemaContractSummary'; +import { FreshnessContractSummary } from './FreshnessContractSummary'; +import { DataContractBuilderModal } from './builder/DataContractBuilderModal'; +import { createBuilderState } from './builder/utils'; +import { getAssertionsSummary } from '../utils'; + +const Container = styled.div` + display: flex; +`; + +const LeftColumn = styled.div` + width: 50%; +`; + +const RightColumn = styled.div` + width: 50%; +`; + +/** + * Component used for rendering the Data Contract Tab on the Assertions parent tab. + */ +export const DataContractTab = () => { + const { urn } = useEntityData(); + + const { data, refetch } = useGetDatasetContractQuery({ + variables: { + urn, + }, + }); + const [showContractBuilder, setShowContractBuilder] = useState(false); + + const contract = data?.dataset?.contract; + const schemaContracts = data?.dataset?.contract?.properties?.schema || []; + const freshnessContracts = data?.dataset?.contract?.properties?.freshness || []; + const dataQualityContracts = data?.dataset?.contract?.properties?.dataQuality || []; + const schemaAssertions = schemaContracts.map((c) => c.assertion); + const freshnessAssertions = freshnessContracts.map((c) => c.assertion); + const dataQualityAssertions = dataQualityContracts.map((c) => c.assertion); + const assertionsSummary = getAssertionsSummary([ + ...schemaAssertions, + ...freshnessAssertions, + ...dataQualityAssertions, + ] as any); + const contractState = data?.dataset?.contract?.status?.state || DataContractState.Active; + const hasFreshnessContract = freshnessContracts && freshnessContracts?.length; + const hasSchemaContract = schemaContracts && schemaContracts?.length; + const hasDataQualityContract = dataQualityContracts && dataQualityContracts?.length; + const showLeftColumn = hasFreshnessContract || hasSchemaContract || undefined; + + const onContractUpdate = () => { + if (contract) { + // Contract exists, just refetch. + refetch(); + } else { + // no contract yet, wait for indxing, + setTimeout(() => refetch(), 3000); + } + setShowContractBuilder(false); + }; + + return ( + <> + {data?.dataset?.contract ? ( + <> + setShowContractBuilder(true)} + /> + + {showLeftColumn && ( + + {(hasFreshnessContract && ( + + )) || + undefined} + {(hasSchemaContract && ( + + )) || + undefined} + + )} + + {hasDataQualityContract ? ( + + ) : undefined} + + + + ) : ( + setShowContractBuilder(true)} /> + )} + {showContractBuilder && ( + setShowContractBuilder(false)} + onSubmit={onContractUpdate} + /> + )} + + ); +}; diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/DataQualityContractSummary.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/DataQualityContractSummary.tsx new file mode 100644 index 0000000000000..5b01540f859c6 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/DataQualityContractSummary.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import styled from 'styled-components'; +import { Table } from 'antd'; +import { Assertion, DataQualityContract, DatasetAssertionInfo } from '../../../../../../../types.generated'; +import { ANTD_GRAY } from '../../../../constants'; +import { DataContractAssertionStatus } from './DataContractAssertionStatus'; +import { DataContractSummaryFooter } from './DataContractSummaryFooter'; +import { DatasetAssertionDescription } from '../DatasetAssertionDescription'; +import { FieldAssertionDescription } from '../FieldAssertionDescription'; +import { SqlAssertionDescription } from '../SqlAssertionDescription'; +import { VolumeAssertionDescription } from '../VolumeAssertionDescription'; + +const TitleText = styled.div` + color: ${ANTD_GRAY[7]}; + margin-bottom: 20px; + letter-spacing: 1px; +`; + +const ColumnHeader = styled.div` + color: ${ANTD_GRAY[8]}; +`; + +const Container = styled.div` + padding: 28px; +`; + +const SummaryContainer = styled.div` + width: 100%; + display: flex; + align-items: center; +`; + +const StyledTable = styled(Table)` + width: 100%; + border-radius: 8px; + box-shadow: 0px 0px 4px rgba(0, 0, 0, 0.1); +`; + +type Props = { + contracts: DataQualityContract[]; + showAction?: boolean; +}; + +export const DataQualityContractSummary = ({ contracts, showAction = false }: Props) => { + const assertions: Assertion[] = contracts?.map((contract) => contract.assertion); + + const columns = [ + { + title: () => ASSERTION, + render: (assertion: Assertion) => ( + <> + {assertion.info?.datasetAssertion && ( + + )} + {assertion.info?.volumeAssertion && ( + + )} + {assertion.info?.fieldAssertion && ( + + )} + {assertion.info?.sqlAssertion && } + + ), + }, + { + title: () => STATUS, + render: (assertion: Assertion) => , + }, + ]; + + const data = (assertions || []).map((assertion) => ({ + ...assertion, + key: assertion.urn, + })); + + return ( + + DATA QUALITY + + ( + + )} + /> + + + ); +}; diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/FreshnessContractSummary.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/FreshnessContractSummary.tsx new file mode 100644 index 0000000000000..efd0151b69bc2 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/FreshnessContractSummary.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import styled from 'styled-components'; +import { Divider } from 'antd'; +import { ClockCircleOutlined } from '@ant-design/icons'; +import { FreshnessContract } from '../../../../../../../types.generated'; +import { ANTD_GRAY } from '../../../../constants'; +import { DataContractSummaryFooter } from './DataContractSummaryFooter'; +import { FreshnessScheduleSummary } from './FreshnessScheduleSummary'; + +const Container = styled.div` + padding: 28px; +`; + +const TitleText = styled.div` + color: ${ANTD_GRAY[7]}; + margin-bottom: 20px; + letter-spacing: 1px; +`; + +const ThinDivider = styled(Divider)` + && { + padding: 0px; + margin: 0px; + } +`; + +const Header = styled.div` + color: ${ANTD_GRAY[8]}; + letter-spacing; 4px; + padding-top: 8px; + padding: 12px; + background-color: ${ANTD_GRAY[2]}; +`; + +const Body = styled.div` + padding: 12px; +`; + +const Footer = styled.div` + padding-top: 8px; + padding: 12px; + background-color: ${ANTD_GRAY[2]}; +`; + +const SummaryContainer = styled.div` + width: 100%; + border-radius: 8px; + box-shadow: 0px 0px 4px rgba(0, 0, 0, 0.1); +`; + +type Props = { + contracts: FreshnessContract[]; + showAction?: boolean; +}; + +export const FreshnessContractSummary = ({ contracts, showAction = false }: Props) => { + // TODO: Support multiple per-asset contracts. + const firstContract = (contracts.length && contracts[0]) || undefined; + const assertionDefinition = firstContract?.assertion?.info?.freshnessAssertion?.schedule; + const evaluationSchedule = (firstContract?.assertion as any)?.monitor?.relationships[0]?.entity?.info + ?.assertionMonitor?.assertions[0]?.schedule; + + return ( + + FRESHNESS + +
+ + UPDATE FREQUENCY +
+ + {!assertionDefinition && <>No contract found :(} + + {assertionDefinition && ( + + )} + + + +
+ +
+
+
+ ); +}; diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/FreshnessScheduleSummary.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/FreshnessScheduleSummary.tsx new file mode 100644 index 0000000000000..434ccb985574f --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/FreshnessScheduleSummary.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import cronstrue from 'cronstrue'; +import { + FreshnessAssertionSchedule, + FreshnessAssertionScheduleType, + CronSchedule, +} from '../../../../../../../types.generated'; +import { capitalizeFirstLetter } from '../../../../../../shared/textUtil'; + +type Props = { + definition: FreshnessAssertionSchedule; + evaluationSchedule?: CronSchedule; // When the assertion is run. +}; + +export const FreshnessScheduleSummary = ({ definition, evaluationSchedule }: Props) => { + const scheduleText = + definition.type === FreshnessAssertionScheduleType.Cron + ? `${capitalizeFirstLetter(cronstrue.toString(definition.cron?.cron as string))}.` + : `In the past ${ + definition.fixedInterval?.multiple + } ${definition.fixedInterval?.unit.toLocaleLowerCase()}s${ + (evaluationSchedule && + `, as of ${cronstrue.toString(evaluationSchedule.cron as string).toLowerCase()}`) || + '' + }`; + + return <>{scheduleText}; +}; diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/SchemaContractSummary.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/SchemaContractSummary.tsx new file mode 100644 index 0000000000000..7313a1064634c --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/SchemaContractSummary.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import styled from 'styled-components'; +import { Table } from 'antd'; +import { SchemaContract } from '../../../../../../../types.generated'; +import { ANTD_GRAY } from '../../../../constants'; +import { DataContractSummaryFooter } from './DataContractSummaryFooter'; + +const TitleText = styled.div` + color: ${ANTD_GRAY[7]}; + margin-bottom: 20px; + letter-spacing: 1px; +`; + +const ColumnHeader = styled.div` + color: ${ANTD_GRAY[8]}; +`; + +const Container = styled.div` + padding: 28px; +`; + +const SummaryContainer = styled.div` + width: 100%; + display: flex; + align-items: center; +`; + +const StyledTable = styled(Table)` + width: 100%; + border-radius: 8px; + box-shadow: 0px 0px 4px rgba(0, 0, 0, 0.1); + height: 100%; +`; + +type Props = { + contracts: SchemaContract[]; + showAction?: boolean; +}; + +export const SchemaContractSummary = ({ contracts, showAction = false }: Props) => { + const firstContract = (contracts.length && contracts[0]) || undefined; + const schemaMetadata = firstContract?.assertion?.info?.schemaAssertion?.schema; + + const columns = [ + { + title: () => NAME, + render: (field) => <>{field.fieldPath}, + }, + { + title: () => TYPE, + render: (field) => <>{field.type}, + }, + ]; + + const data = (schemaMetadata?.fields || []).map((field) => ({ + ...field, + key: field.fieldPath, + })); + + return ( + + SCHEMA + + ( + + )} + /> + + + ); +}; diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/builder/DataContractAssertionGroupSelect.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/builder/DataContractAssertionGroupSelect.tsx new file mode 100644 index 0000000000000..f96149dd0be5e --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/builder/DataContractAssertionGroupSelect.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import styled from 'styled-components'; +import { Assertion } from '../../../../../../../../types.generated'; +import { ANTD_GRAY } from '../../../../../constants'; +import { DataContractCategoryType } from './types'; +import { DatasetAssertionsList } from '../../DatasetAssertionsList'; + +const Category = styled.div` + padding: 20px; + font-weight: bold; + font-size: 14px; + background-color: ${ANTD_GRAY[3]}; + border-radius: 4px; +`; + +const Hint = styled.span` + font-weight: normal; + font-size: 14px; + color: ${ANTD_GRAY[8]}; +`; + +type Props = { + category: DataContractCategoryType; + multiple?: boolean; + assertions: Assertion[]; + selectedUrns: string[]; + onSelect: (assertionUrn: string) => void; +}; + +/** + * Used for selecting the assertions that are part of a data contract + */ +export const DataContractAssertionGroupSelect = ({ + category, + assertions, + multiple = true, + selectedUrns, + onSelect, +}: Props) => { + return ( + <> + + {category} {!multiple ? `(Choose 1)` : ''} + + + + ); +}; diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/builder/DataContractBuilder.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/builder/DataContractBuilder.tsx new file mode 100644 index 0000000000000..0e6aef52a1cb7 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/builder/DataContractBuilder.tsx @@ -0,0 +1,176 @@ +import React, { useState } from 'react'; +import { message, Button } from 'antd'; +import styled from 'styled-components'; +import lodash from 'lodash'; +import { DataContract, AssertionType, Assertion } from '../../../../../../../../types.generated'; +import { DataContractBuilderState, DataContractCategoryType, DEFAULT_BUILDER_STATE } from './types'; +import { buildUpsertDataContractMutationVariables } from './utils'; +import { useUpsertDataContractMutation } from '../../../../../../../../graphql/contract.generated'; +import { createAssertionGroups } from '../../utils'; +import { DataContractAssertionGroupSelect } from './DataContractAssertionGroupSelect'; +import { ANTD_GRAY } from '../../../../../constants'; +import { DATA_QUALITY_ASSERTION_TYPES } from '../utils'; +import { useGetDatasetAssertionsQuery } from '../../../../../../../../graphql/dataset.generated'; + +const AssertionsSection = styled.div` + border: 0.5px solid ${ANTD_GRAY[4]}; +`; + +const HeaderText = styled.div` + padding: 16px 20px; + color: ${ANTD_GRAY[7]}; + font-size: 16px; +`; + +const ActionContainer = styled.div` + display: flex; + justify-content: space-between; + margin-top: 16px; +`; + +const CancelButton = styled(Button)` + margin-left: 12px; +`; + +const SaveButton = styled(Button)` + margin-right: 20px; +`; + +type Props = { + entityUrn: string; + initialState?: DataContractBuilderState; + onSubmit?: (contract: DataContract) => void; + onCancel?: () => void; +}; + +/** + * This component is a modal used for constructing new Data Contracts + * + * In order to build a data contract, we simply list all dataset assertions and allow the user to choose. + */ +export const DataContractBuilder = ({ entityUrn, initialState, onSubmit, onCancel }: Props) => { + const isEdit = !!initialState; + const [builderState, setBuilderState] = useState(initialState || DEFAULT_BUILDER_STATE); + const [upsertDataContractMutation] = useUpsertDataContractMutation(); + + // note that for contracts, we do not allow the use of sibling node assertions, for clarity. + const { data } = useGetDatasetAssertionsQuery({ + variables: { urn: entityUrn }, + fetchPolicy: 'cache-first', + }); + const assertionData = data?.dataset?.assertions?.assertions ?? []; + + const assertionGroups = createAssertionGroups(assertionData as Array); + const freshnessAssertions = + assertionGroups.find((group) => group.type === AssertionType.Freshness)?.assertions || []; + const schemaAssertions = assertionGroups.find((group) => group.type === AssertionType.DataSchema)?.assertions || []; + const dataQualityAssertions = assertionGroups + .filter((group) => DATA_QUALITY_ASSERTION_TYPES.has(group.type)) + .flatMap((group) => group.assertions || []); + + /** + * Upserts the Data Contract for an entity + */ + const upsertDataContract = () => { + return upsertDataContractMutation({ + variables: buildUpsertDataContractMutationVariables(entityUrn, builderState), + }) + .then(({ data: dataContract, errors }) => { + if (!errors) { + message.success({ + content: isEdit ? `Edited Data Contract` : `Created Data Contract!`, + duration: 3, + }); + onSubmit?.(dataContract?.upsertDataContract as DataContract); + } + }) + .catch(() => { + message.destroy(); + message.error({ content: 'Failed to create Data Contract! An unexpected error occurred' }); + }); + }; + + const onSelectDataAssertion = (assertionUrn: string, type: string) => { + const selected = builderState[type]?.some((c) => c.assertionUrn === assertionUrn); + if (selected) { + setBuilderState({ + ...builderState, + [type]: builderState[type]?.filter((c) => c.assertionUrn !== assertionUrn), + }); + } else { + setBuilderState({ + ...builderState, + [type]: [...(builderState[type] || []), { assertionUrn }], + }); + } + }; + + const editDisabled = + lodash.isEqual(builderState, initialState) || lodash.isEqual(builderState, DEFAULT_BUILDER_STATE); + + const hasAssertions = freshnessAssertions.length || schemaAssertions.length || dataQualityAssertions.length; + + const onSelectFreshnessOrSchemaAssertion = (assertionUrn: string, type: string) => { + const selected = builderState[type]?.assertionUrn === assertionUrn; + if (selected) { + setBuilderState({ + ...builderState, + [type]: undefined, + }); + } else { + setBuilderState({ + ...builderState, + [type]: { assertionUrn }, + }); + } + }; + + return ( + <> + {(hasAssertions && Select the assertions that will make up your contract.) || ( + Add a few assertions on this entity to create a data contract out of them. + )} + + {(freshnessAssertions.length && ( + onSelectFreshnessOrSchemaAssertion(selectedUrn, 'freshness')} + /> + )) || + undefined} + {(schemaAssertions.length && ( + onSelectFreshnessOrSchemaAssertion(selectedUrn, 'schema')} + /> + )) || + undefined} + {(dataQualityAssertions.length && ( + c.assertionUrn) || []} + onSelect={(selectedUrn: string) => onSelectDataAssertion(selectedUrn, 'dataQuality')} + /> + )) || + undefined} + + + Cancel +
+ + Save + +
+
+ + ); +}; diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/builder/DataContractBuilderModal.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/builder/DataContractBuilderModal.tsx new file mode 100644 index 0000000000000..75a8fe0410918 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/builder/DataContractBuilderModal.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { Modal, Typography } from 'antd'; +import { DataContract } from '../../../../../../../../types.generated'; +import ClickOutside from '../../../../../../../shared/ClickOutside'; +import { DataContractBuilderState } from './types'; +import { DataContractBuilder } from './DataContractBuilder'; + +const modalStyle = {}; +const modalBodyStyle = { + paddingRight: 0, + paddingLeft: 0, + paddingBottom: 20, + paddingTop: 0, + maxHeight: '70vh', + 'overflow-x': 'auto', +}; + +type Props = { + entityUrn: string; + initialState?: DataContractBuilderState; + onSubmit?: (contract: DataContract) => void; + onCancel?: () => void; +}; + +/** + * This component is a modal used for constructing new Data Contracts + */ +export const DataContractBuilderModal = ({ entityUrn, initialState, onSubmit, onCancel }: Props) => { + const isEditing = initialState !== undefined; + const titleText = isEditing ? 'Edit Data Contract' : 'New Data Contract'; + + const modalClosePopup = () => { + Modal.confirm({ + title: 'Exit Editor', + content: `Are you sure you want to exit the editor? All changes will be lost`, + onOk() { + onCancel?.(); + }, + onCancel() {}, + okText: 'Yes', + maskClosable: true, + closable: true, + }); + }; + + return ( + + {titleText}} + style={modalStyle} + bodyStyle={modalBodyStyle} + visible + onCancel={onCancel} + > + + + + ); +}; diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/builder/types.ts b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/builder/types.ts new file mode 100644 index 0000000000000..d527837efd72e --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/builder/types.ts @@ -0,0 +1,37 @@ +/** + * The Data Contract Builder state + */ +export type DataContractBuilderState = { + /** + * The schema contract. In the UI, we only support defining a single schema contract. + */ + schema?: { + assertionUrn: string; + }; + + /** + * The freshness contract. In the UI, we only support defining a single freshness contract. + */ + freshness?: { + assertionUrn: string; + }; + + /** + * Data Quality contract. We cane define multiple data quality rules as part of the contract. + */ + dataQuality?: { + assertionUrn: string; + }[]; +}; + +export const DEFAULT_BUILDER_STATE = { + dataQuality: undefined, + schema: undefined, + freshness: undefined, +}; + +export enum DataContractCategoryType { + FRESHNESS = 'Freshness', + SCHEMA = 'Schema', + DATA_QUALITY = 'Data Quality', +} diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/builder/utils.ts b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/builder/utils.ts new file mode 100644 index 0000000000000..da2ae66d1ec9c --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/builder/utils.ts @@ -0,0 +1,120 @@ +import { DataContract } from '../../../../../../../../types.generated'; +import { DataContractBuilderState, DataContractCategoryType } from './types'; + +/** + * Creates a builder state instance from a Data Contract object. + */ +export const createBuilderState = (contract?: DataContract | null): DataContractBuilderState | undefined => { + if (contract) { + return { + schema: + (contract?.properties?.schema?.length && { + assertionUrn: contract?.properties?.schema[0].assertion.urn, + }) || + undefined, + freshness: + (contract?.properties?.freshness?.length && { + assertionUrn: contract?.properties?.freshness[0].assertion.urn, + }) || + undefined, + dataQuality: + contract?.properties?.dataQuality?.map((c) => ({ assertionUrn: c.assertion.urn })) || undefined, + }; + } + return undefined; +}; + +/** + * Constructs the input variables required for upserting a data contract using graphql + */ +export const buildUpsertDataContractMutationVariables = (entityUrn: string, state: DataContractBuilderState) => { + return { + input: { + entityUrn, + freshness: (state.freshness && [state.freshness]) || [], + schema: (state.schema && [state.schema]) || [], + dataQuality: state.dataQuality || [], + }, + }; +}; + +/** + * Constructs the input variables required for removing an assertion from a data contract using graphql. + */ +export const buildRemoveAssertionFromContractMutationVariables = ( + entityUrn: string, + assertionUrn: string, + contract?: DataContract, +) => { + return { + input: { + entityUrn, + freshness: contract?.properties?.freshness + ?.filter((c) => c.assertion.urn !== assertionUrn) + ?.map((c) => ({ + assertionUrn: c.assertion.urn, + })), + schema: contract?.properties?.schema + ?.filter((c) => c.assertion.urn !== assertionUrn) + ?.map((c) => ({ + assertionUrn: c.assertion.urn, + })), + dataQuality: contract?.properties?.dataQuality + ?.filter((c) => c.assertion.urn !== assertionUrn) + ?.map((c) => ({ + assertionUrn: c.assertion.urn, + })), + }, + }; +}; + +/** + * Constructs the input variables required for adding an assertion to a data contract using graphql. + */ +export const buildAddAssertionToContractMutationVariables = ( + category: DataContractCategoryType, + entityUrn: string, + assertionUrn: string, + contract?: DataContract, +) => { + const baseInput = { + entityUrn, + freshness: contract?.properties?.freshness?.map((c) => ({ + assertionUrn: c.assertion.urn, + })), + schema: contract?.properties?.schema?.map((c) => ({ + assertionUrn: c.assertion.urn, + })), + dataQuality: contract?.properties?.dataQuality?.map((c) => ({ + assertionUrn: c.assertion.urn, + })), + }; + + switch (category) { + case DataContractCategoryType.SCHEMA: + // Replace schema assertion. We only support 1 schema assertion at a time (currently). + return { + input: { + ...baseInput, + schema: [{ assertionUrn }], + }, + }; + case DataContractCategoryType.FRESHNESS: + // Replace freshness assertion. We only support 1 freshness assertion at a time (currently). + return { + input: { + ...baseInput, + freshness: [{ assertionUrn }], + }, + }; + case DataContractCategoryType.DATA_QUALITY: + return { + input: { + ...baseInput, + dataQuality: [...(baseInput.dataQuality || []), { assertionUrn }], + }, + }; + default: + throw new Error(`Unrecognized category type ${category} provided.`); + } +}; diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/utils.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/utils.tsx new file mode 100644 index 0000000000000..cc2e1bb7b386e --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/contract/utils.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { + CheckOutlined, + ClockCircleOutlined, + CloseOutlined, + ExclamationCircleFilled, + StopOutlined, +} from '@ant-design/icons'; +import { Assertion, AssertionType, DataContract, DataContractState } from '../../../../../../../types.generated'; +import { ANTD_GRAY } from '../../../../constants'; +import { FAILURE_COLOR_HEX, SUCCESS_COLOR_HEX, WARNING_COLOR_HEX } from '../utils'; +import { DataContractCategoryType } from './builder/types'; +import { AssertionStatusSummary } from '../types'; + +export const getContractSummaryIcon = (state: DataContractState, summary: AssertionStatusSummary) => { + if (state === DataContractState.Pending) { + return ; + } + if (summary.total === 0) { + return ; + } + if (summary.passing === summary.total) { + return ; + } + if (summary.failing > 0) { + return ; + } + if (summary.erroring > 0) { + return ; + } + return ; +}; + +export const getContractSummaryTitle = (state: DataContractState, summary: AssertionStatusSummary) => { + if (state === DataContractState.Pending) { + return 'This contract is pending implementation'; + } + if (summary.total === 0) { + return 'This contract has not yet been validated'; + } + if (summary.passing === summary.total) { + return 'This dataset is meeting its contract'; + } + if (summary.failing > 0) { + return 'This dataset is not meeting its contract'; + } + if (summary.erroring > 0) { + return 'Unable to determine contract status'; + } + return 'This contract has not yet been validated'; +}; + +export const getContractSummaryMessage = (state: DataContractState, summary: AssertionStatusSummary) => { + if (state === DataContractState.Pending) { + return 'This may take some time. Come back later!'; + } + if (summary.total === 0) { + return 'No contract assertions have been run yet'; + } + if (summary.passing === summary.total) { + return 'All contract assertions are passing'; + } + if (summary.failing > 0) { + return 'Some contract assertions are failing'; + } + if (summary.erroring > 0) { + return 'Some contract assertions are completing with errors'; + } + return 'No contract assertions have been run yet'; +}; + +/** + * Returns true if a given assertion is part of a given contract, false otherwise. + */ +export const isAssertionPartOfContract = (assertion: Assertion, contract: DataContract) => { + if (contract.properties?.dataQuality?.some((c) => c.assertion.urn === assertion.urn)) { + return true; + } + if (contract.properties?.schema?.some((c) => c.assertion.urn === assertion.urn)) { + return true; + } + if (contract.properties?.freshness?.some((c) => c.assertion.urn === assertion.urn)) { + return true; + } + return false; +}; + +/** + * Retrieves the high level contract category - schema, freshness, or data quality - given an assertion + */ +export const getDataContractCategoryFromAssertion = (assertion: Assertion) => { + if ( + assertion.info?.type === AssertionType.Dataset || + assertion.info?.type === AssertionType.Volume || + assertion.info?.type === AssertionType.Field || + assertion.info?.type === AssertionType.Sql + ) { + return DataContractCategoryType.DATA_QUALITY; + } + if (assertion.info?.type === AssertionType.Freshness) { + return DataContractCategoryType.FRESHNESS; + } + if (assertion.info?.type === AssertionType.DataSchema) { + return DataContractCategoryType.SCHEMA; + } + return DataContractCategoryType.DATA_QUALITY; +}; + +export const DATA_QUALITY_ASSERTION_TYPES = new Set([ + AssertionType.Volume, + AssertionType.Sql, + AssertionType.Field, + AssertionType.Dataset, +]); diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/fieldDescriptionUtils.ts b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/fieldDescriptionUtils.ts new file mode 100644 index 0000000000000..3c6e14f1d80ab --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/fieldDescriptionUtils.ts @@ -0,0 +1,179 @@ +import { + AssertionStdOperator, + AssertionStdParameters, + FieldAssertionInfo, + FieldAssertionType, + FieldMetricType, + FieldTransformType, +} from '../../../../../../types.generated'; +import { formatNumberWithoutAbbreviation } from '../../../../../shared/formatNumber'; +import { parseMaybeStringAsFloatOrDefault } from '../../../../../shared/numberUtil'; + +const ASSERTION_OPERATOR_TO_DESCRIPTION: Record = { + [AssertionStdOperator.EqualTo]: 'Is equal to', + [AssertionStdOperator.NotEqualTo]: 'Is not equal to', + [AssertionStdOperator.Contain]: 'Contains', + [AssertionStdOperator.RegexMatch]: 'Matches', + [AssertionStdOperator.StartWith]: 'Starts with', + [AssertionStdOperator.EndWith]: 'Ends with', + [AssertionStdOperator.In]: 'Is in', + [AssertionStdOperator.NotIn]: 'Is not in', + + [AssertionStdOperator.IsFalse]: 'Is False', + [AssertionStdOperator.IsTrue]: 'Is True', + [AssertionStdOperator.Null]: 'Is NULL', + [AssertionStdOperator.NotNull]: 'Is not NULL', + + [AssertionStdOperator.GreaterThan]: 'Is greater than', + [AssertionStdOperator.GreaterThanOrEqualTo]: 'Is greater than or equal to', + [AssertionStdOperator.LessThan]: 'Is less than', + [AssertionStdOperator.LessThanOrEqualTo]: 'Is less than or equal to', + [AssertionStdOperator.Between]: 'Is within a range', + + [AssertionStdOperator.Native]: undefined, +}; + +const SUPPORTED_OPERATORS_FOR_FIELD_DESCRIPTION = [ + AssertionStdOperator.EqualTo, + AssertionStdOperator.Null, + AssertionStdOperator.NotNull, + AssertionStdOperator.NotEqualTo, + AssertionStdOperator.NotIn, + AssertionStdOperator.RegexMatch, + AssertionStdOperator.GreaterThan, + AssertionStdOperator.LessThan, + AssertionStdOperator.GreaterThanOrEqualTo, + AssertionStdOperator.LessThanOrEqualTo, + AssertionStdOperator.In, + AssertionStdOperator.Between, + AssertionStdOperator.Contain, + AssertionStdOperator.IsTrue, + AssertionStdOperator.IsFalse, +]; + +const getAssertionStdOperator = (operator: AssertionStdOperator) => { + if (!ASSERTION_OPERATOR_TO_DESCRIPTION[operator] || !SUPPORTED_OPERATORS_FOR_FIELD_DESCRIPTION.includes(operator)) { + throw new Error(`Unknown operator ${operator}`); + } + return ASSERTION_OPERATOR_TO_DESCRIPTION[operator]?.toLowerCase(); +}; + +export const getFieldMetricTypeReadableLabel = (metric: FieldMetricType) => { + switch (metric) { + case FieldMetricType.NullCount: + return 'Null count'; + case FieldMetricType.NullPercentage: + return 'Null percentage'; + case FieldMetricType.UniqueCount: + return 'Unique count'; + case FieldMetricType.UniquePercentage: + return 'Unique percentage'; + case FieldMetricType.MaxLength: + return 'Max length'; + case FieldMetricType.MinLength: + return 'Min length'; + case FieldMetricType.EmptyCount: + return 'Empty count'; + case FieldMetricType.EmptyPercentage: + return 'Empty percentage'; + case FieldMetricType.Max: + return 'Max'; + case FieldMetricType.Min: + return 'Min'; + case FieldMetricType.Mean: + return 'Average'; + case FieldMetricType.Median: + return 'Median'; + case FieldMetricType.NegativeCount: + return 'Negative count'; + case FieldMetricType.NegativePercentage: + return 'Negative percentage'; + case FieldMetricType.Stddev: + return 'Standard deviation'; + case FieldMetricType.ZeroCount: + return 'Zero count'; + case FieldMetricType.ZeroPercentage: + return 'Zero percentage'; + default: + throw new Error(`Unknown field metric type ${metric}`); + } +}; + +const getFieldTransformType = (transform: FieldTransformType) => { + switch (transform) { + case FieldTransformType.Length: + return 'Length'; + default: + throw new Error(`Unknown field transform type ${transform}`); + } +}; + +const getAssertionStdParameters = (parameters: AssertionStdParameters) => { + if (parameters.value) { + return formatNumberWithoutAbbreviation( + parseMaybeStringAsFloatOrDefault(parameters.value.value, parameters.value.value), + ); + } + if (parameters.minValue && parameters.maxValue) { + return `${formatNumberWithoutAbbreviation( + parseMaybeStringAsFloatOrDefault(parameters.minValue.value, parameters.minValue.value), + )} and ${formatNumberWithoutAbbreviation( + parseMaybeStringAsFloatOrDefault(parameters.maxValue.value, parameters.maxValue.value), + )}`; + } + return ''; +}; + +export const getFieldDescription = (assertionInfo: FieldAssertionInfo) => { + const { type, fieldValuesAssertion, fieldMetricAssertion } = assertionInfo; + switch (type) { + case FieldAssertionType.FieldValues: + return fieldValuesAssertion?.field?.path; + case FieldAssertionType.FieldMetric: + return fieldMetricAssertion?.field?.path; + default: + throw new Error(`Unknown field assertion type ${type}`); + } +}; + +export const getFieldOperatorDescription = (assertionInfo: FieldAssertionInfo) => { + const { type, fieldValuesAssertion, fieldMetricAssertion } = assertionInfo; + switch (type) { + case FieldAssertionType.FieldValues: + if (!fieldValuesAssertion?.operator) return ''; + return getAssertionStdOperator(fieldValuesAssertion.operator); + case FieldAssertionType.FieldMetric: + if (!fieldMetricAssertion?.operator) return ''; + return getAssertionStdOperator(fieldMetricAssertion.operator); + default: + throw new Error(`Unknown field assertion type ${type}`); + } +}; + +export const getFieldTransformDescription = (assertionInfo: FieldAssertionInfo) => { + const { type, fieldValuesAssertion, fieldMetricAssertion } = assertionInfo; + switch (type) { + case FieldAssertionType.FieldValues: + if (!fieldValuesAssertion?.transform?.type) return ''; + return getFieldTransformType(fieldValuesAssertion.transform.type); + case FieldAssertionType.FieldMetric: + if (!fieldMetricAssertion?.metric) return ''; + return getFieldMetricTypeReadableLabel(fieldMetricAssertion.metric); + default: + throw new Error(`Unknown field assertion type ${type}`); + } +}; + +export const getFieldParametersDescription = (assertionInfo: FieldAssertionInfo) => { + const { type, fieldValuesAssertion, fieldMetricAssertion } = assertionInfo; + switch (type) { + case FieldAssertionType.FieldValues: + if (!fieldValuesAssertion?.parameters) return ''; + return getAssertionStdParameters(fieldValuesAssertion.parameters); + case FieldAssertionType.FieldMetric: + if (!fieldMetricAssertion?.parameters) return ''; + return getAssertionStdParameters(fieldMetricAssertion.parameters); + default: + throw new Error(`Unknown field assertion type ${type}`); + } +}; diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/shared/styledComponents.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/shared/styledComponents.tsx new file mode 100644 index 0000000000000..14651899a355f --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/shared/styledComponents.tsx @@ -0,0 +1,32 @@ +import styled from 'styled-components'; +import { CheckOutlined, ClockCircleOutlined, CloseOutlined, ExclamationCircleOutlined } from '@ant-design/icons'; +import { ANTD_GRAY } from '../../../../constants'; +import { FAILURE_COLOR_HEX, SUCCESS_COLOR_HEX, WARNING_COLOR_HEX } from '../utils'; + +export const StyledCheckOutlined = styled(CheckOutlined)` + color: ${SUCCESS_COLOR_HEX}; + font-size: 16px; + margin-right: 4px; + margin-left: 4px; +`; + +export const StyledCloseOutlined = styled(CloseOutlined)` + color: ${FAILURE_COLOR_HEX}; + font-size: 16px; + margin-right: 4px; + margin-left: 4px; +`; + +export const StyledExclamationOutlined = styled(ExclamationCircleOutlined)` + color: ${WARNING_COLOR_HEX}; + font-size: 16px; + margin-right: 4px; + margin-left: 4px; +`; + +export const StyledClockCircleOutlined = styled(ClockCircleOutlined)` + color: ${ANTD_GRAY[6]}; + font-size: 16px; + margin-right: 4px; + margin-left: 4px; +`; diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/types.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/types.tsx new file mode 100644 index 0000000000000..8a70a3d87c147 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/types.tsx @@ -0,0 +1,21 @@ +import { Assertion, AssertionType } from '../../../../../../types.generated'; + +export type AssertionStatusSummary = { + passing: number; + failing: number; + erroring: number; + total: number; // Total assertions with at least 1 run. + totalAssertions: number; +}; + +/** + * A group of assertions related by their logical type or category. + */ +export type AssertionGroup = { + name: string; + icon: React.ReactNode; + description?: string; + assertions: Assertion[]; + summary: AssertionStatusSummary; + type: AssertionType; +}; diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/utils.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/utils.tsx new file mode 100644 index 0000000000000..019271b4732a1 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/utils.tsx @@ -0,0 +1,378 @@ +import React from 'react'; +import styled from 'styled-components'; +import { + ClockCircleOutlined, + TableOutlined, + ProjectOutlined, + ConsoleSqlOutlined, + CheckOutlined, + CloseOutlined, + ApiOutlined, + CodeOutlined, + ExclamationCircleOutlined, +} from '@ant-design/icons'; +import { + Assertion, + AssertionResultType, + AssertionStdOperator, + AssertionStdParameters, + AssertionType, + AssertionValueChangeType, + EntityType, + VolumeAssertionInfo, + VolumeAssertionType, +} from '../../../../../../types.generated'; +import { sortAssertions } from './assertionUtils'; +import { AssertionGroup, AssertionStatusSummary } from './types'; +import { formatNumberWithoutAbbreviation } from '../../../../../shared/formatNumber'; +import { parseMaybeStringAsFloatOrDefault } from '../../../../../shared/numberUtil'; + +export const SUCCESS_COLOR_HEX = '#52C41A'; +export const FAILURE_COLOR_HEX = '#F5222D'; +export const WARNING_COLOR_HEX = '#FA8C16'; + +const StyledClockCircleOutlined = styled(ClockCircleOutlined)` + && { + margin: 0px; + padding: 0px; + margin-right: 8px; + font-size: 14px; + } +`; + +const StyledTableOutlined = styled(TableOutlined)` + && { + margin: 0px; + padding: 0px; + margin-right: 8px; + font-size: 18px; + } +`; + +const StyledProjectOutlined = styled(ProjectOutlined)` + && { + margin: 0px; + padding: 0px; + margin-right: 8px; + font-size: 18px; + } +`; + +const StyledConsoleSqlOutlined = styled(ConsoleSqlOutlined)` + && { + margin: 0px; + padding: 0px; + margin-right: 8px; + font-size: 18px; + } +`; + +const StyledApiOutlined = styled(ApiOutlined)` + && { + margin: 0px; + padding: 0px; + margin-right: 8px; + font-size: 18px; + } +`; + +const StyledCheckOutlined = styled(CheckOutlined)` + && { + color: ${SUCCESS_COLOR_HEX}; + font-size: 14px; + padding: 0px; + margin: 0px; + } +`; + +const StyledCloseOutlined = styled(CloseOutlined)` + && { + color: ${FAILURE_COLOR_HEX}; + font-size: 14px; + padding: 0px; + margin: 0px; + } +`; + +const StyledExclamationOutlined = styled(ExclamationCircleOutlined)` + && { + color: ${WARNING_COLOR_HEX}; + font-size: 14px; + padding: 0px; + margin: 0px; + } +`; + +const StyledCodeOutlined = styled(CodeOutlined)` + && { + margin: 0px; + padding: 0px; + margin-right: 8px; + font-size: 18px; + } +`; + +export const ASSERTION_INFO = [ + { + name: 'Freshness', + description: 'Define & monitor your expectations about when this dataset should be updated', + icon: , + type: AssertionType.Freshness, + entityTypes: [EntityType.Dataset], + enabled: true, + visible: true, + }, + { + name: 'Volume', + description: 'Define & monitor your expectations about the size of this dataset', + icon: , + type: AssertionType.Volume, + entityTypes: [EntityType.Dataset], + enabled: true, + visible: true, + }, + { + name: 'Column', + description: 'Define & monitor your expectations about the values in a column', + icon: , + type: AssertionType.Field, + entityTypes: [EntityType.Dataset], + enabled: true, + visible: true, + requiresConnectionSupportedByMonitors: false, + }, + { + name: 'Schema', + description: "Define & monitor your expectations about the table's columns and their types", + icon: , + type: AssertionType.DataSchema, + entityTypes: [EntityType.Dataset], + enabled: true, + visible: true, + }, + { + name: 'Custom', + description: 'Define & monitor your expectations using custom SQL rules', + icon: , + type: AssertionType.Sql, + entityTypes: [EntityType.Dataset], + enabled: true, + visible: true, + requiresConnectionSupportedByMonitors: true, + }, + { + name: 'Other', + description: 'Assertions that are defined and maintained outside of DataHub.', + icon: , + type: AssertionType.Dataset, + entityTypes: [EntityType.Dataset], + enabled: false, + visible: false, + }, +]; + +const ASSERTION_TYPE_TO_INFO = new Map(); +ASSERTION_INFO.forEach((info) => { + ASSERTION_TYPE_TO_INFO.set(info.type, info); +}); + +const getAssertionGroupName = (type: AssertionType): string => { + return ASSERTION_TYPE_TO_INFO.has(type) ? ASSERTION_TYPE_TO_INFO.get(type).name : 'Unknown'; +}; + +const getAssertionGroupTypeIcon = (type: AssertionType) => { + return ASSERTION_TYPE_TO_INFO.has(type) ? ASSERTION_TYPE_TO_INFO.get(type).icon : undefined; +}; + +/** + * Returns a status summary for the assertions associated with a Dataset. + * + * @param assertions The assertions to extract the summary for + */ +export const getAssertionsSummary = (assertions: Assertion[]): AssertionStatusSummary => { + const summary = { + passing: 0, + failing: 0, + erroring: 0, + total: 0, + totalAssertions: assertions.length, + }; + assertions.forEach((assertion) => { + if ((assertion.runEvents?.runEvents?.length || 0) > 0) { + const mostRecentRun = assertion.runEvents?.runEvents?.[0]; + const resultType = mostRecentRun?.result?.type; + if (AssertionResultType.Success === resultType) { + summary.passing++; + } + if (AssertionResultType.Failure === resultType) { + summary.failing++; + } + if (AssertionResultType.Error === resultType) { + summary.erroring++; + } + if (AssertionResultType.Init !== resultType) { + summary.total++; // only count assertions for which there is one completed run event, ignoring INIT statuses! + } + } + }); + return summary; +}; + +// /** +// * Returns a list of assertion groups, where assertions are grouped +// * by their "type" or "category". Each group includes the assertions inside, along with +// * a summary of passing and failing assertions for the group. +// * +// * @param assertions The assertions to group +// */ +export const createAssertionGroups = (assertions: Array): AssertionGroup[] => { + // Pre-sort the list of assertions based on which has been most recently executed. + const newAssertions = [...assertions].sort(sortAssertions); + + const typeToAssertions = new Map(); + newAssertions + .filter((assertion) => assertion.info?.type) + .forEach((assertion) => { + const groupType = assertion.info?.type; + const groupedAssertions = typeToAssertions.get(groupType) || []; + groupedAssertions.push(assertion); + typeToAssertions.set(groupType, groupedAssertions); + }); + + // Now, create summary for each type and build the AssertionGroup object + const assertionGroups: AssertionGroup[] = []; + typeToAssertions.forEach((groupedAssertions, type) => { + const newGroup: AssertionGroup = { + name: getAssertionGroupName(type), + icon: getAssertionGroupTypeIcon(type), + assertions: groupedAssertions, + summary: getAssertionsSummary(groupedAssertions), + type, + }; + assertionGroups.push(newGroup); + }); + + return assertionGroups; +}; + +export const getAssertionGroupSummaryIcon = (summary: AssertionStatusSummary) => { + if (summary.total === 0) { + return null; + } + if (summary.passing === summary.total) { + return ; + } + if (summary.erroring > 0) { + return ; + } + return ; +}; + +export const getAssertionGroupSummaryMessage = (summary: AssertionStatusSummary) => { + if (summary.total === 0) { + return 'No assertions have run'; + } + if (summary.passing === summary.total) { + return 'All assertions are passing'; + } + if (summary.erroring > 0) { + return 'An error is preventing some assertions from running'; + } + if (summary.failing === summary.total) { + return 'All assertions are failing'; + } + return 'Some assertions are failing'; +}; + +export const getAssertionTypesForEntityType = (entityType: EntityType, monitorsConnectionForEntityExists: boolean) => { + return ASSERTION_INFO.filter((type) => type.entityTypes.includes(entityType)).map((type) => ({ + ...type, + enabled: type.enabled && (!type.requiresConnectionSupportedByMonitors || monitorsConnectionForEntityExists), + })); +}; + +type VolumeTypeField = + | 'rowCountTotal' + | 'rowCountChange' + | 'incrementingSegmentRowCountTotal' + | 'incrementingSegmentRowCountChange'; + +export const getPropertyFromVolumeType = (type: VolumeAssertionType) => { + switch (type) { + case VolumeAssertionType.RowCountTotal: + return 'rowCountTotal' as VolumeTypeField; + case VolumeAssertionType.RowCountChange: + return 'rowCountChange' as VolumeTypeField; + case VolumeAssertionType.IncrementingSegmentRowCountTotal: + return 'incrementingSegmentRowCountTotal' as VolumeTypeField; + case VolumeAssertionType.IncrementingSegmentRowCountChange: + return 'incrementingSegmentRowCountChange' as VolumeTypeField; + default: + throw new Error(`Unknown volume assertion type: ${type}`); + } +}; + +export const getVolumeTypeInfo = (volumeAssertion: VolumeAssertionInfo) => { + const result = volumeAssertion[getPropertyFromVolumeType(volumeAssertion.type)]; + if (!result) { + return undefined; + } + return result; +}; + +export const getIsRowCountChange = (type: VolumeAssertionType) => { + return [VolumeAssertionType.RowCountChange, VolumeAssertionType.IncrementingSegmentRowCountChange].includes(type); +}; + +export const getVolumeTypeDescription = (volumeType: VolumeAssertionType) => { + switch (volumeType) { + case VolumeAssertionType.RowCountTotal: + case VolumeAssertionType.IncrementingSegmentRowCountTotal: + return 'has'; + case VolumeAssertionType.RowCountChange: + case VolumeAssertionType.IncrementingSegmentRowCountChange: + return 'should grow by'; + default: + throw new Error(`Unknown volume type ${volumeType}`); + } +}; + +export const getOperatorDescription = (operator: AssertionStdOperator) => { + switch (operator) { + case AssertionStdOperator.GreaterThanOrEqualTo: + return 'at least'; + case AssertionStdOperator.LessThanOrEqualTo: + return 'at most'; + case AssertionStdOperator.Between: + return 'between'; + default: + throw new Error(`Unknown operator ${operator}`); + } +}; + +export const getValueChangeTypeDescription = (valueChangeType: AssertionValueChangeType) => { + switch (valueChangeType) { + case AssertionValueChangeType.Absolute: + return 'rows'; + case AssertionValueChangeType.Percentage: + return '%'; + default: + throw new Error(`Unknown value change type ${valueChangeType}`); + } +}; + +export const getParameterDescription = (parameters: AssertionStdParameters) => { + if (parameters.value) { + return formatNumberWithoutAbbreviation( + parseMaybeStringAsFloatOrDefault(parameters.value.value, parameters.value.value), + ); + } + if (parameters.minValue && parameters.maxValue) { + return `${formatNumberWithoutAbbreviation( + parseMaybeStringAsFloatOrDefault(parameters.minValue.value, parameters.minValue.value), + )} and ${formatNumberWithoutAbbreviation( + parseMaybeStringAsFloatOrDefault(parameters.maxValue.value, parameters.maxValue.value), + )}`; + } + throw new Error('Invalid assertion parameters provided'); +}; diff --git a/datahub-web-react/src/app/entity/shared/tabs/Documentation/components/editor/EditorTheme.tsx b/datahub-web-react/src/app/entity/shared/tabs/Documentation/components/editor/EditorTheme.tsx index ec094fb84e59a..b3cc3c6a225e6 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/Documentation/components/editor/EditorTheme.tsx +++ b/datahub-web-react/src/app/entity/shared/tabs/Documentation/components/editor/EditorTheme.tsx @@ -81,7 +81,7 @@ export const EditorContainer = styled.div<{ editorStyle?: string }>` line-height: 1.5; white-space: pre-wrap; margin: 0; - ${props => props.editorStyle} + ${(props) => props.editorStyle} a { font-weight: 500; diff --git a/datahub-web-react/src/app/entity/shared/tabs/ERModelRelationship/ERModelRelationshipTab.less b/datahub-web-react/src/app/entity/shared/tabs/ERModelRelationship/ERModelRelationshipTab.less index bb8f2f04f6a37..897dbf251330b 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/ERModelRelationship/ERModelRelationshipTab.less +++ b/datahub-web-react/src/app/entity/shared/tabs/ERModelRelationship/ERModelRelationshipTab.less @@ -1,4 +1,4 @@ -@import "../../../../../../node_modules/antd/dist/antd.less"; +@import '../../../../../../node_modules/antd/dist/antd.less'; .ERModelRelationTab { .add-btn-link { @@ -12,19 +12,17 @@ font-size: 12px !important; line-height: 20px !important; color: #262626 !important; - } - .thin-divider{ + .thin-divider { width: 100%; - height:0px; - border:1px solid rgba(0,0,0,0.12) !important; - flex:none; - order:5; - align-self:stretch; - flex-grow:0; + height: 0px; + border: 1px solid rgba(0, 0, 0, 0.12) !important; + flex: none; + order: 5; + align-self: stretch; + flex-grow: 0; } .search-header-div { - padding: 8px 20px; gap: 238px; @@ -34,11 +32,11 @@ width: 1292px !important; height: 56px !important; - background: #FFFFFF; + background: #ffffff; box-shadow: 0px 2px 6px rgba(0, 0, 0, 0.05); } .ermodelrelation-preview-div { padding-left: 22px; padding-top: 34px; } -} \ No newline at end of file +} diff --git a/datahub-web-react/src/app/entity/shared/tabs/ERModelRelationship/ERModelRelationshipTab.tsx b/datahub-web-react/src/app/entity/shared/tabs/ERModelRelationship/ERModelRelationshipTab.tsx index 946bf429d3e87..e4869356f7661 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/ERModelRelationship/ERModelRelationshipTab.tsx +++ b/datahub-web-react/src/app/entity/shared/tabs/ERModelRelationship/ERModelRelationshipTab.tsx @@ -8,7 +8,13 @@ export const ERModelRelationshipTab = () => { const { entityData } = useEntityData(); const refetch = useRefetch(); const ermodelrelationView = (ermodelrelationData?: any): JSX.Element => { - return ; + return ( + + ); }; return ( <> diff --git a/datahub-web-react/src/app/entity/shared/tabs/Incident/components/IncidentListItem.tsx b/datahub-web-react/src/app/entity/shared/tabs/Incident/components/IncidentListItem.tsx index 2fef9de5c3166..4c9f83ab76621 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/Incident/components/IncidentListItem.tsx +++ b/datahub-web-react/src/app/entity/shared/tabs/Incident/components/IncidentListItem.tsx @@ -230,13 +230,14 @@ export default function IncidentListItem({ incident, refetch }: Props) { Description {incident?.description} - {incident.status.state === IncidentState.Resolved ? + {incident.status.state === IncidentState.Resolved ? ( <> Resolution Note - {incident?.status.message || 'No additional details'} + + {incident?.status.message || 'No additional details'} + - : null - } + ) : null} diff --git a/datahub-web-react/src/app/entity/shared/tabs/Lineage/ImpactAnalysis.tsx b/datahub-web-react/src/app/entity/shared/tabs/Lineage/ImpactAnalysis.tsx index 4f1c5bb98807d..7eeff26cc815c 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/Lineage/ImpactAnalysis.tsx +++ b/datahub-web-react/src/app/entity/shared/tabs/Lineage/ImpactAnalysis.tsx @@ -27,7 +27,7 @@ export const ImpactAnalysis = ({ setSkipCache, resetShouldRefetch, onLineageClick, - isLineageTab + isLineageTab, }: Props) => { const finalStartTimeMillis = startTimeMillis || undefined; const finalEndTimeMillis = endTimeMillis || undefined; diff --git a/datahub-web-react/src/app/entity/user/UserInfoSideBar.tsx b/datahub-web-react/src/app/entity/user/UserInfoSideBar.tsx index 71bfbfcd49a16..4e3dc45489b55 100644 --- a/datahub-web-react/src/app/entity/user/UserInfoSideBar.tsx +++ b/datahub-web-react/src/app/entity/user/UserInfoSideBar.tsx @@ -62,17 +62,18 @@ export default function UserInfoSideBar({ sideBarData, refetch }: Props) { const me = useUserContext(); const isProfileOwner = me?.user?.urn === urn; - const { updateTitle } = useBrowserTitle(); - - useEffect(()=>{ + const { updateTitle } = useBrowserTitle(); + + useEffect(() => { // You can use the title and updateTitle function here // For example, updating the title when the component mounts - if(name){ + if (name) { updateTitle(`User | ${name}`); } // // Don't forget to clean up the title when the component unmounts return () => { - if(name){ // added to condition for rerendering issue + if (name) { + // added to condition for rerendering issue updateTitle(''); } }; diff --git a/datahub-web-react/src/app/entity/user/UserProfile.tsx b/datahub-web-react/src/app/entity/user/UserProfile.tsx index ffbfbeb977527..2d6efb95b9d2a 100644 --- a/datahub-web-react/src/app/entity/user/UserProfile.tsx +++ b/datahub-web-react/src/app/entity/user/UserProfile.tsx @@ -119,7 +119,7 @@ export default function UserProfile() { if (data?.corpUser?.exists === false) { return ; } - + return ( <> {error && } diff --git a/datahub-web-react/src/app/glossary/GlossaryBrowser/NodeItem.tsx b/datahub-web-react/src/app/glossary/GlossaryBrowser/NodeItem.tsx index cd6593e2d2f07..af3c7454425de 100644 --- a/datahub-web-react/src/app/glossary/GlossaryBrowser/NodeItem.tsx +++ b/datahub-web-react/src/app/glossary/GlossaryBrowser/NodeItem.tsx @@ -166,7 +166,12 @@ function NodeItem(props: Props) { ))} {!hideTerms && (childTerms as GlossaryTerm[]).map((child) => ( - + ))} )} diff --git a/datahub-web-react/src/app/glossary/GlossarySearch.tsx b/datahub-web-react/src/app/glossary/GlossarySearch.tsx index 321c218c38fe3..160f8e66d709d 100644 --- a/datahub-web-react/src/app/glossary/GlossarySearch.tsx +++ b/datahub-web-react/src/app/glossary/GlossarySearch.tsx @@ -89,7 +89,6 @@ function GlossarySearch() { onFocus={() => setIsSearchBarFocused(true)} /> {isSearchBarFocused && searchResults && !!searchResults.length && renderSearchResults()} - ); diff --git a/datahub-web-react/src/app/home/HomePageRecommendations.tsx b/datahub-web-react/src/app/home/HomePageRecommendations.tsx index fa8da01e8079b..c55c194291d94 100644 --- a/datahub-web-react/src/app/home/HomePageRecommendations.tsx +++ b/datahub-web-react/src/app/home/HomePageRecommendations.tsx @@ -117,7 +117,7 @@ export const HomePageRecommendations = ({ user }: Props) => { variables: { input: { types: browseEntityList, - viewUrn + viewUrn, }, }, }); @@ -138,7 +138,7 @@ export const HomePageRecommendations = ({ user }: Props) => { scenario, }, limit: 10, - viewUrn + viewUrn, }, }, fetchPolicy: 'no-cache', @@ -191,9 +191,8 @@ export const HomePageRecommendations = ({ user }: Props) => { {orderedEntityCounts.map( (entityCount) => entityCount && - entityCount.count !== 0 && - entityCount.entityType !== EntityType.BusinessAttribute && - ( + entityCount.count !== 0 && + entityCount.entityType !== EntityType.BusinessAttribute && ( { {orderedEntityCounts.map( (entityCount) => entityCount && - entityCount.count !== 0 && - entityCount.entityType === EntityType.BusinessAttribute && + entityCount.count !== 0 && + entityCount.entityType === EntityType.BusinessAttribute && businessAttributesFlag && ( { (entityCount) => entityCount.entityType === EntityType.GlossaryTerm, ) && } - ) : ( + ) : ( - )} + )} )} {recommendationModules && diff --git a/datahub-web-react/src/app/identity/group/GroupListItem.tsx b/datahub-web-react/src/app/identity/group/GroupListItem.tsx index e5aada4800253..3559c4bdbb42c 100644 --- a/datahub-web-react/src/app/identity/group/GroupListItem.tsx +++ b/datahub-web-react/src/app/identity/group/GroupListItem.tsx @@ -54,7 +54,11 @@ export default function GroupListItem({ group, onDelete, selectRoleOptions, refe - +
{displayName} diff --git a/datahub-web-react/src/app/identity/user/UserListItem.tsx b/datahub-web-react/src/app/identity/user/UserListItem.tsx index 8ad3d7d93d657..ff349664d7628 100644 --- a/datahub-web-react/src/app/identity/user/UserListItem.tsx +++ b/datahub-web-react/src/app/identity/user/UserListItem.tsx @@ -98,8 +98,8 @@ export default function UserListItem({ user, canManageUserCredentials, selectRol
{displayName}
-
- {user.username} +
+ {user.username}
{userStatus && ( diff --git a/datahub-web-react/src/app/ingest/source/IngestionSourceTable.tsx b/datahub-web-react/src/app/ingest/source/IngestionSourceTable.tsx index ad1e7f6425062..00d04ed245edf 100644 --- a/datahub-web-react/src/app/ingest/source/IngestionSourceTable.tsx +++ b/datahub-web-react/src/app/ingest/source/IngestionSourceTable.tsx @@ -3,7 +3,7 @@ import React from 'react'; import styled from 'styled-components/macro'; import { StyledTable } from '../../entity/shared/components/styled/StyledTable'; import { ANTD_GRAY } from '../../entity/shared/constants'; -import { CLI_EXECUTOR_ID } from './utils'; +import { CLI_EXECUTOR_ID, getIngestionSourceStatus } from './utils'; import { LastStatusColumn, TypeColumn, @@ -123,7 +123,7 @@ function IngestionSourceTable({ lastExecStatus: source.executions && source.executions?.executionRequests.length > 0 && - source.executions?.executionRequests[0].result?.status, + getIngestionSourceStatus(source.executions?.executionRequests[0].result), cliIngestion: source.config?.executorId === CLI_EXECUTOR_ID, })); diff --git a/datahub-web-react/src/app/ingest/source/builder/NameSourceStep.tsx b/datahub-web-react/src/app/ingest/source/builder/NameSourceStep.tsx index 09728520e8366..5573e5a3e3904 100644 --- a/datahub-web-react/src/app/ingest/source/builder/NameSourceStep.tsx +++ b/datahub-web-react/src/app/ingest/source/builder/NameSourceStep.tsx @@ -180,10 +180,10 @@ export const NameSourceStep = ({ state, updateState, prev, submit }: StepProps) Advanced} key="1"> Executor ID}> - Provide the ID of the executor that should execute this ingestion recipe. This ID is used - to route execution requests of the recipe to the executor of the same ID. The built-in - DataHub executor ID is 'default'. Do not change this unless you have configured - a remote or custom executor. + Provide the ID of the executor that should execute this ingestion recipe. This ID is + used to route execution requests of the recipe to the executor of the same ID. The + built-in DataHub executor ID is 'default'. Do not change this unless you have + configured a remote or custom executor. { const validator = validateURL('test url'); await expect(validator.validator(null, 'http://example')).rejects.toThrowError('A valid test url is required.'); await expect(validator.validator(null, 'example')).rejects.toThrowError('A valid test url is required.'); - await expect(validator.validator(null, 'http://example')).rejects.toThrowError( - 'A valid test url is required.', - ); + await expect(validator.validator(null, 'http://example')).rejects.toThrowError('A valid test url is required.'); }); it('should resolve if the value is empty', async () => { diff --git a/datahub-web-react/src/app/ingest/source/builder/RecipeForm/okta.ts b/datahub-web-react/src/app/ingest/source/builder/RecipeForm/okta.ts index 6efee3769f908..ccb16f41e9a6c 100644 --- a/datahub-web-react/src/app/ingest/source/builder/RecipeForm/okta.ts +++ b/datahub-web-react/src/app/ingest/source/builder/RecipeForm/okta.ts @@ -150,4 +150,3 @@ export const SKIP_USERS_WITHOUT_GROUP: RecipeField = { fieldPath: 'source.config.skip_users_without_a_group', rules: null, }; - diff --git a/datahub-web-react/src/app/ingest/source/executions/ExecutionRequestDetailsModal.tsx b/datahub-web-react/src/app/ingest/source/executions/ExecutionRequestDetailsModal.tsx index 0799f8af1173d..6711f0ad12b03 100644 --- a/datahub-web-react/src/app/ingest/source/executions/ExecutionRequestDetailsModal.tsx +++ b/datahub-web-react/src/app/ingest/source/executions/ExecutionRequestDetailsModal.tsx @@ -13,9 +13,13 @@ import { getExecutionRequestStatusDisplayText, getExecutionRequestStatusIcon, getExecutionRequestSummaryText, + getIngestionSourceStatus, + getStructuredReport, RUNNING, SUCCESS, } from '../utils'; +import { ExecutionRequestResult } from '../../../../types.generated'; +import { StructuredReport } from './reporting/StructuredReport'; const StyledTitle = styled(Typography.Title)` padding: 0px; @@ -125,26 +129,30 @@ export const ExecutionDetailsModal = ({ urn, visible, onClose }: Props) => { }; const logs = (showExpandedLogs && output) || output?.split('\n').slice(0, 5).join('\n'); - const result = data?.executionRequest?.result?.status; + const result = data?.executionRequest?.result as Partial; + const status = getIngestionSourceStatus(result); useEffect(() => { const interval = setInterval(() => { - if (result === RUNNING) refetch(); + if (status === RUNNING) refetch(); }, 2000); return () => clearInterval(interval); }); - const ResultIcon = result && getExecutionRequestStatusIcon(result); - const resultColor = result && getExecutionRequestStatusDisplayColor(result); - const resultText = result && ( + const ResultIcon = status && getExecutionRequestStatusIcon(status); + const resultColor = status && getExecutionRequestStatusDisplayColor(status); + const resultText = status && ( {ResultIcon && } - {getExecutionRequestStatusDisplayText(result)} + {getExecutionRequestStatusDisplayText(status)} ); + + const structuredReport = result && getStructuredReport(result); + const resultSummaryText = - (result && {getExecutionRequestSummaryText(result)}) || + (status && {getExecutionRequestSummaryText(status)}) || undefined; const recipeJson = data?.executionRequest?.input.arguments?.find((arg) => arg.key === 'recipe')?.value; @@ -167,21 +175,22 @@ export const ExecutionDetailsModal = ({ urn, visible, onClose }: Props) => { bodyStyle={modalBodyStyle} title={ - Ingestion Run Details + Sync Details } visible={visible} onCancel={onClose} > - {!data && loading && } - {error && message.error('Failed to load execution details :(')} + {!data && loading && } + {error && message.error('Failed to load sync details :(')}
Status {resultText} {resultSummaryText} + {structuredReport ? : null} - {result === SUCCESS && ( + {status === SUCCESS && ( {data?.executionRequest?.id && } @@ -190,7 +199,7 @@ export const ExecutionDetailsModal = ({ urn, visible, onClose }: Props) => { Logs - View logs that were collected during the ingestion run. + View logs that were collected during the sync.