diff --git a/ingestion/src/metadata/data_quality/builders/i_validator_builder.py b/ingestion/src/metadata/data_quality/builders/i_validator_builder.py index d24df9034178..66cececad6fa 100644 --- a/ingestion/src/metadata/data_quality/builders/i_validator_builder.py +++ b/ingestion/src/metadata/data_quality/builders/i_validator_builder.py @@ -25,7 +25,7 @@ from metadata.generated.schema.tests.testCase import TestCase, TestCaseParameterValue from metadata.generated.schema.type.basic import Timestamp from metadata.profiler.processor.runner import QueryRunner -from metadata.utils.importer import import_test_case_class +from metadata.utils import importer if TYPE_CHECKING: from pandas import DataFrame @@ -59,7 +59,8 @@ def __init__( """ self._test_case = test_case self.runner = runner - self.validator_cls: Type[BaseTestValidator] = import_test_case_class( + # TODO this will be removed on https://github.com/open-metadata/OpenMetadata/pull/18716 + self.validator_cls: Type[BaseTestValidator] = importer.import_test_case_class( entity_type, self._get_source_type(), self.test_case.testDefinition.fullyQualifiedName, # type: ignore diff --git a/ingestion/src/metadata/data_quality/validations/runtime_param_setter/param_setter_factory.py b/ingestion/src/metadata/data_quality/validations/runtime_param_setter/param_setter_factory.py index be1a0abc2915..f66fcbfcebda 100644 --- a/ingestion/src/metadata/data_quality/validations/runtime_param_setter/param_setter_factory.py +++ b/ingestion/src/metadata/data_quality/validations/runtime_param_setter/param_setter_factory.py @@ -22,13 +22,20 @@ from metadata.data_quality.validations.runtime_param_setter.table_diff_params_setter import ( TableDiffParamsSetter, ) -from metadata.data_quality.validations.table.sqlalchemy.tableDiff import ( - TableDiffValidator, -) from metadata.generated.schema.entity.data.table import Table from metadata.ingestion.ometa.ometa_api import OpenMetadata from metadata.sampler.sqlalchemy.sampler import SQASampler +# We want to use the explicit class name here but the packages might not exist +try: + from metadata.data_quality.validations.table.sqlalchemy.tableDiff import ( + TableDiffValidator, + ) +except ImportError: + + class TableDiffValidator: + pass + def removesuffix(s: str, suffix: str) -> str: """A custom implementation of removesuffix for python versions < 3.9 diff --git a/ingestion/src/metadata/ingestion/api/models.py b/ingestion/src/metadata/ingestion/api/models.py index 6a576978efb6..3410b787af1b 100644 --- a/ingestion/src/metadata/ingestion/api/models.py +++ b/ingestion/src/metadata/ingestion/api/models.py @@ -14,20 +14,24 @@ from typing import Generic, Optional, TypeVar from pydantic import BaseModel, Field +from typing_extensions import Annotated from metadata.generated.schema.entity.services.ingestionPipelines.status import ( StackTraceError, ) -# Entities are instances of BaseModel Entity = BaseModel + T = TypeVar("T") class Either(BaseModel, Generic[T]): """Any execution should return us Either an Entity of an error for us to handle""" - left: Optional[StackTraceError] = Field( - None, description="Error encountered during execution" - ) - right: Optional[T] = Field(None, description="Correct instance of an Entity") + left: Annotated[ + Optional[StackTraceError], + Field(description="Error encountered during execution", default=None), + ] + right: Annotated[ + Optional[T], Field(description="Correct instance of an Entity", default=None) + ] diff --git a/ingestion/src/metadata/ingestion/source/database/dbt/dbt_config.py b/ingestion/src/metadata/ingestion/source/database/dbt/dbt_config.py index fca6f17cc928..216d7c6e9f83 100644 --- a/ingestion/src/metadata/ingestion/source/database/dbt/dbt_config.py +++ b/ingestion/src/metadata/ingestion/source/database/dbt/dbt_config.py @@ -164,7 +164,7 @@ def _(config: DbtCloudConfig): # pylint: disable=too-many-locals logger.debug( "Requesting [dbt_catalog], [dbt_manifest] and [dbt_run_results] data" ) - params_data = {"order_by": "-finished_at", "limit": "1", "status": "10"} + params_data = {"order_by": "-finished_at", "limit": "1"} if project_id: params_data["project_id"] = project_id diff --git a/ingestion/src/metadata/ingestion/source/search/elasticsearch/metadata.py b/ingestion/src/metadata/ingestion/source/search/elasticsearch/metadata.py index 191662523b75..1d2214656c42 100644 --- a/ingestion/src/metadata/ingestion/source/search/elasticsearch/metadata.py +++ b/ingestion/src/metadata/ingestion/source/search/elasticsearch/metadata.py @@ -12,6 +12,7 @@ Elasticsearch source to extract metadata """ import shutil +import traceback from pathlib import Path from typing import Any, Iterable, Optional @@ -21,6 +22,7 @@ CreateSearchIndexRequest, ) from metadata.generated.schema.entity.data.searchIndex import ( + IndexType, SearchIndex, SearchIndexSampleData, ) @@ -103,6 +105,7 @@ def yield_search_index( fields=parse_es_index_mapping( search_index_details.get(index_name, {}).get("mappings") ), + indexType=IndexType.Index, ) yield Either(right=search_index_request) self.register_record(search_index_request=search_index_request) @@ -143,6 +146,56 @@ def yield_search_index_sample_data( ) ) + def get_search_index_template_list(self) -> Iterable[dict]: + """ + Get List of all search index template + """ + yield from self.client.indices.get_index_template().get("index_templates", []) + + def get_search_index_template_name( + self, search_index_template_details: dict + ) -> Optional[str]: + """ + Get Search Index Template Name + """ + return search_index_template_details and search_index_template_details["name"] + + def yield_search_index_template( + self, search_index_template_details: Any + ) -> Iterable[Either[CreateSearchIndexRequest]]: + """ + Method to Get Search Index Template Entity + """ + try: + if self.source_config.includeIndexTemplate: + index_name = self.get_search_index_template_name( + search_index_template_details + ) + index_template = search_index_template_details["index_template"] + if index_name: + search_index_template_request = CreateSearchIndexRequest( + name=EntityName(index_name), + displayName=index_name, + searchIndexSettings=index_template.get("template", {}).get( + "settings", {} + ), + service=FullyQualifiedEntityName( + self.context.get().search_service + ), + fields=parse_es_index_mapping( + index_template.get("template", {}).get("mappings") + ), + indexType=IndexType.IndexTemplate, + description=index_template.get("_meta", {}).get("description"), + ) + yield Either(right=search_index_template_request) + self.register_record( + search_index_request=search_index_template_request + ) + except Exception as exc: + logger.debug(traceback.format_exc()) + logger.error(f"Could not include index templates due to {exc}") + def close(self): try: if Path(self.service_connection.sslConfig.certificates.stagingDir).exists(): diff --git a/ingestion/src/metadata/ingestion/source/search/search_service.py b/ingestion/src/metadata/ingestion/source/search/search_service.py index 6bf2eaadd374..8ef0400748a9 100644 --- a/ingestion/src/metadata/ingestion/source/search/search_service.py +++ b/ingestion/src/metadata/ingestion/source/search/search_service.py @@ -83,7 +83,7 @@ class SearchServiceTopology(ServiceTopology): cache_entities=True, ), ], - children=["search_index"], + children=["search_index", "search_index_template"], post_process=["mark_search_indexes_as_deleted"], ) search_index: Annotated[ @@ -107,6 +107,21 @@ class SearchServiceTopology(ServiceTopology): ], ) + search_index_template: Annotated[ + TopologyNode, Field(description="Search Index Template Processing Node") + ] = TopologyNode( + producer="get_search_index_template", + stages=[ + NodeStage( + type_=SearchIndex, + context="search_index_template", + processor="yield_search_index_template", + consumer=["search_service"], + use_cache=True, + ) + ], + ) + class SearchServiceSource(TopologyRunnerMixin, Source, ABC): """ @@ -178,6 +193,34 @@ def get_search_index(self) -> Any: continue yield index_details + def yield_search_index_template( + self, search_index_template_details: Any + ) -> Iterable[Either[CreateSearchIndexRequest]]: + """Method to Get Search Index Templates""" + + def get_search_index_template_list(self) -> Optional[List[Any]]: + """Get list of all search index templates""" + + def get_search_index_template_name(self, search_index_template_details: Any) -> str: + """Get Search Index Template Name""" + + def get_search_index_template(self) -> Any: + if self.source_config.includeIndexTemplate: + for index_template_details in self.get_search_index_template_list(): + if search_index_template_name := self.get_search_index_template_name( + index_template_details + ): + if filter_by_search_index( + self.source_config.searchIndexFilterPattern, + search_index_template_name, + ): + self.status.filter( + search_index_template_name, + "Search Index Template Filtered Out", + ) + continue + yield index_template_details + def yield_create_request_search_service( self, config: WorkflowSource ) -> Iterable[Either[CreateSearchServiceRequest]]: diff --git a/openmetadata-docs/images/v1.5/features/ingestion/workflows/profiler/custom-metric4.png b/openmetadata-docs/images/v1.5/features/ingestion/workflows/profiler/custom-metric4.png index 66d3b597699c..d51283a63309 100644 Binary files a/openmetadata-docs/images/v1.5/features/ingestion/workflows/profiler/custom-metric4.png and b/openmetadata-docs/images/v1.5/features/ingestion/workflows/profiler/custom-metric4.png differ diff --git a/openmetadata-docs/images/v1.6/features/ingestion/workflows/profiler/custom-metric4.png b/openmetadata-docs/images/v1.6/features/ingestion/workflows/profiler/custom-metric4.png index 66d3b597699c..d51283a63309 100644 Binary files a/openmetadata-docs/images/v1.6/features/ingestion/workflows/profiler/custom-metric4.png and b/openmetadata-docs/images/v1.6/features/ingestion/workflows/profiler/custom-metric4.png differ diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/AbstractNativeApplication.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/AbstractNativeApplication.java index 6dd73db36b53..9bdc74f21830 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/apps/AbstractNativeApplication.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/AbstractNativeApplication.java @@ -48,7 +48,6 @@ public class AbstractNativeApplication implements NativeApplication { protected CollectionDAO collectionDAO; private App app; protected SearchRepository searchRepository; - protected boolean isJobInterrupted = false; // Default service that contains external apps' Ingestion Pipelines private static final String SERVICE_NAME = "OpenMetadata"; @@ -299,6 +298,11 @@ protected void pushAppStatusUpdates( @Override public void interrupt() throws UnableToInterruptJobException { LOG.info("Interrupting the job for app: {}", this.app.getName()); - isJobInterrupted = true; + stop(); + } + + protected void stop() { + LOG.info("Default stop behavior for app: {}", this.app.getName()); + // Default implementation: no-op or generic cleanup logic } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/SearchIndexApp.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/SearchIndexApp.java index 2a356bf1024c..fd34c2027f5c 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/SearchIndexApp.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/SearchIndexApp.java @@ -161,12 +161,13 @@ public class SearchIndexApp extends AbstractNativeApplication { @Getter private EventPublisherJob jobData; private final Object jobDataLock = new Object(); - private volatile boolean stopped = false; private ExecutorService producerExecutor; private final ExecutorService jobExecutor = Executors.newCachedThreadPool(); private BlockingQueue producerQueue = new LinkedBlockingQueue<>(100); private final AtomicReference searchIndexStats = new AtomicReference<>(); private final AtomicReference batchSize = new AtomicReference<>(5); + private JobExecutionContext jobExecutionContext; + private volatile boolean stopped = false; public SearchIndexApp(CollectionDAO collectionDAO, SearchRepository searchRepository) { super(collectionDAO, searchRepository); @@ -190,6 +191,7 @@ public void init(App app) { @Override public void startApp(JobExecutionContext jobExecutionContext) { try { + this.jobExecutionContext = jobExecutionContext; initializeJob(jobExecutionContext); String runType = (String) jobExecutionContext.getJobDetail().getJobDataMap().get("triggerType"); @@ -533,11 +535,17 @@ private void reCreateIndexes(String entityType) throws SearchIndexException { } @SuppressWarnings("unused") - public void stopJob() { + @Override + public void stop() { LOG.info("Stopping reindexing job."); stopped = true; + jobData.setStatus(EventPublisherJob.Status.STOP_IN_PROGRESS); + sendUpdates(jobExecutionContext); shutdownExecutor(jobExecutor, "JobExecutor", 60, TimeUnit.SECONDS); shutdownExecutor(producerExecutor, "ProducerExecutor", 60, TimeUnit.SECONDS); + LOG.info("Stopped reindexing job."); + jobData.setStatus(EventPublisherJob.Status.STOPPED); + sendUpdates(jobExecutionContext); } private void processTask(IndexingTask task, JobExecutionContext jobExecutionContext) { @@ -596,7 +604,9 @@ private void processTask(IndexingTask task, JobExecutionContext jobExecutionC } LOG.error("Unexpected error during processing task for entity {}", entityType, e); } finally { - sendUpdates(jobExecutionContext); + if (!stopped) { + sendUpdates(jobExecutionContext); + } } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/scheduler/AppScheduler.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/scheduler/AppScheduler.java index 38bd2c2c870e..3163fc9a2e66 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/apps/scheduler/AppScheduler.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/scheduler/AppScheduler.java @@ -263,30 +263,52 @@ public void triggerOnDemandApplication(App application) { } public void stopApplicationRun(App application) { - if (application.getFullyQualifiedName() == null) { - throw new IllegalArgumentException("Application's fullyQualifiedName is null."); - } try { - // Interrupt any scheduled job JobDetail jobDetailScheduled = scheduler.getJobDetail(new JobKey(application.getName(), APPS_JOB_GROUP)); - if (jobDetailScheduled != null) { - LOG.debug("Stopping Scheduled Execution for App : {}", application.getName()); - scheduler.interrupt(jobDetailScheduled.getKey()); - } - - // Interrupt any on-demand job JobDetail jobDetailOnDemand = scheduler.getJobDetail( new JobKey( String.format("%s-%s", application.getName(), ON_DEMAND_JOB), APPS_JOB_GROUP)); - - if (jobDetailOnDemand != null) { - LOG.debug("Stopping On Demand Execution for App : {}", application.getName()); - scheduler.interrupt(jobDetailOnDemand.getKey()); + boolean isJobRunning = false; + // Check if the job is already running + List currentJobs = scheduler.getCurrentlyExecutingJobs(); + for (JobExecutionContext context : currentJobs) { + if ((jobDetailScheduled != null + && context.getJobDetail().getKey().equals(jobDetailScheduled.getKey())) + || (jobDetailOnDemand != null + && context.getJobDetail().getKey().equals(jobDetailOnDemand.getKey()))) { + isJobRunning = true; + } } - } catch (Exception ex) { - LOG.error("Failed to stop job execution.", ex); + if (!isJobRunning) { + throw new UnhandledServerException("There is no job running for the application."); + } + JobKey scheduledJobKey = new JobKey(application.getName(), APPS_JOB_GROUP); + if (jobDetailScheduled != null) { + LOG.debug("Stopping Scheduled Execution for App: {}", application.getName()); + scheduler.interrupt(scheduledJobKey); + try { + scheduler.deleteJob(scheduledJobKey); + } catch (SchedulerException ex) { + LOG.error("Failed to delete scheduled job: {}", scheduledJobKey, ex); + } + } else { + JobKey onDemandJobKey = + new JobKey( + String.format("%s-%s", application.getName(), ON_DEMAND_JOB), APPS_JOB_GROUP); + if (jobDetailOnDemand != null) { + LOG.debug("Stopping On Demand Execution for App: {}", application.getName()); + scheduler.interrupt(onDemandJobKey); + try { + scheduler.deleteJob(onDemandJobKey); + } catch (SchedulerException ex) { + LOG.error("Failed to delete on-demand job: {}", onDemandJobKey, ex); + } + } + } + } catch (SchedulerException ex) { + LOG.error("Failed to stop job execution for app: {}", application.getName(), ex); } } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/AppRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/AppRepository.java index e4ecd6bb2c98..1c7cab44492f 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/AppRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/AppRepository.java @@ -141,11 +141,7 @@ public EntityReference createNewAppBot(App application) { public void storeEntity(App entity, boolean update) { List ownerRefs = entity.getOwners(); entity.withOwners(null); - - // Store store(entity, update); - - // Restore entity fields entity.withOwners(ownerRefs); } @@ -178,7 +174,6 @@ protected void postDelete(App entity) { } public final List listAll() { - // forward scrolling, if after == null then first page is being asked List jsons = dao.listAfterWithOffset(Integer.MAX_VALUE, 0); List entities = new ArrayList<>(); for (String json : jsons) { @@ -214,7 +209,6 @@ public ResultList listAppExtensionByName( .listAppExtensionCountByName(app.getName(), extensionType.toString()); List entities = new ArrayList<>(); if (limitParam > 0) { - // forward scrolling, if after == null then first page is being asked List jsons = daoCollection .appExtensionTimeSeriesDao() @@ -274,7 +268,6 @@ public ResultList listAppExtensionAfterTimeByName( app.getName(), startTime, extensionType.toString()); List entities = new ArrayList<>(); if (limitParam > 0) { - // forward scrolling, if after == null then first page is being asked List jsons = daoCollection .appExtensionTimeSeriesDao() @@ -287,7 +280,6 @@ public ResultList listAppExtensionAfterTimeByName( return new ResultList<>(entities, offset, total); } else { - // limit == 0 , return total count of entity. return new ResultList<>(entities, null, total); } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SearchIndexRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SearchIndexRepository.java index e20a5d37c247..4bc116133421 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SearchIndexRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SearchIndexRepository.java @@ -389,6 +389,7 @@ public void entitySpecificUpdate() { original.getSearchIndexSettings(), updated.getSearchIndexSettings()); recordChange("sourceHash", original.getSourceHash(), updated.getSourceHash()); + recordChange("indexType", original.getIndexType(), updated.getIndexType()); } private void updateSearchIndexFields( diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseResolutionStatusRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseResolutionStatusRepository.java index 564fecc64807..7fb596d0b5a9 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseResolutionStatusRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseResolutionStatusRepository.java @@ -401,12 +401,6 @@ public void inferIncidentSeverity(TestCaseResolutionStatus incident) { incident.setSeverity(severity); } - public void deleteTestCaseFailedSamples(TestCaseResolutionStatus entity) { - TestCaseRepository testCaseRepository = - (TestCaseRepository) Entity.getEntityRepository(Entity.TEST_CASE); - testCaseRepository.deleteTestCaseFailedRowsSample(entity.getTestCaseReference().getId()); - } - public static String addOriginEntityFQNJoin(ListFilter filter, String condition) { // if originEntityFQN is present, we need to join with test_case table if (filter.getQueryParam("originEntityFQN") != null) { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestSuiteRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestSuiteRepository.java index 8d80bbfc237d..291c55cb077a 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestSuiteRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestSuiteRepository.java @@ -15,7 +15,6 @@ import java.io.IOException; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -228,39 +227,52 @@ public DataQualityReport getDataQualityReport(String q, String aggQuery, String } public TestSummary getTestSummary(List testCaseResults) { - Map> summaries = + record ProcessedTestCaseResults(String entityLink, String status) {} + + List processedTestCaseResults = testCaseResults.stream() + .map( + result -> { + TestCase testCase = + Entity.getEntityByName(TEST_CASE, result.getTestCaseName(), "", ALL); + MessageParser.EntityLink entityLink = + MessageParser.EntityLink.parse(testCase.getEntityLink()); + String linkString = + entityLink.getFieldName() == null ? "table" : entityLink.getLinkString(); + return new ProcessedTestCaseResults(linkString, result.getStatus().toString()); + }) + .toList(); + + Map> summaries = + processedTestCaseResults.stream() .collect( Collectors.groupingBy( - result -> { - TestCase testCase = - Entity.getEntityByName(TEST_CASE, result.getTestCaseName(), "", ALL); - MessageParser.EntityLink entityLink = - MessageParser.EntityLink.parse(testCase.getEntityLink()); - return entityLink.getFieldName() == null - ? "table" - : entityLink.getLinkString(); - }, + ProcessedTestCaseResults::entityLink, Collectors.groupingBy( - result -> result.getStatus().toString(), + ProcessedTestCaseResults::status, Collectors.collectingAndThen(Collectors.counting(), Long::intValue)))); - Map testSummaryMap = summaries.getOrDefault("table", new HashMap<>()); - TestSummary testSummary = createTestSummary(testSummaryMap); - testSummary.setTotal(testCaseResults.size()); + Map testSummaryMap = + processedTestCaseResults.stream() + .collect( + Collectors.groupingBy( + result -> result.status, + Collectors.collectingAndThen(Collectors.counting(), Long::intValue))); List columnTestSummaryDefinitions = summaries.entrySet().stream() .filter(entry -> !entry.getKey().equals("table")) .map( entry -> { - Map columnSummaryMap = entry.getValue(); ColumnTestSummaryDefinition columnTestSummaryDefinition = - createColumnSummary(columnSummaryMap); + createColumnSummary(entry.getValue()); columnTestSummaryDefinition.setEntityLink(entry.getKey()); return columnTestSummaryDefinition; }) .toList(); + + TestSummary testSummary = createTestSummary(testSummaryMap); + testSummary.setTotal(testCaseResults.size()); testSummary.setColumnTestSummary(columnTestSummaryDefinitions); return testSummary; } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/apps/AppResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/apps/AppResource.java index f9066b215e0d..53259cf8e320 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/apps/AppResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/apps/AppResource.java @@ -269,13 +269,13 @@ public Response listAppRuns( @DefaultValue("10") @QueryParam("limit") @Min(0) - @Max(1000000) + @Max(1000) int limitParam, @Parameter(description = "Offset records. (0 to 1000000, default = 0)") @DefaultValue("0") @QueryParam("offset") @Min(0) - @Max(1000000) + @Max(1000) int offset, @Parameter( description = "Filter pipeline status after the given start timestamp", @@ -1013,9 +1013,9 @@ public Response stopApplicationRun( App app = repository.getByName(uriInfo, name, fields); if (Boolean.TRUE.equals(app.getSupportsInterrupt())) { if (app.getAppType().equals(AppType.Internal)) { - AppScheduler.getInstance().stopApplicationRun(app); + new Thread(() -> AppScheduler.getInstance().stopApplicationRun(app)).start(); return Response.status(Response.Status.OK) - .entity("Application will be stopped in some time.") + .entity("Application stop in progress. Please check status via.") .build(); } else { if (!app.getPipelines().isEmpty()) { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseResolutionStatusResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseResolutionStatusResource.java index e807d3d0ce52..4a12f5eff632 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseResolutionStatusResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseResolutionStatusResource.java @@ -282,12 +282,6 @@ public Response patch( authorizer.authorize(securityContext, operationContext, resourceContext); RestUtil.PatchResponse response = repository.patch(id, patch, securityContext.getUserPrincipal().getName()); - if (response - .entity() - .getTestCaseResolutionStatusType() - .equals(TestCaseResolutionStatusTypes.Resolved)) { - repository.deleteTestCaseFailedSamples(response.entity()); - } return response.toResponse(); } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/events/subscription/EventSubscriptionResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/events/subscription/EventSubscriptionResource.java index e81be9ae905c..51d943faa3b4 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/events/subscription/EventSubscriptionResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/events/subscription/EventSubscriptionResource.java @@ -588,8 +588,6 @@ public ResultList listEventSubResources( @Parameter(description = "AlertType", schema = @Schema(type = "string")) @PathParam("alertType") CreateEventSubscription.AlertType alertType) { - OperationContext operationContext = new OperationContext(entityType, MetadataOperation.CREATE); - authorizer.authorize(securityContext, operationContext, getResourceContext()); if (alertType.equals(NOTIFICATION)) { return new ResultList<>(EventsSubscriptionRegistry.listEntityNotificationDescriptors()); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/searchindex/SearchIndexResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/searchindex/SearchIndexResource.java index 628e34670d85..24831f21253e 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/searchindex/SearchIndexResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/searchindex/SearchIndexResource.java @@ -628,11 +628,14 @@ public Response restoreSearchIndex( } private SearchIndex getSearchIndex(CreateSearchIndex create, String user) { - return repository - .copy(new SearchIndex(), create, user) - .withService(getEntityReference(Entity.SEARCH_SERVICE, create.getService())) - .withFields(create.getFields()) - .withSearchIndexSettings(create.getSearchIndexSettings()) - .withSourceHash(create.getSourceHash()); + SearchIndex searchIndex = + repository + .copy(new SearchIndex(), create, user) + .withService(getEntityReference(Entity.SEARCH_SERVICE, create.getService())) + .withFields(create.getFields()) + .withSearchIndexSettings(create.getSearchIndexSettings()) + .withSourceHash(create.getSourceHash()) + .withIndexType(create.getIndexType()); + return searchIndex; } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/indexes/SearchEntityIndex.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/indexes/SearchEntityIndex.java index 2ebe64d612a9..a04ebee91a0f 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/indexes/SearchEntityIndex.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/indexes/SearchEntityIndex.java @@ -31,6 +31,7 @@ public Map buildSearchIndexDocInternal(Map doc) doc.put("tags", parseTags.getTags()); doc.put("tier", parseTags.getTierTag()); doc.put("service", getEntityWithDisplayName(searchIndex.getService())); + doc.put("indexType", searchIndex.getIndexType()); doc.put("lineage", SearchIndex.getLineageData(searchIndex.getEntityReference())); return doc; } diff --git a/openmetadata-service/src/main/resources/elasticsearch/en/search_entity_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/en/search_entity_index_mapping.json index 5a27ac7576d8..f18800d7d4a7 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/en/search_entity_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/en/search_entity_index_mapping.json @@ -494,7 +494,10 @@ "format": "strict_date_optional_time||epoch_millis" } } - } + }, + "indexType": { + "type": "text" + } } } } diff --git a/openmetadata-service/src/main/resources/elasticsearch/jp/search_entity_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/jp/search_entity_index_mapping.json index 2ce8ef3d282a..3d05a850134c 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/jp/search_entity_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/jp/search_entity_index_mapping.json @@ -491,7 +491,10 @@ "format": "strict_date_optional_time||epoch_millis" } } - } + }, + "indexType": { + "type": "text" + } } } } diff --git a/openmetadata-service/src/main/resources/elasticsearch/zh/search_entity_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/zh/search_entity_index_mapping.json index 2b188430a0b8..385a51afbf0a 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/zh/search_entity_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/zh/search_entity_index_mapping.json @@ -482,7 +482,10 @@ "format": "strict_date_optional_time||epoch_millis" } } - } + }, + "indexType": { + "type": "text" + } } } } diff --git a/openmetadata-spec/src/main/resources/json/schema/api/data/createSearchIndex.json b/openmetadata-spec/src/main/resources/json/schema/api/data/createSearchIndex.json index 18774ddd6900..7f5e3bb735d4 100644 --- a/openmetadata-spec/src/main/resources/json/schema/api/data/createSearchIndex.json +++ b/openmetadata-spec/src/main/resources/json/schema/api/data/createSearchIndex.json @@ -74,6 +74,11 @@ "type": "string", "minLength": 1, "maxLength": 32 + }, + "indexType": { + "description": "Whether the entity is index or index template.", + "$ref": "../../entity/data/searchIndex.json#/properties/indexType", + "default": "Index" } }, "required": ["name", "service", "fields"], diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/data/searchIndex.json b/openmetadata-spec/src/main/resources/json/schema/entity/data/searchIndex.json index f664210f2b5b..23063fe3a64c 100644 --- a/openmetadata-spec/src/main/resources/json/schema/entity/data/searchIndex.json +++ b/openmetadata-spec/src/main/resources/json/schema/entity/data/searchIndex.json @@ -6,14 +6,18 @@ "description": "A `SearchIndex` is a index mapping definition in ElasticSearch or OpenSearch", "type": "object", "javaType": "org.openmetadata.schema.entity.data.SearchIndex", - "javaInterfaces": ["org.openmetadata.schema.EntityInterface"], + "javaInterfaces": [ + "org.openmetadata.schema.EntityInterface" + ], "definitions": { "searchIndexSettings": { "javaType": "org.openmetadata.schema.type.searchindex.SearchIndexSettings", "description": "Contains key/value pair of SearchIndex Settings.", "type": "object", "additionalProperties": { - ".{1,}": { "type": "string" } + ".{1,}": { + "type": "string" + } } }, "searchIndexSampleData": { @@ -92,7 +96,9 @@ "searchIndexField": { "type": "object", "javaType": "org.openmetadata.schema.type.SearchIndexField", - "javaInterfaces": ["org.openmetadata.schema.FieldInterface"], + "javaInterfaces": [ + "org.openmetadata.schema.FieldInterface" + ], "description": "This schema defines the type for a field in a searchIndex.", "properties": { "name": { @@ -232,15 +238,15 @@ "description": "Entity extension data with custom attributes added to the entity.", "$ref": "../../type/basic.json#/definitions/entityExtension" }, - "domain" : { + "domain": { "description": "Domain the SearchIndex belongs to. When not set, the SearchIndex inherits the domain from the messaging service it belongs to.", "$ref": "../../type/entityReference.json" }, - "dataProducts" : { + "dataProducts": { "description": "List of data products this entity is part of.", - "$ref" : "../../type/entityReferenceList.json" + "$ref": "../../type/entityReferenceList.json" }, - "votes" : { + "votes": { "description": "Votes on the entity.", "$ref": "../../type/votes.json" }, @@ -256,8 +262,20 @@ "type": "string", "minLength": 1, "maxLength": 32 + }, + "indexType": { + "description": "Whether the entity is index or index template.", + "type": "string", + "javaType": "org.openmetadata.schema.entity.type.SearchIndexType", + "enum": ["Index", "IndexTemplate"], + "default": "Index" } }, - "required": ["id", "name", "service", "fields"], + "required": [ + "id", + "name", + "service", + "fields" + ], "additionalProperties": false -} +} \ No newline at end of file diff --git a/openmetadata-spec/src/main/resources/json/schema/metadataIngestion/searchServiceMetadataPipeline.json b/openmetadata-spec/src/main/resources/json/schema/metadataIngestion/searchServiceMetadataPipeline.json index 0b1b52ad9a15..01e0f88f3666 100644 --- a/openmetadata-spec/src/main/resources/json/schema/metadataIngestion/searchServiceMetadataPipeline.json +++ b/openmetadata-spec/src/main/resources/json/schema/metadataIngestion/searchServiceMetadataPipeline.json @@ -50,6 +50,12 @@ "description": "Set the 'Override Metadata' toggle to control whether to override the existing metadata in the OpenMetadata server with the metadata fetched from the source. If the toggle is set to true, the metadata fetched from the source will override the existing metadata in the OpenMetadata server. If the toggle is set to false, the metadata fetched from the source will not override the existing metadata in the OpenMetadata server. This is applicable for fields like description, tags, owner and displayName", "type": "boolean", "default": false + }, + "includeIndexTemplate":{ + "title": "Include Index Template", + "description": "Enable the 'Include Index Template' toggle to manage the ingestion of index template data.", + "type": "boolean", + "default": false } }, "additionalProperties": false diff --git a/openmetadata-spec/src/main/resources/json/schema/system/eventPublisherJob.json b/openmetadata-spec/src/main/resources/json/schema/system/eventPublisherJob.json index 3305971858dd..e79867682b9b 100644 --- a/openmetadata-spec/src/main/resources/json/schema/system/eventPublisherJob.json +++ b/openmetadata-spec/src/main/resources/json/schema/system/eventPublisherJob.json @@ -75,7 +75,8 @@ "active", "activeError", "stopped", - "success" + "success", + "stopInProgress" ] }, "failure": { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/constant/alert.interface.ts b/openmetadata-ui/src/main/resources/ui/playwright/constant/alert.interface.ts index ecf92748b51a..9962fb59ef0f 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/constant/alert.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/constant/alert.interface.ts @@ -69,3 +69,13 @@ export interface ObservabilityCreationDetails { secretKey?: string; }>; } + +export interface EventDetails { + status: 'successful' | 'failed'; + data: { + id: string; + entityType: string; + eventType: string; + entityId: string; + }[]; +} diff --git a/openmetadata-ui/src/main/resources/ui/playwright/constant/alert.ts b/openmetadata-ui/src/main/resources/ui/playwright/constant/alert.ts index 12ae5f33d002..9c2331ef6f6e 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/constant/alert.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/constant/alert.ts @@ -19,3 +19,61 @@ export const TEST_SUITE_NAME = `0-pw-test-suite-${uuid()}`; export const TEST_CASE_NAME = `0-pw-test-case-${uuid()}`; export const INGESTION_PIPELINE_NAME = `0-playwright-ingestion-pipeline-${uuid()}`; + +export const ALERT_WITH_PERMISSION_POLICY_NAME = `alert-policy-${uuid()}`; +export const ALERT_WITH_PERMISSION_ROLE_NAME = `alert-role-${uuid()}`; +export const ALERT_WITHOUT_PERMISSION_POLICY_NAME = `alert-policy-${uuid()}`; +export const ALERT_WITHOUT_PERMISSION_ROLE_NAME = `alert-role-${uuid()}`; + +export const ALERT_WITH_PERMISSION_POLICY_DETAILS = { + name: ALERT_WITH_PERMISSION_POLICY_NAME, + description: 'Alert Policy Description', + rules: [ + { + name: 'Alert Rule', + description: 'Alert Rule Description', + resources: ['eventsubscription'], + operations: ['Create', 'EditAll', 'ViewAll', 'Delete'], + effect: 'allow', + }, + ], +}; + +export const ALERT_WITHOUT_PERMISSION_POLICY_DETAILS = { + name: ALERT_WITHOUT_PERMISSION_POLICY_NAME, + description: 'Alert Policy Description', + rules: [ + { + name: 'Deny Rules', + description: 'Alert Rule Description', + resources: ['eventsubscription'], + operations: [ + 'Create', + 'EditAll', + 'Delete', + 'EditOwners', + 'EditDescription', + ], + effect: 'deny', + }, + { + name: 'Allow Rules', + description: 'Alert Rule Description', + resources: ['eventsubscription'], + operations: ['ViewAll'], + effect: 'allow', + }, + ], +}; + +export const ALERT_WITH_PERMISSION_ROLE_DETAILS = { + name: ALERT_WITH_PERMISSION_ROLE_NAME, + description: 'Alert Role Description', + policies: [ALERT_WITH_PERMISSION_POLICY_NAME], +}; + +export const ALERT_WITHOUT_PERMISSION_ROLE_DETAILS = { + name: ALERT_WITHOUT_PERMISSION_ROLE_NAME, + description: 'Alert Role Description', + policies: [ALERT_WITHOUT_PERMISSION_POLICY_NAME], +}; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/NotificationAlerts.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/NotificationAlerts.spec.ts index d953b6247fc2..f4e3ac87e93f 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/NotificationAlerts.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/NotificationAlerts.spec.ts @@ -11,32 +11,46 @@ * limitations under the License. */ -import test, { expect } from '@playwright/test'; -import { ALERT_UPDATED_DESCRIPTION } from '../../constant/alert'; +import { Page, test as base } from '@playwright/test'; import { Domain } from '../../support/domain/Domain'; import { DashboardClass } from '../../support/entity/DashboardClass'; +import { TableClass } from '../../support/entity/TableClass'; +import { AdminClass } from '../../support/user/AdminClass'; import { UserClass } from '../../support/user/UserClass'; +import { performAdminLogin } from '../../utils/admin'; import { - addExternalDestination, - addFilterWithUsersListInput, - addInternalDestination, - addMultipleFilters, - addOwnerFilter, + commonCleanup, + commonPrerequisites, + createAlert, deleteAlert, generateAlertName, inputBasicAlertInformation, - saveAlertAndVerifyResponse, verifyAlertDetails, visitAlertDetailsPage, visitEditAlertPage, - visitNotificationAlertPage, } from '../../utils/alert'; -import { createNewPage, descriptionBox } from '../../utils/common'; +import { descriptionBox, getApiContext } from '../../utils/common'; +import { + addFilterWithUsersListInput, + addInternalDestination, + checkAlertDetailsForWithPermissionUser, + checkAlertFlowForWithoutPermissionUser, + createAlertForRecentEventsCheck, + createAlertWithMultipleFilters, + createConversationAlert, + createTaskAlert, + editSingleFilterAlert, + visitNotificationAlertPage, +} from '../../utils/notificationAlert'; +import { addExternalDestination } from '../../utils/observabilityAlert'; const dashboard = new DashboardClass(); +const table = new TableClass(); +const admin = new AdminClass(); const user1 = new UserClass(); const user2 = new UserClass(); const domain = new Domain(); + const SOURCE_NAME_1 = 'all'; const SOURCE_DISPLAY_NAME_1 = 'All'; const SOURCE_NAME_2 = 'dashboard'; @@ -45,382 +59,386 @@ const SOURCE_NAME_3 = 'task'; const SOURCE_DISPLAY_NAME_3 = 'Task'; const SOURCE_NAME_4 = 'conversation'; const SOURCE_DISPLAY_NAME_4 = 'Conversation'; +const SOURCE_NAME_5 = 'table'; +const SOURCE_DISPLAY_NAME_5 = 'Table'; + +// Create 3 page and authenticate 1 with admin and others with normal user +const test = base.extend<{ + page: Page; + userWithPermissionsPage: Page; + userWithoutPermissionsPage: Page; +}>({ + page: async ({ browser }, use) => { + const page = await browser.newPage(); + await admin.login(page); + await use(page); + await page.close(); + }, + userWithPermissionsPage: async ({ browser }, use) => { + const page = await browser.newPage(); + await user1.login(page); + await use(page); + await page.close(); + }, + userWithoutPermissionsPage: async ({ browser }, use) => { + const page = await browser.newPage(); + await user2.login(page); + await use(page); + await page.close(); + }, +}); -// use the admin user to login -test.use({ storageState: 'playwright/.auth/admin.json' }); - -test.describe('Notification Alert Flow', () => { - const data = { - alertDetails: { - id: '', - name: '', - displayName: '', - description: '', - filteringRules: { resources: [] }, - input: { filters: [], actions: [] }, - destinations: [], - }, - }; - - test.beforeAll(async ({ browser }) => { - const { apiContext, afterAction } = await createNewPage(browser); - await dashboard.create(apiContext); - await user1.create(apiContext); - await user2.create(apiContext); - await domain.create(apiContext); - await afterAction(); +const data = { + alertDetails: { + id: '', + name: '', + displayName: '', + description: '', + filteringRules: { resources: [] }, + input: { filters: [], actions: [] }, + destinations: [], + }, +}; + +test.beforeAll(async ({ browser }) => { + const { afterAction, apiContext } = await performAdminLogin(browser); + await commonPrerequisites({ + apiContext, + table, + user1, + user2, + domain, }); + await dashboard.create(apiContext); - test.afterAll('Cleanup', async ({ browser }) => { - const { apiContext, afterAction } = await createNewPage(browser); - await dashboard.delete(apiContext); - await user1.delete(apiContext); - await user2.delete(apiContext); - await domain.delete(apiContext); - await afterAction(); - }); + await afterAction(); +}); - test.beforeEach('Visit entity details page', async ({ page }) => { - await visitNotificationAlertPage(page); +test.afterAll('Cleanup', async ({ browser }) => { + const { afterAction, apiContext } = await performAdminLogin(browser); + await commonCleanup({ + apiContext, + table, + user1, + user2, + domain, }); + await dashboard.delete(apiContext); - test('Single Filter Alert', async ({ page }) => { - test.slow(); + await afterAction(); +}); - const ALERT_NAME = generateAlertName(); +test('Single Filter Alert', async ({ page }) => { + test.slow(); - await test.step('Create alert', async () => { - await inputBasicAlertInformation({ - page, - name: ALERT_NAME, - sourceName: SOURCE_NAME_1, - sourceDisplayName: SOURCE_DISPLAY_NAME_1, - }); + const ALERT_NAME = generateAlertName(); + await visitNotificationAlertPage(page); - // Select filters - await page.click('[data-testid="add-filters"]'); + await test.step('Create alert', async () => { + data.alertDetails = await createAlert({ + page, + alertName: ALERT_NAME, + sourceName: SOURCE_NAME_1, + sourceDisplayName: SOURCE_DISPLAY_NAME_1, + user: user1, + }); + }); - await addOwnerFilter({ + await test.step('Check created alert details', async () => { + await visitNotificationAlertPage(page); + await visitAlertDetailsPage(page, data.alertDetails); + + // Verify alert details + await verifyAlertDetails({ page, alertDetails: data.alertDetails }); + }); + + await test.step( + 'Edit alert by adding multiple filters and internal destinations', + async () => { + await editSingleFilterAlert({ page, - filterNumber: 0, - ownerName: user1.getUserName(), + sourceName: SOURCE_NAME_2, + sourceDisplayName: SOURCE_DISPLAY_NAME_2, + user1, + user2, + domain, + dashboard, + alertDetails: data.alertDetails, }); - // Select Destination - await page.click('[data-testid="add-destination-button"]'); + // Click save + const updateAlert = page.waitForResponse( + (response) => + response.url().includes('/api/v1/events/subscriptions') && + response.request().method() === 'PATCH' && + response.status() === 200 + ); + await page.click('[data-testid="save-button"]'); + await updateAlert.then(async (response) => { + data.alertDetails = await response.json(); - await addInternalDestination({ - page, - destinationNumber: 0, - category: 'Admins', - type: 'Email', + test.expect(response.status()).toEqual(200); + + // Verify the edited alert changes + await verifyAlertDetails({ page, alertDetails: data.alertDetails }); }); + } + ); - data.alertDetails = await saveAlertAndVerifyResponse(page); + await test.step('Delete alert', async () => { + await deleteAlert(page, data.alertDetails); + }); +}); + +test('Multiple Filters Alert', async ({ page }) => { + test.slow(); + + const ALERT_NAME = generateAlertName(); + await visitNotificationAlertPage(page); + + await test.step('Create alert', async () => { + data.alertDetails = await createAlertWithMultipleFilters({ + page, + alertName: ALERT_NAME, + sourceName: SOURCE_NAME_1, + sourceDisplayName: SOURCE_DISPLAY_NAME_1, + user1, + user2, + domain, + dashboard, }); + }); - await test.step('Check created alert details', async () => { - await visitNotificationAlertPage(page); - await visitAlertDetailsPage(page, data.alertDetails); + await test.step( + 'Edit alert by removing added filters and internal destinations', + async () => { + await visitEditAlertPage(page, data.alertDetails); - // Verify alert details - await verifyAlertDetails({ page, alertDetails: data.alertDetails }); - }); + // Remove description + await page.locator(descriptionBox).clear(); - await test.step( - 'Edit alert by adding multiple filters and internal destinations', - async () => { - await visitEditAlertPage(page, data.alertDetails); - - // Update description - await page.locator(descriptionBox).clear(); - await page.locator(descriptionBox).fill(ALERT_UPDATED_DESCRIPTION); - - // Update source - await page.click('[data-testid="source-select"]'); - await page - .getByTestId(`${SOURCE_NAME_2}-option`) - .getByText(SOURCE_DISPLAY_NAME_2) - .click(); - - // Filters should reset after source change - await expect(page.getByTestId('filter-select-0')).not.toBeAttached(); - - await addMultipleFilters({ - page, - user1, - user2, - domain, - dashboard, - }); - - await page.getByTestId('connection-timeout-input').clear(); - await page.fill('[data-testid="connection-timeout-input"]', '26'); - - // Add owner GChat destination - await page.click('[data-testid="add-destination-button"]'); - await addInternalDestination({ - page, - destinationNumber: 0, - category: 'Owners', - type: 'G Chat', - }); - - // Add team Slack destination - await page.click('[data-testid="add-destination-button"]'); - await addInternalDestination({ - page, - destinationNumber: 1, - category: 'Teams', - type: 'Slack', - typeId: 'Team-select', - searchText: 'Organization', - }); - - // Add user email destination - await page.click('[data-testid="add-destination-button"]'); - await addInternalDestination({ - page, - destinationNumber: 2, - category: 'Users', - type: 'Email', - typeId: 'User-select', - searchText: user1.getUserName(), - }); - - // Click save - const updateAlert = page.waitForResponse( - (response) => - response.url().includes('/api/v1/events/subscriptions') && - response.request().method() === 'PATCH' && - response.status() === 200 - ); - await page.click('[data-testid="save-button"]'); - await updateAlert.then(async (response) => { - data.alertDetails = await response.json(); - - expect(response.status()).toEqual(200); - - // Verify the edited alert changes - await verifyAlertDetails({ page, alertDetails: data.alertDetails }); - }); + // Remove all filters + for (const _ of Array(6).keys()) { + await page.click('[data-testid="remove-filter-0"]'); } - ); - await test.step('Delete alert', async () => { - await deleteAlert(page, data.alertDetails); - }); - }); + // Remove all destinations except one + for (const _ of Array(5).keys()) { + await page.click('[data-testid="remove-destination-0"]'); + } - test('Multiple Filters Alert', async ({ page }) => { - test.slow(); + // Click save + const updateAlert = page.waitForResponse( + (response) => + response.url().includes('/api/v1/events/subscriptions') && + response.request().method() === 'PATCH' && + response.status() === 200 + ); + await page.click('[data-testid="save-button"]'); + await updateAlert.then(async (response) => { + data.alertDetails = await response.json(); - const ALERT_NAME = generateAlertName(); + test.expect(response.status()).toEqual(200); - await test.step('Create alert', async () => { - await inputBasicAlertInformation({ - page, - name: ALERT_NAME, - sourceName: SOURCE_NAME_1, - sourceDisplayName: SOURCE_DISPLAY_NAME_1, + // Verify the edited alert changes + await verifyAlertDetails({ page, alertDetails: data.alertDetails }); }); + } + ); - await addMultipleFilters({ - page, - user1, - user2, - domain, - dashboard, - }); + await test.step('Delete alert', async () => { + await deleteAlert(page, data.alertDetails); + }); +}); - await page.click('[data-testid="add-destination-button"]'); - await addInternalDestination({ - page, - destinationNumber: 0, - category: 'Followers', - type: 'Email', - }); - await page.click('[data-testid="add-destination-button"]'); - await addExternalDestination({ - page, - destinationNumber: 1, - category: 'Email', - input: 'test@example.com', - }); - await page.click('[data-testid="add-destination-button"]'); - await addExternalDestination({ - page, - destinationNumber: 2, - category: 'G Chat', - input: 'https://gchat.com', - }); - await page.click('[data-testid="add-destination-button"]'); - await addExternalDestination({ - page, - destinationNumber: 3, - category: 'Webhook', - input: 'https://webhook.com', - }); - await page.click('[data-testid="add-destination-button"]'); - await addExternalDestination({ - page, - destinationNumber: 4, - category: 'Ms Teams', - input: 'https://msteams.com', - }); - await page.click('[data-testid="add-destination-button"]'); - await addExternalDestination({ - page, - destinationNumber: 5, - category: 'Slack', - input: 'https://slack.com', - }); +test('Task source alert', async ({ page }) => { + const ALERT_NAME = generateAlertName(); + await visitNotificationAlertPage(page); - data.alertDetails = await saveAlertAndVerifyResponse(page); + await test.step('Create alert', async () => { + data.alertDetails = await createTaskAlert({ + page, + alertName: ALERT_NAME, + sourceName: SOURCE_NAME_3, + sourceDisplayName: SOURCE_DISPLAY_NAME_3, }); + }); - await test.step( - 'Edit alert by removing added filters and internal destinations', - async () => { - await visitEditAlertPage(page, data.alertDetails); - - // Remove description - await page.locator(descriptionBox).clear(); - - // Remove all filters - for (const _ of Array(6).keys()) { - await page.click('[data-testid="remove-filter-0"]'); - } - - // Remove all destinations except one - for (const _ of Array(5).keys()) { - await page.click('[data-testid="remove-destination-0"]'); - } - - // Click save - const updateAlert = page.waitForResponse( - (response) => - response.url().includes('/api/v1/events/subscriptions') && - response.request().method() === 'PATCH' && - response.status() === 200 - ); - await page.click('[data-testid="save-button"]'); - await updateAlert.then(async (response) => { - data.alertDetails = await response.json(); - - expect(response.status()).toEqual(200); - - // Verify the edited alert changes - await verifyAlertDetails({ page, alertDetails: data.alertDetails }); - }); - } - ); + await test.step('Delete alert', async () => { + await deleteAlert(page, data.alertDetails); + }); +}); + +test('Conversation source alert', async ({ page }) => { + const ALERT_NAME = generateAlertName(); + await visitNotificationAlertPage(page); - await test.step('Delete alert', async () => { - await deleteAlert(page, data.alertDetails); + await test.step('Create alert', async () => { + data.alertDetails = await createConversationAlert({ + page, + alertName: ALERT_NAME, + sourceName: SOURCE_NAME_4, + sourceDisplayName: SOURCE_DISPLAY_NAME_4, }); }); - test('Task source alert', async ({ page }) => { - const ALERT_NAME = generateAlertName(); + await test.step('Edit alert by adding mentions filter', async () => { + await visitEditAlertPage(page, data.alertDetails); - await test.step('Create alert', async () => { - await inputBasicAlertInformation({ - page, - name: ALERT_NAME, - sourceName: SOURCE_NAME_3, - sourceDisplayName: SOURCE_DISPLAY_NAME_3, - }); + // Add filter + await page.click('[data-testid="add-filters"]'); - // Select Destination - await page.click('[data-testid="add-destination-button"]'); - await addInternalDestination({ - page, - destinationNumber: 0, - category: 'Owners', - type: 'Email', - }); - await page.click('[data-testid="add-destination-button"]'); - await addInternalDestination({ - page, - destinationNumber: 1, - category: 'Assignees', - type: 'Email', - }); - data.alertDetails = await saveAlertAndVerifyResponse(page); + await addFilterWithUsersListInput({ + page, + filterNumber: 0, + updaterName: user1.getUserName(), + filterTestId: 'Mentioned Users-filter-option', + exclude: true, }); - await test.step('Delete alert', async () => { - await deleteAlert(page, data.alertDetails); + // Add Destination + await page.click('[data-testid="add-destination-button"]'); + await addInternalDestination({ + page, + destinationNumber: 1, + category: 'Mentions', + type: 'Slack', }); - }); - test('Conversation source alert', async ({ page }) => { - const ALERT_NAME = generateAlertName(); + // Click save + const updateAlert = page.waitForResponse( + (response) => + response.url().includes('/api/v1/events/subscriptions') && + response.request().method() === 'PATCH' && + response.status() === 200 + ); + await page.click('[data-testid="save-button"]'); + await updateAlert.then(async (response) => { + data.alertDetails = await response.json(); - await test.step('Create alert', async () => { - await inputBasicAlertInformation({ - page, - name: ALERT_NAME, - sourceName: SOURCE_NAME_4, - sourceDisplayName: SOURCE_DISPLAY_NAME_4, - }); + test.expect(response.status()).toEqual(200); - // Select Destination - await page.click('[data-testid="add-destination-button"]'); - await addInternalDestination({ - page, - destinationNumber: 0, - category: 'Owners', - type: 'Email', - }); + // Verify the edited alert changes + await verifyAlertDetails({ page, alertDetails: data.alertDetails }); + }); + }); - data.alertDetails = await saveAlertAndVerifyResponse(page); + await test.step('Delete alert', async () => { + await deleteAlert(page, data.alertDetails); + }); +}); + +test('Alert operations for a user with and without permissions', async ({ + page, + userWithPermissionsPage, + userWithoutPermissionsPage, +}) => { + test.slow(); + + const ALERT_NAME = generateAlertName(); + const { apiContext } = await getApiContext(page); + await visitNotificationAlertPage(userWithPermissionsPage); + + await test.step('Create and trigger alert', async () => { + data.alertDetails = await createAlertForRecentEventsCheck({ + page: userWithPermissionsPage, + alertName: ALERT_NAME, + sourceName: SOURCE_NAME_5, + sourceDisplayName: SOURCE_DISPLAY_NAME_5, + user: user1, + table, }); - await test.step('Edit alert by adding mentions filter', async () => { - await visitEditAlertPage(page, data.alertDetails); + // Trigger alert + await table.deleteTable(apiContext, false); + await table.restore(apiContext); + }); - // Add filter - await page.click('[data-testid="add-filters"]'); + await test.step('Checks for user without permission', async () => { + await checkAlertFlowForWithoutPermissionUser({ + page: userWithoutPermissionsPage, + alertDetails: data.alertDetails, + sourceName: SOURCE_NAME_5, + table, + }); + }); - await addFilterWithUsersListInput({ - page, - filterNumber: 0, - updaterName: user1.getUserName(), - filterTestId: 'Mentioned Users-filter-option', - exclude: true, + await test.step( + 'Check alert details page and Recent Events tab', + async () => { + await checkAlertDetailsForWithPermissionUser({ + page: userWithPermissionsPage, + alertDetails: data.alertDetails, + sourceName: SOURCE_NAME_5, + table, + user: user2, }); + } + ); - // Add Destination - await page.click('[data-testid="add-destination-button"]'); - await addInternalDestination({ - page, - destinationNumber: 1, - category: 'Mentions', - type: 'Slack', - }); + await test.step('Delete alert', async () => { + await deleteAlert(userWithPermissionsPage, data.alertDetails); + }); +}); - // Click save - const updateAlert = page.waitForResponse( - (response) => - response.url().includes('/api/v1/events/subscriptions') && - response.request().method() === 'PATCH' && - response.status() === 200 - ); - await page.click('[data-testid="save-button"]'); - await updateAlert.then(async (response) => { - data.alertDetails = await response.json(); +test('destination should work properly', async ({ page }) => { + await visitNotificationAlertPage(page); - expect(response.status()).toEqual(200); + await inputBasicAlertInformation({ + page, + name: 'test-name', + sourceName: SOURCE_NAME_1, + sourceDisplayName: SOURCE_DISPLAY_NAME_1, + }); - // Verify the edited alert changes - await verifyAlertDetails({ page, alertDetails: data.alertDetails }); - }); - }); + await page.click('[data-testid="add-destination-button"]'); + await addInternalDestination({ + page, + destinationNumber: 0, + category: 'Owners', + type: 'G Chat', + }); - await test.step('Delete alert', async () => { - await deleteAlert(page, data.alertDetails); - }); + await test.expect(page.getByTestId('test-destination-button')).toBeDisabled(); + + await addExternalDestination({ + page, + destinationNumber: 0, + category: 'G Chat', + input: 'https://google.com', + }); + + await page.click('[data-testid="add-destination-button"]'); + await addExternalDestination({ + page, + destinationNumber: 1, + category: 'Slack', + input: 'https://slack.com', + }); + + const testDestinations = page.waitForResponse( + (response) => + response.url().includes('/api/v1/events/subscriptions/testDestination') && + response.request().method() === 'POST' && + response.status() === 200 + ); + + await page.click('[data-testid="test-destination-button"]'); + + await testDestinations.then(async (response) => { + const testResults = await response.json(); + + for (const testResult of testResults) { + const isGChat = testResult.type === 'GChat'; + + await test + .expect( + page + .getByTestId(`destination-${isGChat ? 0 : 1}`) + .getByRole('alert') + .getByText(testResult.statusDetails.status) + ) + .toBeAttached(); + } }); }); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ObservabilityAlerts.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ObservabilityAlerts.spec.ts index 529fb6f7b132..8bd4d01a7eeb 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ObservabilityAlerts.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ObservabilityAlerts.spec.ts @@ -11,9 +11,8 @@ * limitations under the License. */ -import test, { expect } from '@playwright/test'; +import { Page, test as base } from '@playwright/test'; import { - ALERT_UPDATED_DESCRIPTION, INGESTION_PIPELINE_NAME, TEST_CASE_NAME, TEST_SUITE_NAME, @@ -21,133 +20,212 @@ import { import { Domain } from '../../support/domain/Domain'; import { PipelineClass } from '../../support/entity/PipelineClass'; import { TableClass } from '../../support/entity/TableClass'; +import { AdminClass } from '../../support/user/AdminClass'; import { UserClass } from '../../support/user/UserClass'; +import { performAdminLogin } from '../../utils/admin'; import { - addDomainFilter, - addEntityFQNFilter, - addExternalDestination, - addGetSchemaChangesAction, - addInternalDestination, - addOwnerFilter, - addPipelineStatusUpdatesAction, + commonCleanup, + commonPrerequisites, + createAlert, deleteAlert, generateAlertName, - getObservabilityCreationDetails, inputBasicAlertInformation, saveAlertAndVerifyResponse, verifyAlertDetails, visitAlertDetailsPage, - visitEditAlertPage, - visitObservabilityAlertPage, } from '../../utils/alert'; +import { getApiContext } from '../../utils/common'; import { - clickOutside, - createNewPage, - descriptionBox, -} from '../../utils/common'; + addExternalDestination, + checkAlertDetailsForWithPermissionUser, + checkAlertFlowForWithoutPermissionUser, + createCommonObservabilityAlert, + editObservabilityAlert, + getObservabilityCreationDetails, + visitObservabilityAlertPage, +} from '../../utils/observabilityAlert'; const table1 = new TableClass(); const table2 = new TableClass(); const pipeline = new PipelineClass(); const user1 = new UserClass(); const user2 = new UserClass(); +const admin = new AdminClass(); const domain = new Domain(); const SOURCE_NAME_1 = 'container'; const SOURCE_DISPLAY_NAME_1 = 'Container'; const SOURCE_NAME_2 = 'pipeline'; const SOURCE_DISPLAY_NAME_2 = 'Pipeline'; +const SOURCE_NAME_3 = 'table'; +const SOURCE_DISPLAY_NAME_3 = 'Table'; + +// Create 2 page and authenticate 1 with admin and another with normal user +const test = base.extend<{ + page: Page; + userWithPermissionsPage: Page; + userWithoutPermissionsPage: Page; +}>({ + page: async ({ browser }, use) => { + const page = await browser.newPage(); + await admin.login(page); + await use(page); + await page.close(); + }, + userWithPermissionsPage: async ({ browser }, use) => { + const page = await browser.newPage(); + await user1.login(page); + await use(page); + await page.close(); + }, + userWithoutPermissionsPage: async ({ browser }, use) => { + const page = await browser.newPage(); + await user2.login(page); + await use(page); + await page.close(); + }, +}); -// use the admin user to login -test.use({ storageState: 'playwright/.auth/admin.json' }); - -test.describe('Observability Alert Flow', () => { - const data = { - alertDetails: { - id: '', - name: '', - displayName: '', - description: '', - filteringRules: { resources: [] }, - input: { filters: [], actions: [] }, - destinations: [], - }, - }; - - test.beforeAll(async ({ browser }) => { - const { apiContext, afterAction } = await createNewPage(browser); - await table1.create(apiContext); - await table2.create(apiContext); - await table1.createTestSuiteAndPipelines(apiContext, { - name: TEST_SUITE_NAME, - }); - await table1.createTestCase(apiContext, { name: TEST_CASE_NAME }); - await pipeline.create(apiContext); - await pipeline.createIngestionPipeline(apiContext, INGESTION_PIPELINE_NAME); - await user1.create(apiContext); - await user2.create(apiContext); - await domain.create(apiContext); - await afterAction(); +const data = { + alertDetails: { + id: '', + name: '', + displayName: '', + description: '', + filteringRules: { resources: [] }, + input: { filters: [], actions: [] }, + destinations: [], + }, +}; + +test.beforeAll(async ({ browser }) => { + const { afterAction, apiContext } = await performAdminLogin(browser); + await commonPrerequisites({ + apiContext, + table: table2, + user1, + user2, + domain, + }); + + await table1.create(apiContext); + await table1.createTestSuiteAndPipelines(apiContext, { + name: TEST_SUITE_NAME, }); + await table1.createTestCase(apiContext, { name: TEST_CASE_NAME }); + await pipeline.create(apiContext); + await pipeline.createIngestionPipeline(apiContext, INGESTION_PIPELINE_NAME); - test.afterAll(async ({ browser }) => { - const { apiContext, afterAction } = await createNewPage(browser); - await table1.delete(apiContext); - await table2.delete(apiContext); - await pipeline.delete(apiContext); - await user1.delete(apiContext); - await user2.delete(apiContext); - await domain.delete(apiContext); - await afterAction(); + await afterAction(); +}); + +test.afterAll(async ({ browser }) => { + const { afterAction, apiContext } = await performAdminLogin(browser); + await commonCleanup({ + apiContext, + table: table2, + user1, + user2, + domain, + }); + await table1.delete(apiContext); + await pipeline.delete(apiContext); + + await afterAction(); +}); + +test.beforeEach(async ({ page }) => { + await visitObservabilityAlertPage(page); +}); + +test('Pipeline Alert', async ({ page }) => { + test.slow(); + + const ALERT_NAME = generateAlertName(); + + await test.step('Create alert', async () => { + data.alertDetails = await createAlert({ + page, + alertName: ALERT_NAME, + sourceName: SOURCE_NAME_1, + sourceDisplayName: SOURCE_DISPLAY_NAME_1, + user: user1, + createButtonId: 'create-observability', + selectId: 'Owner Name', + addTrigger: true, + }); }); - test.beforeEach(async ({ page }) => { + await test.step('Check created alert details', async () => { await visitObservabilityAlertPage(page); + await visitAlertDetailsPage(page, data.alertDetails); + + // Verify alert details + await verifyAlertDetails({ page, alertDetails: data.alertDetails }); }); - test('Pipeline Alert', async ({ page }) => { - test.slow(); + await test.step('Edit alert', async () => { + await editObservabilityAlert({ + page, + alertDetails: data.alertDetails, + sourceName: SOURCE_NAME_2, + sourceDisplayName: SOURCE_DISPLAY_NAME_2, + user: user1, + domain, + pipeline, + }); - const ALERT_NAME = generateAlertName(); + // Click save + const updateAlert = page.waitForResponse( + (response) => + response.url().includes('/api/v1/events/subscriptions') && + response.request().method() === 'PATCH' && + response.status() === 200 + ); + await page.click('[data-testid="save-button"]'); + await updateAlert.then(async (response) => { + data.alertDetails = await response.json(); - await test.step('Create alert', async () => { - await inputBasicAlertInformation({ - page, - name: ALERT_NAME, - sourceName: SOURCE_NAME_1, - sourceDisplayName: SOURCE_DISPLAY_NAME_1, - createButtonId: 'create-observability', - }); + test.expect(response.status()).toEqual(200); - // Select filters - await page.click('[data-testid="add-filters"]'); + // Verify the edited alert changes + await verifyAlertDetails({ page, alertDetails: data.alertDetails }); + }); + }); - await addOwnerFilter({ - page, - filterNumber: 0, - ownerName: user1.getUserName(), - selectId: 'Owner Name', - }); + await test.step('Delete alert', async () => { + await deleteAlert(page, data.alertDetails, false); + }); +}); - // Select trigger - await page.click('[data-testid="add-trigger"]'); +const OBSERVABILITY_CREATION_DETAILS = getObservabilityCreationDetails({ + tableName1: table1.entity.name, + tableName2: table2.entity.name, + testSuiteFQN: TEST_SUITE_NAME, + testCaseName: TEST_CASE_NAME, + ingestionPipelineName: INGESTION_PIPELINE_NAME, + domainName: domain.data.name, + domainDisplayName: domain.data.displayName, + userName: `${user1.data.firstName}${user1.data.lastName}`, +}); - await addGetSchemaChangesAction({ - page, - filterNumber: 0, - }); +for (const alertDetails of OBSERVABILITY_CREATION_DETAILS) { + const { source, sourceDisplayName, filters, actions } = alertDetails; - await page.getByTestId('connection-timeout-input').clear(); - await page.fill('[data-testid="connection-timeout-input"]', '26'); + test(`${sourceDisplayName} alert`, async ({ page }) => { + const ALERT_NAME = generateAlertName(); - // Select Destination - await page.click('[data-testid="add-destination-button"]'); + test.slow(true); - await addInternalDestination({ + await test.step('Create alert', async () => { + await createCommonObservabilityAlert({ page, - destinationNumber: 0, - category: 'Admins', - type: 'Email', + alertName: ALERT_NAME, + sourceName: source, + sourceDisplayName: sourceDisplayName, + alertDetails, + filters: filters, + actions: actions, }); // Click save @@ -162,273 +240,144 @@ test.describe('Observability Alert Flow', () => { await verifyAlertDetails({ page, alertDetails: data.alertDetails }); }); - await test.step('Edit alert', async () => { - await visitEditAlertPage(page, data.alertDetails, false); - - // Update description - await page.locator(descriptionBox).clear(); - await page.locator(descriptionBox).fill(ALERT_UPDATED_DESCRIPTION); - - // Update source - await page.click('[data-testid="source-select"]'); - await page - .getByTestId(`${SOURCE_NAME_2}-option`) - .getByText(SOURCE_DISPLAY_NAME_2) - .click(); - - // Filters should reset after source change - await expect(page.getByTestId('filter-select-0')).not.toBeAttached(); - - // Add owner filter - await page.click('[data-testid="add-filters"]'); - await addOwnerFilter({ - page, - filterNumber: 0, - ownerName: user1.getUserName(), - selectId: 'Owner Name', - }); - - // Add entityFQN filter - await page.click('[data-testid="add-filters"]'); - await addEntityFQNFilter({ - page, - filterNumber: 1, - entityFQN: ( - pipeline.entityResponseData as { fullyQualifiedName: string } - ).fullyQualifiedName, - selectId: 'Pipeline Name', - exclude: true, - }); - // Add domain filter - await page.click('[data-testid="add-filters"]'); - await addDomainFilter({ - page, - filterNumber: 2, - domainName: domain.responseData.name, - domainDisplayName: domain.responseData.displayName, - }); - - // Add trigger - await page.click('[data-testid="add-trigger"]'); - - await addPipelineStatusUpdatesAction({ - page, - filterNumber: 0, - statusName: 'Successful', - exclude: true, - }); - - // Add multiple destinations - await page.click('[data-testid="add-destination-button"]'); - await addInternalDestination({ - page, - destinationNumber: 0, - category: 'Owners', - type: 'G Chat', - }); - - // Add team Slack destination - await page.click('[data-testid="add-destination-button"]'); - await addInternalDestination({ - page, - destinationNumber: 1, - category: 'Teams', - type: 'Slack', - typeId: 'Team-select', - searchText: 'Organization', - }); - - // Click save - const updateAlert = page.waitForResponse( - (response) => - response.url().includes('/api/v1/events/subscriptions') && - response.request().method() === 'PATCH' && - response.status() === 200 - ); - await page.click('[data-testid="save-button"]'); - await updateAlert.then(async (response) => { - data.alertDetails = await response.json(); - - expect(response.status()).toEqual(200); - - // Verify the edited alert changes - await verifyAlertDetails({ page, alertDetails: data.alertDetails }); - }); - }); - await test.step('Delete alert', async () => { await deleteAlert(page, data.alertDetails, false); }); }); +} + +test('Alert operations for a user with and without permissions', async ({ + page, + userWithPermissionsPage, + userWithoutPermissionsPage, +}) => { + test.slow(); + + const ALERT_NAME = generateAlertName(); + const { apiContext } = await getApiContext(page); + await visitObservabilityAlertPage(userWithPermissionsPage); + + await test.step('Create and trigger alert', async () => { + await inputBasicAlertInformation({ + page: userWithPermissionsPage, + name: ALERT_NAME, + sourceName: SOURCE_NAME_3, + sourceDisplayName: SOURCE_DISPLAY_NAME_3, + createButtonId: 'create-observability', + }); + await userWithPermissionsPage.click('[data-testid="add-filters"]'); + + // Select filter + await userWithPermissionsPage.click('[data-testid="filter-select-0"]'); + await userWithPermissionsPage.click( + '.ant-select-dropdown:visible [data-testid="Table Name-filter-option"]' + ); + + // Search and select filter input value + const searchOptions = userWithPermissionsPage.waitForResponse( + '/api/v1/search/query?q=*' + ); + await userWithPermissionsPage.fill( + `[data-testid="fqn-list-select"] [role="combobox"]`, + table1.entity.name, + { + force: true, + } + ); + + await searchOptions; + + await userWithPermissionsPage.click( + `.ant-select-dropdown:visible [title="${table1.entity.name}"]` + ); + + // Check if option is selected + await test + .expect( + userWithPermissionsPage.locator( + `[data-testid="fqn-list-select"] [title="${table1.entity.name}"]` + ) + ) + .toBeAttached(); + + await userWithPermissionsPage.click('[data-testid="add-trigger"]'); + + // Select action + await userWithPermissionsPage.click('[data-testid="trigger-select-0"]'); + + // Adding the dropdown visibility check to avoid flakiness here + await userWithPermissionsPage.waitForSelector( + `.ant-select-dropdown:visible`, + { + state: 'visible', + } + ); + await userWithPermissionsPage.click( + '.ant-select-dropdown:visible [data-testid="Get Schema Changes-filter-option"]:visible' + ); + await userWithPermissionsPage.waitForSelector( + `.ant-select-dropdown:visible`, + { + state: 'hidden', + } + ); + + await userWithPermissionsPage.click( + '[data-testid="add-destination-button"]' + ); + await addExternalDestination({ + page: userWithPermissionsPage, + destinationNumber: 0, + category: 'Slack', + input: 'https://slack.com', + }); - const OBSERVABILITY_CREATION_DETAILS = getObservabilityCreationDetails({ - tableName1: table1.entity.name, - tableName2: table2.entity.name, - testSuiteFQN: TEST_SUITE_NAME, - testCaseName: TEST_CASE_NAME, - ingestionPipelineName: INGESTION_PIPELINE_NAME, - domainName: domain.data.name, - domainDisplayName: domain.data.displayName, - userName: `${user1.data.firstName}${user1.data.lastName}`, + // Click save + data.alertDetails = await saveAlertAndVerifyResponse( + userWithPermissionsPage + ); + + // Trigger alert + await table1.patch({ + apiContext, + patchData: [ + { + op: 'add', + path: '/columns/4', + value: { + name: 'new_field', + dataType: 'VARCHAR', + dataLength: 100, + dataTypeDisplay: 'varchar(100)', + }, + }, + ], + }); }); - for (const alertDetails of OBSERVABILITY_CREATION_DETAILS) { - const { source, sourceDisplayName, filters, actions } = alertDetails; - - test(`${sourceDisplayName} alert`, async ({ page }) => { - const ALERT_NAME = generateAlertName(); - - test.slow(true); - - await test.step('Create alert', async () => { - await inputBasicAlertInformation({ - page, - name: ALERT_NAME, - sourceName: source, - sourceDisplayName: sourceDisplayName, - createButtonId: 'create-observability', - }); - - for (const filter of filters) { - const filterNumber = filters.indexOf(filter); - - await page.click('[data-testid="add-filters"]'); - - // Select filter - await page.click(`[data-testid="filter-select-${filterNumber}"]`); - await page.click( - `.ant-select-dropdown:visible [data-testid="${filter.name}-filter-option"]` - ); - - // Search and select filter input value - const searchOptions = page.waitForResponse( - '/api/v1/search/query?q=*' - ); - await page.fill( - `[data-testid="${filter.inputSelector}"] [role="combobox"]`, - filter.inputValue, - { - force: true, - } - ); - - await searchOptions; - - await page.click( - `.ant-select-dropdown:visible [title="${ - filter.inputValueId ?? filter.inputValue - }"]` - ); - - // Check if option is selected - await expect( - page.locator( - `[data-testid="${filter.inputSelector}"] [title="${ - filter.inputValueId ?? filter.inputValue - }"]` - ) - ).toBeAttached(); - - if (filter.exclude) { - // Change filter effect - await page.click(`[data-testid="filter-switch-${filterNumber}"]`); - } - } - - // Add triggers - for (const action of actions) { - const actionNumber = actions.indexOf(action); - - await page.click('[data-testid="add-trigger"]'); - - // Select action - await page.click(`[data-testid="trigger-select-${actionNumber}"]`); - - // Adding the dropdown visibility check to avoid flakiness here - await page.waitForSelector(`.ant-select-dropdown:visible`, { - state: 'visible', - }); - await page.click( - `.ant-select-dropdown:visible [data-testid="${action.name}-filter-option"]:visible` - ); - await page.waitForSelector(`.ant-select-dropdown:visible`, { - state: 'hidden', - }); - - if (action.inputs && action.inputs.length > 0) { - for (const input of action.inputs) { - const getSearchResult = page.waitForResponse( - '/api/v1/search/query?q=*' - ); - await page.fill( - `[data-testid="${input.inputSelector}"] [role="combobox"]`, - input.inputValue, - { - force: true, - } - ); - if (input.waitForAPI) { - await getSearchResult; - } - await page.click(`[title="${input.inputValue}"]:visible`); - - // eslint-disable-next-line jest/no-conditional-expect - await expect(page.getByTestId(input.inputSelector)).toHaveText( - input.inputValue - ); - - await clickOutside(page); - } - } - - if (action.exclude) { - // Change filter effect - await page.click(`[data-testid="trigger-switch-${actionNumber}"]`); - } - } - - // Add Destinations - for (const destination of alertDetails.destinations) { - const destinationNumber = - alertDetails.destinations.indexOf(destination); - - await page.click('[data-testid="add-destination-button"]'); - - if (destination.mode === 'internal') { - await addInternalDestination({ - page, - destinationNumber, - category: destination.category, - type: destination.type, - typeId: destination.inputSelector, - searchText: destination.inputValue, - }); - } else { - await addExternalDestination({ - page, - destinationNumber, - category: destination.category, - input: destination.inputValue, - secretKey: destination.secretKey, - }); - } - } - - // Click save - data.alertDetails = await saveAlertAndVerifyResponse(page); - }); - - await test.step('Check created alert details', async () => { - await visitObservabilityAlertPage(page); - await visitAlertDetailsPage(page, data.alertDetails); + await test.step('Checks for user without permission', async () => { + await checkAlertFlowForWithoutPermissionUser({ + page: userWithoutPermissionsPage, + alertDetails: data.alertDetails, + sourceName: SOURCE_NAME_3, + table: table1, + }); + }); - // Verify alert details - await verifyAlertDetails({ page, alertDetails: data.alertDetails }); + await test.step( + 'Check alert details page and Recent Events tab', + async () => { + await checkAlertDetailsForWithPermissionUser({ + page: userWithPermissionsPage, + alertDetails: data.alertDetails, + sourceName: SOURCE_NAME_3, + table: table1, + user: user2, }); + } + ); - await test.step('Delete alert', async () => { - await deleteAlert(page, data.alertDetails, false); - }); - }); - } + await test.step('Delete alert', async () => { + await deleteAlert(userWithPermissionsPage, data.alertDetails, false); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Tag.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Tag.spec.ts index 5f0345321891..90c8189fff5c 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Tag.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Tag.spec.ts @@ -10,242 +10,245 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { expect, test } from '@playwright/test'; +import { expect, Page, test as base } from '@playwright/test'; +import { DATA_STEWARD_RULES } from '../../constant/permission'; +import { PolicyClass } from '../../support/access-control/PoliciesClass'; +import { RolesClass } from '../../support/access-control/RolesClass'; import { ClassificationClass } from '../../support/tag/ClassificationClass'; import { TagClass } from '../../support/tag/TagClass'; -import { - createNewPage, - getApiContext, - redirectToHomePage, -} from '../../utils/common'; +import { UserClass } from '../../support/user/UserClass'; +import { performAdminLogin } from '../../utils/admin'; +import { redirectToHomePage } from '../../utils/common'; import { addAssetsToTag, checkAssetsCount, + editTagPageDescription, removeAssetsFromTag, setupAssetsForTag, + verifyTagPageUI, } from '../../utils/tag'; -test.use({ storageState: 'playwright/.auth/admin.json' }); +const adminUser = new UserClass(); +const dataConsumerUser = new UserClass(); +const dataStewardUser = new UserClass(); +const policy = new PolicyClass(); +const role = new RolesClass(); +const classification = new ClassificationClass({ + provider: 'system', + mutuallyExclusive: true, +}); +const tag = new TagClass({ + classification: classification.data.name, +}); + +const test = base.extend<{ + adminPage: Page; + dataConsumerPage: Page; + dataStewardPage: Page; +}>({ + adminPage: async ({ browser }, use) => { + const adminPage = await browser.newPage(); + await adminUser.login(adminPage); + await use(adminPage); + await adminPage.close(); + }, + dataConsumerPage: async ({ browser }, use) => { + const page = await browser.newPage(); + await dataConsumerUser.login(page); + await use(page); + await page.close(); + }, + dataStewardPage: async ({ browser }, use) => { + const page = await browser.newPage(); + await dataStewardUser.login(page); + await use(page); + await page.close(); + }, +}); -test.describe('Tag page', () => { +base.beforeAll('Setup pre-requests', async ({ browser }) => { + const { apiContext, afterAction } = await performAdminLogin(browser); + await adminUser.create(apiContext); + await adminUser.setAdminRole(apiContext); + await dataConsumerUser.create(apiContext); + await dataStewardUser.create(apiContext); + await dataStewardUser.setDataStewardRole(apiContext); + await policy.create(apiContext, DATA_STEWARD_RULES); + await role.create(apiContext, [policy.responseData.name]); + await classification.create(apiContext); + await tag.create(apiContext); + await afterAction(); +}); + +base.afterAll('Cleanup', async ({ browser }) => { + const { apiContext, afterAction } = await performAdminLogin(browser); + await adminUser.delete(apiContext); + await dataConsumerUser.delete(apiContext); + await dataStewardUser.delete(apiContext); + await policy.delete(apiContext); + await role.delete(apiContext); + await classification.delete(apiContext); + await tag.delete(apiContext); + await afterAction(); +}); + +test.describe('Tag Page with Admin Roles', () => { test.slow(true); - const classification = new ClassificationClass({ - provider: 'system', - mutuallyExclusive: true, + test('Verify Tag UI', async ({ adminPage }) => { + await verifyTagPageUI(adminPage, classification.data.name, tag); }); - test.beforeAll(async ({ browser }) => { - const { apiContext, afterAction } = await createNewPage(browser); - await classification.create(apiContext); - await afterAction(); - }); + test('Rename Tag name', async ({ adminPage }) => { + await redirectToHomePage(adminPage); + const res = adminPage.waitForResponse(`/api/v1/tags/name/*`); + await tag.visitPage(adminPage); + await res; + await adminPage.getByTestId('manage-button').click(); - test.afterAll(async ({ browser }) => { - const { apiContext, afterAction } = await createNewPage(browser); - await classification.delete(apiContext); - await afterAction(); - }); + await expect( + adminPage.locator('.ant-dropdown-placement-bottomRight') + ).toBeVisible(); - test('Verify Tag UI', async ({ page }) => { - await redirectToHomePage(page); - const { apiContext, afterAction } = await getApiContext(page); - const tag = new TagClass({ - classification: classification.data.name, - }); - try { - await tag.create(apiContext); - const res = page.waitForResponse(`/api/v1/tags/name/*`); - await tag.visitPage(page); - await res; + await adminPage.getByRole('menuitem', { name: 'Rename' }).click(); - await expect(page.getByText(tag.data.name)).toBeVisible(); - await expect(page.getByText(tag.data.description)).toBeVisible(); + await expect(adminPage.getByRole('dialog')).toBeVisible(); - const classificationTable = page.waitForResponse( - `/api/v1/classifications/name/*` - ); - await page.getByRole('link', { name: classification.data.name }).click(); - classificationTable; + await adminPage + .getByPlaceholder('Enter display name') + .fill('TestDisplayName'); - await page.getByTestId(tag.data.name).click(); - await res; + const updateName = adminPage.waitForResponse(`/api/v1/tags/*`); + await adminPage.getByTestId('save-button').click(); + updateName; - const classificationPage = page.waitForResponse( - `/api/v1/classifications*` - ); - await page.getByRole('link', { name: 'Classifications' }).click(); - await classificationPage; - } finally { - await tag.delete(apiContext); - await afterAction(); - } + await expect(adminPage.getByText('TestDisplayName')).toBeVisible(); }); - test('Rename Tag name', async ({ page }) => { - await redirectToHomePage(page); - const { apiContext, afterAction } = await getApiContext(page); - const tag = new TagClass({ - classification: classification.data.name, - }); - try { - await tag.create(apiContext); - const res = page.waitForResponse(`/api/v1/tags/name/*`); - await tag.visitPage(page); - await res; - await page.getByTestId('manage-button').click(); + test('Restyle Tag', async ({ adminPage }) => { + await redirectToHomePage(adminPage); + const res = adminPage.waitForResponse(`/api/v1/tags/name/*`); + await tag.visitPage(adminPage); + await res; + await adminPage.getByTestId('manage-button').click(); - await expect( - page.locator('.ant-dropdown-placement-bottomRight') - ).toBeVisible(); + await expect( + adminPage.locator('.ant-dropdown-placement-bottomRight') + ).toBeVisible(); - await page.getByRole('menuitem', { name: 'Rename' }).click(); + await adminPage.getByRole('menuitem', { name: 'Style' }).click(); - await expect(page.getByRole('dialog')).toBeVisible(); + await expect(adminPage.getByRole('dialog')).toBeVisible(); - await page.getByPlaceholder('Enter display name').fill('TestDisplayName'); + await adminPage.getByTestId('color-color-input').fill('#6366f1'); - const updateName = page.waitForResponse(`/api/v1/tags/*`); - await page.getByTestId('save-button').click(); - updateName; + const updateColor = adminPage.waitForResponse(`/api/v1/tags/*`); + await adminPage.locator('button[type="submit"]').click(); + updateColor; - await expect(page.getByText('TestDisplayName')).toBeVisible(); - } finally { - await tag.delete(apiContext); - await afterAction(); - } + await adminPage.waitForLoadState('networkidle'); + + await expect(adminPage.getByText(tag.data.name)).toBeVisible(); }); - test('Restyle Tag', async ({ page }) => { - await redirectToHomePage(page); - const { apiContext, afterAction } = await getApiContext(page); - const tag = new TagClass({ - classification: classification.data.name, - }); - try { - await tag.create(apiContext); - const res = page.waitForResponse(`/api/v1/tags/name/*`); - await tag.visitPage(page); - await res; - await page.getByTestId('manage-button').click(); + test('Edit Tag Description', async ({ adminPage }) => { + await redirectToHomePage(adminPage); + const res = adminPage.waitForResponse(`/api/v1/tags/name/*`); + await tag.visitPage(adminPage); + await res; + await adminPage.getByTestId('edit-description').click(); - await expect( - page.locator('.ant-dropdown-placement-bottomRight') - ).toBeVisible(); + await expect(adminPage.getByRole('dialog')).toBeVisible(); - await page.getByRole('menuitem', { name: 'Style' }).click(); + await adminPage.locator('.toastui-editor-pseudo-clipboard').clear(); + await adminPage + .locator('.toastui-editor-pseudo-clipboard') + .fill(`This is updated test description for tag ${tag.data.name}.`); - await expect(page.getByRole('dialog')).toBeVisible(); + const editDescription = adminPage.waitForResponse(`/api/v1/tags/*`); + await adminPage.getByTestId('save').click(); + await editDescription; - await page.getByTestId('color-color-input').fill('#6366f1'); + await expect(adminPage.getByTestId('viewer-container')).toContainText( + `This is updated test description for tag ${tag.data.name}.` + ); + }); - const updateColor = page.waitForResponse(`/api/v1/tags/*`); - await page.locator('button[type="submit"]').click(); - updateColor; + test('Delete a Tag', async ({ adminPage }) => { + await redirectToHomePage(adminPage); + const res = adminPage.waitForResponse(`/api/v1/tags/name/*`); + await tag.visitPage(adminPage); + await res; + await adminPage.getByTestId('manage-button').click(); - await expect(page.getByText(tag.data.name)).toBeVisible(); - } finally { - await tag.delete(apiContext); - await afterAction(); - } - }); + await expect( + adminPage.locator('.ant-dropdown-placement-bottomRight') + ).toBeVisible(); - test('Edit Tag Description', async ({ page }) => { - await redirectToHomePage(page); - const { apiContext, afterAction } = await getApiContext(page); - const tag = new TagClass({ - classification: classification.data.name, - }); - try { - await tag.create(apiContext); - const res = page.waitForResponse(`/api/v1/tags/name/*`); - await tag.visitPage(page); - await res; - await page.getByTestId('edit-description').click(); - - await expect(page.getByRole('dialog')).toBeVisible(); - - await page.locator('.toastui-editor-pseudo-clipboard').clear(); - await page - .locator('.toastui-editor-pseudo-clipboard') - .fill(`This is updated test description for tag ${tag.data.name}.`); - - const editDescription = page.waitForResponse(`/api/v1/tags/*`); - await page.getByTestId('save').click(); - await editDescription; - - await expect(page.getByTestId('viewer-container')).toContainText( - `This is updated test description for tag ${tag.data.name}.` - ); - } finally { - await tag.delete(apiContext); - await afterAction(); - } + await adminPage.getByRole('menuitem', { name: 'Delete' }).click(); + + await expect(adminPage.getByRole('dialog')).toBeVisible(); + + await adminPage.getByTestId('confirmation-text-input').fill('DELETE'); + + const deleteTag = adminPage.waitForResponse(`/api/v1/tags/*`); + await adminPage.getByTestId('confirm-button').click(); + deleteTag; + + await expect( + adminPage.getByText(classification.data.description) + ).toBeVisible(); }); - test('Delete a Tag', async ({ page }) => { - await redirectToHomePage(page); - const { apiContext, afterAction } = await getApiContext(page); - const tag = new TagClass({ - classification: classification.data.name, + test('Add and Remove Assets', async ({ adminPage }) => { + await redirectToHomePage(adminPage); + const { assets } = await setupAssetsForTag(adminPage); + + await test.step('Add Asset', async () => { + const res = adminPage.waitForResponse(`/api/v1/tags/name/*`); + await tag.visitPage(adminPage); + await res; + await addAssetsToTag(adminPage, assets); }); - try { - await tag.create(apiContext); - const res = page.waitForResponse(`/api/v1/tags/name/*`); - await tag.visitPage(page); + + await test.step('Delete Asset', async () => { + const res = adminPage.waitForResponse(`/api/v1/tags/name/*`); + await tag.visitPage(adminPage); await res; - await page.getByTestId('manage-button').click(); - await expect( - page.locator('.ant-dropdown-placement-bottomRight') - ).toBeVisible(); + await removeAssetsFromTag(adminPage, assets); + await checkAssetsCount(adminPage, 0); + }); + }); +}); - await page.getByRole('menuitem', { name: 'Delete' }).click(); +test.describe('Tag Page with Data Consumer Roles', () => { + test.slow(true); - await expect(page.getByRole('dialog')).toBeVisible(); + test('Verify Tag UI for Data Consumer', async ({ dataConsumerPage }) => { + await verifyTagPageUI( + dataConsumerPage, + classification.data.name, + tag, + true + ); + }); - await page.getByTestId('confirmation-text-input').fill('DELETE'); + test('Edit Tag Description or Data Consumer', async ({ + dataConsumerPage, + }) => { + await editTagPageDescription(dataConsumerPage, tag); + }); +}); - const deleteTag = page.waitForResponse(`/api/v1/tags/*`); - await page.getByTestId('confirm-button').click(); - deleteTag; +test.describe('Tag Page with Data Steward Roles', () => { + test.slow(true); - await expect( - page.getByText(classification.data.description) - ).toBeVisible(); - } finally { - await afterAction(); - } + test('Verify Tag UI for Data Steward', async ({ dataStewardPage }) => { + await verifyTagPageUI(dataStewardPage, classification.data.name, tag, true); }); - test('Add and Remove Assets', async ({ page }) => { - await redirectToHomePage(page); - const { apiContext, afterAction } = await getApiContext(page); - const tag = new TagClass({ - classification: classification.data.name, - }); - const { assets } = await setupAssetsForTag(page); - try { - await tag.create(apiContext); - const res = page.waitForResponse(`/api/v1/tags/name/*`); - await tag.visitPage(page); - await res; - - await test.step('Add Asset', async () => { - await addAssetsToTag(page, assets); - - await expect( - page.locator('[role="dialog"].ant-modal') - ).not.toBeVisible(); - }); - - await test.step('Delete Asset', async () => { - await removeAssetsFromTag(page, assets); - await checkAssetsCount(page, 0); - }); - } finally { - await tag.delete(apiContext); - await afterAction(); - } + test('Edit Tag Description for Data Steward', async ({ dataStewardPage }) => { + await editTagPageDescription(dataStewardPage, tag); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/Entity.interface.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/Entity.interface.ts index ca81a393cdcc..15c6c00ca113 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/Entity.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/Entity.interface.ts @@ -41,6 +41,7 @@ export enum EntityTypeEndpoint { METRIC = 'metrics', TestSuites = 'dataQuality/testSuites', Teams = 'teams', + NotificationAlert = 'events/subscriptions', } export type EntityDataType = { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/TableClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/TableClass.ts index c609f9a4f79c..b264c59d3a83 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/TableClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/TableClass.ts @@ -361,11 +361,11 @@ export class TableClass extends EntityClass { ); } - async delete(apiContext: APIRequestContext) { + async delete(apiContext: APIRequestContext, hardDelete = true) { const serviceResponse = await apiContext.delete( `/api/v1/services/databaseServices/name/${encodeURIComponent( this.serviceResponseData?.['fullyQualifiedName'] - )}?recursive=true&hardDelete=true` + )}?recursive=true&hardDelete=${hardDelete}` ); return { @@ -373,4 +373,23 @@ export class TableClass extends EntityClass { entity: this.entityResponseData, }; } + + async deleteTable(apiContext: APIRequestContext, hardDelete = true) { + const tableResponse = await apiContext.delete( + `/api/v1/tables/${this.entityResponseData?.['id']}?recursive=true&hardDelete=${hardDelete}` + ); + + return tableResponse; + } + + async restore(apiContext: APIRequestContext) { + const serviceResponse = await apiContext.put('/api/v1/tables/restore', { + data: { id: this.entityResponseData?.['id'] }, + }); + + return { + service: serviceResponse.body, + entity: this.entityResponseData, + }; + } } diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/alert.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/alert.ts index 9bd0cde198c9..e424bc5f885c 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/alert.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/alert.ts @@ -11,50 +11,146 @@ * limitations under the License. */ -import { expect, Page } from '@playwright/test'; +import { APIRequestContext, expect, Page } from '@playwright/test'; import { isEmpty, startCase } from 'lodash'; -import { ALERT_DESCRIPTION } from '../constant/alert'; import { - AlertDetails, - ObservabilityCreationDetails, -} from '../constant/alert.interface'; + ALERT_DESCRIPTION, + ALERT_WITHOUT_PERMISSION_POLICY_DETAILS, + ALERT_WITHOUT_PERMISSION_POLICY_NAME, + ALERT_WITHOUT_PERMISSION_ROLE_DETAILS, + ALERT_WITHOUT_PERMISSION_ROLE_NAME, + ALERT_WITH_PERMISSION_POLICY_DETAILS, + ALERT_WITH_PERMISSION_POLICY_NAME, + ALERT_WITH_PERMISSION_ROLE_DETAILS, + ALERT_WITH_PERMISSION_ROLE_NAME, +} from '../constant/alert'; +import { AlertDetails, EventDetails } from '../constant/alert.interface'; import { DELETE_TERM } from '../constant/common'; -import { SidebarItem } from '../constant/sidebar'; import { Domain } from '../support/domain/Domain'; import { DashboardClass } from '../support/entity/DashboardClass'; +import { TableClass } from '../support/entity/TableClass'; import { UserClass } from '../support/user/UserClass'; import { clickOutside, descriptionBox, - redirectToHomePage, + getApiContext, toastNotification, uuid, } from './common'; import { getEntityDisplayName } from './entity'; import { validateFormNameFieldInput } from './form'; -import { sidebarClick } from './sidebar'; +import { + addFilterWithUsersListInput, + addInternalDestination, + visitNotificationAlertPage, +} from './notificationAlert'; +import { visitObservabilityAlertPage } from './observabilityAlert'; export const generateAlertName = () => `0%alert-playwright-${uuid()}`; -export const visitNotificationAlertPage = async (page: Page) => { - await redirectToHomePage(page); - await sidebarClick(page, SidebarItem.SETTINGS); - const getAlerts = page.waitForResponse('/api/v1/events/subscriptions?*'); - const getActivityFeedAlertDetails = page.waitForResponse( - '/api/v1/events/subscriptions/name/ActivityFeedAlert?include=all' - ); - await page.click('[data-testid="notifications"]'); - await getAlerts; - await getActivityFeedAlertDetails; +export const commonPrerequisites = async ({ + apiContext, + user1, + user2, + domain, + table, +}: { + apiContext: APIRequestContext; + user1: UserClass; + user2: UserClass; + domain: Domain; + table: TableClass; +}) => { + await table.create(apiContext); + await user1.create(apiContext); + await user2.create(apiContext); + await domain.create(apiContext); + await apiContext.post('/api/v1/policies', { + data: ALERT_WITH_PERMISSION_POLICY_DETAILS, + }); + + await apiContext.post('/api/v1/policies', { + data: ALERT_WITHOUT_PERMISSION_POLICY_DETAILS, + }); + + const role1Response = await apiContext.post('/api/v1/roles', { + data: ALERT_WITH_PERMISSION_ROLE_DETAILS, + }); + + const role2Response = await apiContext.post('/api/v1/roles', { + data: ALERT_WITHOUT_PERMISSION_ROLE_DETAILS, + }); + + const role1Data = (await role1Response.json()) as { + id: string; + name: string; + }; + + const role2Data = (await role2Response.json()) as { + id: string; + name: string; + }; + + await user1.patch({ + apiContext, + patchData: [ + { + op: 'add', + path: '/roles/0', + value: { + id: role1Data.id, + type: 'role', + name: role1Data.name, + }, + }, + ], + }); + + await user2.patch({ + apiContext, + patchData: [ + { + op: 'add', + path: '/roles/0', + value: { + id: role2Data.id, + type: 'role', + name: role2Data.name, + }, + }, + ], + }); }; -export const visitObservabilityAlertPage = async (page: Page) => { - await redirectToHomePage(page); - const getAlerts = page.waitForResponse( - '/api/v1/events/subscriptions?*alertType=Observability*' +export const commonCleanup = async ({ + apiContext, + user1, + user2, + domain, + table, +}: { + apiContext: APIRequestContext; + user1: UserClass; + user2: UserClass; + domain: Domain; + table: TableClass; +}) => { + await user1.delete(apiContext); + await user2.delete(apiContext); + await domain.delete(apiContext); + await table.delete(apiContext); + await apiContext.delete( + `/api/v1/policies/name/${ALERT_WITH_PERMISSION_POLICY_NAME}?hardDelete=true` + ); + await apiContext.delete( + `/api/v1/policies/name/${ALERT_WITHOUT_PERMISSION_POLICY_NAME}?hardDelete=true` + ); + await apiContext.delete( + `/api/v1/roles/name/${ALERT_WITH_PERMISSION_ROLE_NAME}?hardDelete=true` + ); + await apiContext.delete( + `/api/v1/roles/name/${ALERT_WITHOUT_PERMISSION_ROLE_NAME}?hardDelete=true` ); - await sidebarClick(page, SidebarItem.OBSERVABILITY_ALERT); - await getAlerts; }; export const findPageWithAlert = async ( @@ -75,6 +171,47 @@ export const findPageWithAlert = async ( } }; +export const deleteAlertSteps = async ( + page: Page, + name: string, + displayName: string +) => { + await page.getByTestId(`alert-delete-${name}`).click(); + + await expect(page.locator('.ant-modal-header')).toHaveText( + `Delete subscription "${displayName}"` + ); + + await page.fill('[data-testid="confirmation-text-input"]', DELETE_TERM); + + const deleteAlert = page.waitForResponse( + (response) => + response.request().method() === 'DELETE' && response.status() === 200 + ); + await page.click('[data-testid="confirm-button"]'); + await deleteAlert; + + await toastNotification(page, `"${displayName}" deleted successfully!`); +}; + +export const deleteAlert = async ( + page: Page, + alertDetails: AlertDetails, + isNotificationAlert = true +) => { + if (isNotificationAlert) { + await visitNotificationAlertPage(page); + } else { + await visitObservabilityAlertPage(page); + } + await findPageWithAlert(page, alertDetails); + await deleteAlertSteps( + page, + alertDetails.name, + getEntityDisplayName(alertDetails) + ); +}; + export const visitEditAlertPage = async ( page: Page, alertDetails: AlertDetails, @@ -107,34 +244,15 @@ export const visitAlertDetailsPage = async ( const getAlertDetails = page.waitForResponse( '/api/v1/events/subscriptions/name/*' ); + const getEventRecords = page.waitForResponse( + '/api/v1/events/subscriptions/name/*/eventsRecord?listCountOnly=true' + ); await page .locator(`[data-row-key="${alertDetails.id}"]`) .getByText(getEntityDisplayName(alertDetails)) .click(); await getAlertDetails; -}; - -export const deleteAlertSteps = async ( - page: Page, - name: string, - displayName: string -) => { - await page.getByTestId(`alert-delete-${name}`).click(); - - await expect(page.locator('.ant-modal-header')).toHaveText( - `Delete subscription "${displayName}"` - ); - - await page.fill('[data-testid="confirmation-text-input"]', DELETE_TERM); - - const deleteAlert = page.waitForResponse( - (response) => - response.request().method() === 'DELETE' && response.status() === 200 - ); - await page.click('[data-testid="confirm-button"]'); - await deleteAlert; - - await toastNotification(page, `"${displayName}" deleted successfully!`); + await getEventRecords; }; export const addOwnerFilter = async ({ @@ -221,70 +339,35 @@ export const addEntityFQNFilter = async ({ export const addEventTypeFilter = async ({ page, filterNumber, - eventType, + eventTypes, exclude = false, }: { page: Page; filterNumber: number; - eventType: string; + eventTypes: string[]; exclude?: boolean; }) => { // Select event type filter await page.click(`[data-testid="filter-select-${filterNumber}"]`); await page.click(`[data-testid="Event Type-filter-option"]:visible`); - // Search and select event type - await page.fill( - '[data-testid="event-type-select"] [role="combobox"]', - eventType, - { - force: true, - } - ); - await page.click( - `.ant-select-dropdown:visible [title="${startCase(eventType)}"]` - ); - - await expect(page.getByTestId('event-type-select')).toHaveText( - startCase(eventType) - ); + for (const eventType of eventTypes) { + // Search and select event type + await page.fill( + '[data-testid="event-type-select"] [role="combobox"]', + eventType, + { + force: true, + } + ); + await page.click( + `.ant-select-dropdown:visible [title="${startCase(eventType)}"]` + ); - if (exclude) { - // Change filter effect - await page.click(`[data-testid="filter-switch-${filterNumber}"]`); + await expect( + page.getByTestId('event-type-select').getByTitle(startCase(eventType)) + ).toBeAttached(); } -}; - -export const addFilterWithUsersListInput = async ({ - page, - filterTestId, - filterNumber, - updaterName, - exclude = false, -}: { - page: Page; - filterTestId: string; - filterNumber: number; - updaterName: string; - exclude?: boolean; -}) => { - // Select updater name filter - await page.click(`[data-testid="filter-select-${filterNumber}"]`); - await page.click(`[data-testid="${filterTestId}"]:visible`); - - // Search and select user - const getSearchResult = page.waitForResponse('/api/v1/search/query?q=*'); - await page.fill( - '[data-testid="user-name-select"] [role="combobox"]', - updaterName, - { - force: true, - } - ); - await getSearchResult; - await page.click(`.ant-select-dropdown:visible [title="${updaterName}"]`); - - await expect(page.getByTestId('user-name-select')).toHaveText(updaterName); if (exclude) { // Change filter effect @@ -356,126 +439,6 @@ export const addGMEFilter = async ({ } }; -export const addInternalDestination = async ({ - page, - destinationNumber, - category, - typeId, - type = '', - searchText = '', -}: { - page: Page; - destinationNumber: number; - category: string; - typeId?: string; - type?: string; - searchText?: string; -}) => { - // Select destination category - await page.click( - `[data-testid="destination-category-select-${destinationNumber}"]` - ); - await page.click(`[data-testid="${category}-internal-option"]:visible`); - - // Select the receivers - if (typeId) { - if (category === 'Teams' || category === 'Users') { - await page.click( - `[data-testid="destination-${destinationNumber}"] [data-testid="dropdown-trigger-button"]` - ); - const getSearchResult = page.waitForResponse('/api/v1/search/query?q=*'); - await page.fill( - `[data-testid="team-user-select-dropdown-${destinationNumber}"]:visible [data-testid="search-input"]`, - searchText - ); - - await getSearchResult; - await page.click( - `.ant-dropdown:visible [data-testid="${searchText}-option-label"]` - ); - } else { - const getSearchResult = page.waitForResponse('/api/v1/search/query?q=*'); - await page.fill(`[data-testid="${typeId}"]`, searchText); - await getSearchResult; - await page.click(`.ant-select-dropdown:visible [title="${searchText}"]`); - } - await clickOutside(page); - } - - // Select destination type - await page.click( - `[data-testid="destination-type-select-${destinationNumber}"]` - ); - await page.click( - `.select-options-container [data-testid="${type}-external-option"]:visible` - ); - - // Check the added destination type - await expect( - page - .getByTestId(`destination-type-select-${destinationNumber}`) - .getByTestId(`${type}-external-option`) - ).toBeAttached(); -}; - -export const addExternalDestination = async ({ - page, - destinationNumber, - category, - secretKey, - input = '', -}: { - page: Page; - destinationNumber: number; - category: string; - input?: string; - secretKey?: string; -}) => { - // Select destination category - await page.click( - `[data-testid="destination-category-select-${destinationNumber}"]` - ); - - // Select external tab - await page.click(`[data-testid="tab-label-external"]:visible`); - - // Select destination category option - await page.click( - `[data-testid="destination-category-dropdown-${destinationNumber}"]:visible [data-testid="${category}-external-option"]:visible` - ); - - // Input the destination receivers value - if (category === 'Email') { - await page.fill( - `[data-testid="email-input-${destinationNumber}"] [role="combobox"]`, - input - ); - await page.keyboard.press('Enter'); - } else { - await page.fill( - `[data-testid="endpoint-input-${destinationNumber}"]`, - input - ); - } - - // Input the secret key value - if (category === 'Webhook' && secretKey) { - await page - .getByTestId(`destination-${destinationNumber}`) - .getByText('Advanced Configuration') - .click(); - - await expect( - page.getByTestId(`secret-key-input-${destinationNumber}`) - ).toBeVisible(); - - await page.fill( - `[data-testid="secret-key-input-${destinationNumber}"]`, - secretKey - ); - } -}; - const checkActionOrFilterDetails = async ({ page, filters, @@ -670,7 +633,7 @@ export const addMultipleFilters = async ({ await addEventTypeFilter({ page, filterNumber: 2, - eventType: 'entityCreated', + eventTypes: ['entityCreated'], }); // Add users list filter @@ -773,245 +736,189 @@ export const saveAlertAndVerifyResponse = async (page: Page) => { return data.alertDetails; }; -export const deleteAlert = async ( - page: Page, - alertDetails: AlertDetails, - isNotificationAlert = true -) => { - if (isNotificationAlert) { - await visitNotificationAlertPage(page); - } else { - await visitObservabilityAlertPage(page); +export const createAlert = async ({ + page, + alertName, + sourceName, + sourceDisplayName, + user, + createButtonId, + selectId, + addTrigger = false, +}: { + page: Page; + alertName: string; + sourceName: string; + sourceDisplayName: string; + user: UserClass; + createButtonId?: string; + selectId?: string; + addTrigger?: boolean; +}) => { + await inputBasicAlertInformation({ + page, + name: alertName, + sourceName, + sourceDisplayName, + createButtonId, + }); + + // Select filters + await page.click('[data-testid="add-filters"]'); + + await addOwnerFilter({ + page, + filterNumber: 0, + ownerName: user.getUserName(), + selectId, + }); + + if (addTrigger) { + // Select trigger + await page.click('[data-testid="add-trigger"]'); + + await addGetSchemaChangesAction({ + page, + filterNumber: 0, + }); + + await page.getByTestId('connection-timeout-input').clear(); + await page.fill('[data-testid="connection-timeout-input"]', '26'); } - await findPageWithAlert(page, alertDetails); - await deleteAlertSteps( + + // Select Destination + await page.click('[data-testid="add-destination-button"]'); + + await addInternalDestination({ page, - alertDetails.name, - getEntityDisplayName(alertDetails) - ); + destinationNumber: 0, + category: 'Admins', + type: 'Email', + }); + + return await saveAlertAndVerifyResponse(page); }; -export const getObservabilityCreationDetails = ({ - tableName1, - tableName2, - testCaseName, - ingestionPipelineName, - domainName, - domainDisplayName, - userName, - testSuiteFQN, +export const waitForRecentEventsToFinishExecution = async ( + page: Page, + name: string, + totalEventsCount: number +) => { + const { apiContext } = await getApiContext(page); + + await expect + .poll( + async () => { + const response = await apiContext + .get( + `/api/v1/events/subscriptions/name/${name}/eventsRecord?listCountOnly=true` + ) + .then((res) => res.json()); + + return ( + response.pendingEventsCount === 0 && + response.totalEventsCount === totalEventsCount + ); + }, + { + // Custom expect message for reporting, optional. + message: 'Wait for pending events to complete', + intervals: [5_000, 10_000, 15_000], + timeout: 600_000, + } + ) + // Move ahead when the pending events count is 0 + .toEqual(true); +}; + +export const checkRecentEventDetails = async ({ + page, + alertDetails, + table, + totalEventsCount, }: { - tableName1: string; - tableName2: string; - testCaseName: string; - ingestionPipelineName: string; - domainName: string; - domainDisplayName: string; - userName: string; - testSuiteFQN: string; -}): Array => { - return [ - { - source: 'table', - sourceDisplayName: 'Table', - filters: [ - { - name: 'Table Name', - inputSelector: 'fqn-list-select', - inputValue: tableName1, - exclude: true, - }, - { - name: 'Domain', - inputSelector: 'domain-select', - inputValue: domainName, - inputValueId: domainDisplayName, - exclude: false, - }, - { - name: 'Owner Name', - inputSelector: 'owner-name-select', - inputValue: userName, - exclude: true, - }, - ], - actions: [ - { - name: 'Get Schema Changes', - exclude: true, - }, - { - name: 'Get Table Metrics Updates', - exclude: false, - }, - ], - destinations: [ - { - mode: 'internal', - category: 'Owners', - type: 'Email', - }, - { - mode: 'external', - category: 'Webhook', - inputValue: 'https://webhook.com', - secretKey: 'secret_key', - }, - ], - }, - { - source: 'ingestionPipeline', - sourceDisplayName: 'Ingestion Pipeline', - filters: [ - { - name: 'Ingestion Pipeline Name', - inputSelector: 'fqn-list-select', - inputValue: ingestionPipelineName, - exclude: false, - }, - { - name: 'Domain', - inputSelector: 'domain-select', - inputValue: domainName, - inputValueId: domainDisplayName, - exclude: false, - }, - { - name: 'Owner Name', - inputSelector: 'owner-name-select', - inputValue: userName, - exclude: true, - }, - ], - actions: [ - { - name: 'Get Ingestion Pipeline Status Updates', - inputs: [ - { - inputSelector: 'pipeline-status-select', - inputValue: 'Queued', - }, - ], - exclude: false, - }, - ], - destinations: [ - { - mode: 'internal', - category: 'Owners', - type: 'Email', - }, - { - mode: 'external', - category: 'Email', - inputValue: 'test@example.com', - }, - ], - }, - { - source: 'testCase', - sourceDisplayName: 'Test case', - filters: [ - { - name: 'Test Case Name', - inputSelector: 'fqn-list-select', - inputValue: testCaseName, - exclude: true, - }, - { - name: 'Domain', - inputSelector: 'domain-select', - inputValue: domainName, - inputValueId: domainDisplayName, - exclude: false, - }, - { - name: 'Owner Name', - inputSelector: 'owner-name-select', - inputValue: userName, - exclude: true, - }, - { - name: 'Table Name A Test Case Belongs To', - inputSelector: 'table-name-select', - inputValue: tableName2, - exclude: false, - }, - ], - actions: [ - { - name: 'Get Test Case Status Updates', - inputs: [ - { - inputSelector: 'test-result-select', - inputValue: 'Success', - }, - ], - exclude: false, - }, - { - name: 'Get Test Case Status Updates belonging to a Test Suite', - inputs: [ - { - inputSelector: 'test-suite-select', - inputValue: testSuiteFQN, - waitForAPI: true, - }, - { - inputSelector: 'test-status-select', - inputValue: 'Failed', - }, - ], - exclude: false, - }, - ], - destinations: [ - { - mode: 'internal', - category: 'Users', - inputSelector: 'User-select', - inputValue: userName, - type: 'Email', - }, - { - mode: 'external', - category: 'Webhook', - inputValue: 'https://webhook.com', - }, - ], - }, - { - source: 'testSuite', - sourceDisplayName: 'Test Suite', - filters: [ - { - name: 'Test Suite Name', - inputSelector: 'fqn-list-select', - inputValue: testSuiteFQN, - exclude: true, - }, - { - name: 'Domain', - inputSelector: 'domain-select', - inputValue: domainName, - inputValueId: domainDisplayName, - exclude: false, - }, - { - name: 'Owner Name', - inputSelector: 'owner-name-select', - inputValue: userName, - exclude: false, - }, - ], - actions: [], - destinations: [ - { - mode: 'external', - category: 'Slack', - inputValue: 'https://slack.com', - }, - ], - }, - ]; + page: Page; + alertDetails: AlertDetails; + table: TableClass; + totalEventsCount: number; +}) => { + await expect(page.getByTestId('total-events-count')).toHaveText( + `Total Events: ${totalEventsCount}` + ); + + await expect(page.getByTestId('failed-events-count')).toHaveText( + 'Failed Events: 0' + ); + + // Verify Recent Events tab + const getRecentEvents = page.waitForResponse( + (response) => + response + .url() + .includes( + `/api/v1/events/subscriptions/id/${alertDetails.id}/listEvents?limit=15&paginationOffset=0` + ) && + response.request().method() === 'GET' && + response.status() === 200 + ); + + await page.getByRole('tab').getByText('Recent Events').click(); + + await getRecentEvents.then(async (response) => { + const recentEvents: EventDetails[] = (await response.json()).data; + + // Check the event details + for (const event of recentEvents) { + // Open collapse + await page.getByTestId(`event-collapse-${event.data[0].id}`).click(); + + await page.waitForSelector( + `[data-testid="event-details-${event.data[0].id}"]` + ); + + // Check if table id is present in event details + await expect( + page + .getByTestId(`event-details-${event.data[0].id}`) + .getByTestId('event-data-entityId') + .getByTestId('event-data-value') + ).toContainText((table.entityResponseData as { id: string }).id); + + // Check if event type is present in event details + await expect( + page + .getByTestId(`event-details-${event.data[0].id}`) + .getByTestId('event-data-eventType') + .getByTestId('event-data-value') + ).toContainText(event.data[0].eventType); + + // Close collapse + await page.getByTestId(`event-collapse-${event.data[0].id}`).click(); + } + }); + + await page.getByTestId('filter-button').click(); + + await page.waitForSelector( + '.ant-dropdown-menu[role="menu"] [data-menu-id*="failed"]' + ); + + const getFailedEvents = page.waitForResponse( + (response) => + response + .url() + .includes( + `/api/v1/events/subscriptions/id/${alertDetails.id}/listEvents?status=failed&limit=15&paginationOffset=0` + ) && + response.request().method() === 'GET' && + response.status() === 200 + ); + + await page.click('.ant-dropdown-menu[role="menu"] [data-menu-id*="failed"]'); + + await getFailedEvents.then(async (response) => { + const failedEvents: EventDetails[] = (await response.json()).data; + + expect(failedEvents).toHaveLength(0); + }); }; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts index ee90bd1c754c..42b2d3db4e15 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts @@ -318,13 +318,23 @@ export const removeTier = async (page: Page) => { await expect(page.getByTestId('Tier')).toContainText('No Tier'); }; -export const updateDescription = async (page: Page, description: string) => { +export const updateDescription = async ( + page: Page, + description: string, + isModal = false +) => { await page.getByTestId('edit-description').click(); await page.locator('.ProseMirror').first().click(); await page.locator('.ProseMirror').first().clear(); await page.locator('.ProseMirror').first().fill(description); await page.getByTestId('save').click(); + if (isModal) { + await page.waitForSelector('[role="dialog"].description-markdown-editor', { + state: 'hidden', + }); + } + isEmpty(description) ? await expect( page.getByTestId('asset-description-container') diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/notificationAlert.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/notificationAlert.ts new file mode 100644 index 000000000000..45ff9d05482c --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/notificationAlert.ts @@ -0,0 +1,540 @@ +/* + * Copyright 2024 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect, Page } from '@playwright/test'; +import { ALERT_UPDATED_DESCRIPTION } from '../constant/alert'; +import { AlertDetails } from '../constant/alert.interface'; +import { SidebarItem } from '../constant/sidebar'; +import { Domain } from '../support/domain/Domain'; +import { DashboardClass } from '../support/entity/DashboardClass'; +import { EntityTypeEndpoint } from '../support/entity/Entity.interface'; +import { TableClass } from '../support/entity/TableClass'; +import { UserClass } from '../support/user/UserClass'; +import { + addEntityFQNFilter, + addEventTypeFilter, + addMultipleFilters, + checkRecentEventDetails, + inputBasicAlertInformation, + saveAlertAndVerifyResponse, + visitAlertDetailsPage, + visitEditAlertPage, + waitForRecentEventsToFinishExecution, +} from './alert'; +import { clickOutside, descriptionBox, redirectToHomePage } from './common'; +import { addMultiOwner, updateDescription } from './entity'; +import { addExternalDestination } from './observabilityAlert'; +import { sidebarClick } from './sidebar'; + +export const visitNotificationAlertPage = async (page: Page) => { + await redirectToHomePage(page); + await sidebarClick(page, SidebarItem.SETTINGS); + const getAlerts = page.waitForResponse('/api/v1/events/subscriptions?*'); + const getActivityFeedAlertDetails = page.waitForResponse( + '/api/v1/events/subscriptions/name/ActivityFeedAlert?include=all' + ); + await page.click('[data-testid="notifications"]'); + await getAlerts; + await getActivityFeedAlertDetails; +}; + +export const addFilterWithUsersListInput = async ({ + page, + filterTestId, + filterNumber, + updaterName, + exclude = false, +}: { + page: Page; + filterTestId: string; + filterNumber: number; + updaterName: string; + exclude?: boolean; +}) => { + // Select updater name filter + await page.click(`[data-testid="filter-select-${filterNumber}"]`); + await page.click(`[data-testid="${filterTestId}"]:visible`); + + // Search and select user + const getSearchResult = page.waitForResponse('/api/v1/search/query?q=*'); + await page.fill( + '[data-testid="user-name-select"] [role="combobox"]', + updaterName, + { + force: true, + } + ); + await getSearchResult; + await page.click(`.ant-select-dropdown:visible [title="${updaterName}"]`); + + await expect(page.getByTestId('user-name-select')).toHaveText(updaterName); + + if (exclude) { + // Change filter effect + await page.click(`[data-testid="filter-switch-${filterNumber}"]`); + } +}; + +export const addInternalDestination = async ({ + page, + destinationNumber, + category, + typeId, + type = '', + searchText = '', +}: { + page: Page; + destinationNumber: number; + category: string; + typeId?: string; + type?: string; + searchText?: string; +}) => { + // Select destination category + await page.click( + `[data-testid="destination-category-select-${destinationNumber}"]` + ); + await page.click(`[data-testid="${category}-internal-option"]:visible`); + + // Select the receivers + if (typeId) { + if (category === 'Teams' || category === 'Users') { + await page.click( + `[data-testid="destination-${destinationNumber}"] [data-testid="dropdown-trigger-button"]` + ); + const getSearchResult = page.waitForResponse('/api/v1/search/query?q=*'); + await page.fill( + `[data-testid="team-user-select-dropdown-${destinationNumber}"]:visible [data-testid="search-input"]`, + searchText + ); + + await getSearchResult; + await page.click( + `.ant-dropdown:visible [data-testid="${searchText}-option-label"]` + ); + } else { + const getSearchResult = page.waitForResponse('/api/v1/search/query?q=*'); + await page.fill(`[data-testid="${typeId}"]`, searchText); + await getSearchResult; + await page.click(`.ant-select-dropdown:visible [title="${searchText}"]`); + } + await clickOutside(page); + } + + // Select destination type + await page.click( + `[data-testid="destination-type-select-${destinationNumber}"]` + ); + await page.click( + `.select-options-container [data-testid="${type}-external-option"]:visible` + ); + + // Check the added destination type + await expect( + page + .getByTestId(`destination-type-select-${destinationNumber}`) + .getByTestId(`${type}-external-option`) + ).toBeAttached(); +}; + +export const editSingleFilterAlert = async ({ + page, + alertDetails, + sourceName, + sourceDisplayName, + user1, + user2, + domain, + dashboard, +}: { + page: Page; + alertDetails: AlertDetails; + sourceName: string; + sourceDisplayName: string; + user1: UserClass; + user2: UserClass; + domain: Domain; + dashboard: DashboardClass; +}) => { + await visitEditAlertPage(page, alertDetails); + + // Update description + await page.locator(descriptionBox).clear(); + await page.locator(descriptionBox).fill(ALERT_UPDATED_DESCRIPTION); + + // Update source + await page.click('[data-testid="source-select"]'); + await page + .getByTestId(`${sourceName}-option`) + .getByText(sourceDisplayName) + .click(); + + // Filters should reset after source change + await expect(page.getByTestId('filter-select-0')).not.toBeAttached(); + + await addMultipleFilters({ + page, + user1, + user2, + domain, + dashboard, + }); + + await page.getByTestId('connection-timeout-input').clear(); + await page.fill('[data-testid="connection-timeout-input"]', '26'); + + // Add owner GChat destination + await page.click('[data-testid="add-destination-button"]'); + await addInternalDestination({ + page, + destinationNumber: 0, + category: 'Owners', + type: 'G Chat', + }); + + // Add team Slack destination + await page.click('[data-testid="add-destination-button"]'); + await addInternalDestination({ + page, + destinationNumber: 1, + category: 'Teams', + type: 'Slack', + typeId: 'Team-select', + searchText: 'Organization', + }); + + // Add user email destination + await page.click('[data-testid="add-destination-button"]'); + await addInternalDestination({ + page, + destinationNumber: 2, + category: 'Users', + type: 'Email', + typeId: 'User-select', + searchText: user1.getUserName(), + }); +}; + +export const createAlertWithMultipleFilters = async ({ + page, + alertName, + sourceName, + sourceDisplayName, + user1, + user2, + domain, + dashboard, +}: { + page: Page; + alertName: string; + sourceName: string; + sourceDisplayName: string; + user1: UserClass; + user2: UserClass; + domain: Domain; + dashboard: DashboardClass; +}) => { + await inputBasicAlertInformation({ + page, + name: alertName, + sourceName, + sourceDisplayName, + }); + + await addMultipleFilters({ + page, + user1, + user2, + domain, + dashboard, + }); + + await page.click('[data-testid="add-destination-button"]'); + await addInternalDestination({ + page, + destinationNumber: 0, + category: 'Followers', + type: 'Email', + }); + await page.click('[data-testid="add-destination-button"]'); + await addExternalDestination({ + page, + destinationNumber: 1, + category: 'Email', + input: 'test@example.com', + }); + await page.click('[data-testid="add-destination-button"]'); + await addExternalDestination({ + page, + destinationNumber: 2, + category: 'G Chat', + input: 'https://gchat.com', + }); + await page.click('[data-testid="add-destination-button"]'); + await addExternalDestination({ + page, + destinationNumber: 3, + category: 'Webhook', + input: 'https://webhook.com', + }); + await page.click('[data-testid="add-destination-button"]'); + await addExternalDestination({ + page, + destinationNumber: 4, + category: 'Ms Teams', + input: 'https://msteams.com', + }); + await page.click('[data-testid="add-destination-button"]'); + await addExternalDestination({ + page, + destinationNumber: 5, + category: 'Slack', + input: 'https://slack.com', + }); + + return await saveAlertAndVerifyResponse(page); +}; + +export const createTaskAlert = async ({ + page, + alertName, + sourceName, + sourceDisplayName, +}: { + page: Page; + alertName: string; + sourceName: string; + sourceDisplayName: string; +}) => { + await inputBasicAlertInformation({ + page, + name: alertName, + sourceName, + sourceDisplayName, + }); + + // Select Destination + await page.click('[data-testid="add-destination-button"]'); + await addInternalDestination({ + page, + destinationNumber: 0, + category: 'Owners', + type: 'Email', + }); + await page.click('[data-testid="add-destination-button"]'); + await addInternalDestination({ + page, + destinationNumber: 1, + category: 'Assignees', + type: 'Email', + }); + + return await saveAlertAndVerifyResponse(page); +}; + +export const createConversationAlert = async ({ + page, + alertName, + sourceName, + sourceDisplayName, +}: { + page: Page; + alertName: string; + sourceName: string; + sourceDisplayName: string; +}) => { + await inputBasicAlertInformation({ + page, + name: alertName, + sourceName, + sourceDisplayName, + }); + + // Select Destination + await page.click('[data-testid="add-destination-button"]'); + await addInternalDestination({ + page, + destinationNumber: 0, + category: 'Owners', + type: 'Email', + }); + + return await saveAlertAndVerifyResponse(page); +}; + +export const checkAlertConfigDetails = async ({ + page, + sourceName, +}: { + page: Page; + sourceName: string; +}) => { + // Verify alert configs + await expect(page.getByTestId('source-select')).toHaveText(sourceName); + + await expect(page.getByTestId('filter-select-0')).toHaveText('Event Type'); + await expect( + page.getByTestId('event-type-select').getByTitle('Entity Restored') + ).toBeAttached(); + await expect( + page.getByTestId('event-type-select').getByTitle('Entity Soft Deleted') + ).toBeAttached(); + + await expect(page.getByTestId('filter-select-1')).toHaveText('Entity FQN'); + + await expect(page.getByTestId('destination-category-select-0')).toHaveText( + 'Owners' + ); + await expect(page.getByTestId('destination-type-select-0')).toHaveText( + 'Email' + ); +}; + +export const checkAlertDetailsForWithPermissionUser = async ({ + page, + alertDetails, + sourceName, + table, + user, +}: { + page: Page; + alertDetails: AlertDetails; + sourceName: string; + table: TableClass; + user: UserClass; +}) => { + await visitNotificationAlertPage(page); + await visitAlertDetailsPage(page, alertDetails); + + // Change alert owner + await addMultiOwner({ + page, + ownerNames: [user.responseData.displayName], + activatorBtnDataTestId: 'edit-owner', + endpoint: EntityTypeEndpoint.NotificationAlert, + type: 'Users', + }); + + // UpdateDescription + await updateDescription(page, ALERT_UPDATED_DESCRIPTION, true); + + // Check other configs + await checkAlertConfigDetails({ page, sourceName }); + await checkRecentEventDetails({ + page, + alertDetails, + table, + totalEventsCount: 2, + }); +}; + +export const checkAlertFlowForWithoutPermissionUser = async ({ + page, + alertDetails, + sourceName, + table, +}: { + page: Page; + alertDetails: AlertDetails; + sourceName: string; + table: TableClass; +}) => { + await visitNotificationAlertPage(page); + + await expect(page.getByTestId('create-notification')).not.toBeAttached(); + + await expect( + page.getByTestId(`alert-edit-${alertDetails.name}`) + ).not.toBeAttached(); + + await expect( + page.getByTestId(`alert-delete-${alertDetails.name}`) + ).not.toBeAttached(); + + // Wait for events to finish execution + await waitForRecentEventsToFinishExecution(page, alertDetails.name, 2); + + await visitAlertDetailsPage(page, alertDetails); + + await expect(page.getByTestId('edit-owner')).not.toBeAttached(); + + await expect(page.getByTestId('edit-description')).not.toBeAttached(); + + await expect(page.getByTestId('edit-button')).not.toBeAttached(); + + await expect(page.getByTestId('delete-button')).not.toBeAttached(); + + await checkAlertConfigDetails({ page, sourceName }); + await checkRecentEventDetails({ + page, + alertDetails, + table, + totalEventsCount: 2, + }); +}; + +export const createAlertForRecentEventsCheck = async ({ + page, + alertName, + sourceName, + sourceDisplayName, + createButtonId, + table, +}: { + page: Page; + alertName: string; + sourceName: string; + sourceDisplayName: string; + user: UserClass; + createButtonId?: string; + selectId?: string; + addTrigger?: boolean; + table: TableClass; +}) => { + await inputBasicAlertInformation({ + page, + name: alertName, + sourceName, + sourceDisplayName, + createButtonId, + }); + + // Add entityFQN filter + await page.click('[data-testid="add-filters"]'); + await addEntityFQNFilter({ + page, + filterNumber: 0, + entityFQN: (table.entityResponseData as { fullyQualifiedName: string }) + .fullyQualifiedName, + }); + + // Add event type filter + await page.click('[data-testid="add-filters"]'); + await addEventTypeFilter({ + page, + filterNumber: 1, + eventTypes: ['entitySoftDeleted', 'entityRestored'], + }); + + // Select Destination + await page.click('[data-testid="add-destination-button"]'); + + await addInternalDestination({ + page, + destinationNumber: 0, + category: 'Owners', + type: 'Email', + }); + + return await saveAlertAndVerifyResponse(page); +}; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/observabilityAlert.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/observabilityAlert.ts new file mode 100644 index 000000000000..ef68c74b5a88 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/observabilityAlert.ts @@ -0,0 +1,712 @@ +/* + * Copyright 2024 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect, Page } from '@playwright/test'; +import { ALERT_UPDATED_DESCRIPTION } from '../constant/alert'; +import { + AlertDetails, + ObservabilityCreationDetails, +} from '../constant/alert.interface'; +import { SidebarItem } from '../constant/sidebar'; +import { Domain } from '../support/domain/Domain'; +import { EntityTypeEndpoint } from '../support/entity/Entity.interface'; +import { PipelineClass } from '../support/entity/PipelineClass'; +import { TableClass } from '../support/entity/TableClass'; +import { UserClass } from '../support/user/UserClass'; +import { + addDomainFilter, + addEntityFQNFilter, + addOwnerFilter, + addPipelineStatusUpdatesAction, + checkRecentEventDetails, + inputBasicAlertInformation, + visitAlertDetailsPage, + visitEditAlertPage, + waitForRecentEventsToFinishExecution, +} from './alert'; +import { clickOutside, descriptionBox, redirectToHomePage } from './common'; +import { addMultiOwner, updateDescription } from './entity'; +import { addInternalDestination } from './notificationAlert'; +import { sidebarClick } from './sidebar'; + +export const visitObservabilityAlertPage = async (page: Page) => { + await redirectToHomePage(page); + const getAlerts = page.waitForResponse( + '/api/v1/events/subscriptions?*alertType=Observability*' + ); + await sidebarClick(page, SidebarItem.OBSERVABILITY_ALERT); + await getAlerts; +}; + +export const addExternalDestination = async ({ + page, + destinationNumber, + category, + secretKey, + input = '', +}: { + page: Page; + destinationNumber: number; + category: string; + input?: string; + secretKey?: string; +}) => { + // Select destination category + await page.click( + `[data-testid="destination-category-select-${destinationNumber}"]` + ); + + // Select external tab + await page.click(`[data-testid="tab-label-external"]:visible`); + + // Select destination category option + await page.click( + `[data-testid="destination-category-dropdown-${destinationNumber}"]:visible [data-testid="${category}-external-option"]:visible` + ); + + // Input the destination receivers value + if (category === 'Email') { + await page.fill( + `[data-testid="email-input-${destinationNumber}"] [role="combobox"]`, + input + ); + await page.keyboard.press('Enter'); + } else { + await page.fill( + `[data-testid="endpoint-input-${destinationNumber}"]`, + input + ); + } + + // Input the secret key value + if (category === 'Webhook' && secretKey) { + await page + .getByTestId(`destination-${destinationNumber}`) + .getByText('Advanced Configuration') + .click(); + + await expect( + page.getByTestId(`secret-key-input-${destinationNumber}`) + ).toBeVisible(); + + await page.fill( + `[data-testid="secret-key-input-${destinationNumber}"]`, + secretKey + ); + } + + await clickOutside(page); +}; + +export const getObservabilityCreationDetails = ({ + tableName1, + tableName2, + testCaseName, + ingestionPipelineName, + domainName, + domainDisplayName, + userName, + testSuiteFQN, +}: { + tableName1: string; + tableName2: string; + testCaseName: string; + ingestionPipelineName: string; + domainName: string; + domainDisplayName: string; + userName: string; + testSuiteFQN: string; +}): Array => { + return [ + { + source: 'table', + sourceDisplayName: 'Table', + filters: [ + { + name: 'Table Name', + inputSelector: 'fqn-list-select', + inputValue: tableName1, + exclude: true, + }, + { + name: 'Domain', + inputSelector: 'domain-select', + inputValue: domainName, + inputValueId: domainDisplayName, + exclude: false, + }, + { + name: 'Owner Name', + inputSelector: 'owner-name-select', + inputValue: userName, + exclude: true, + }, + ], + actions: [ + { + name: 'Get Schema Changes', + exclude: true, + }, + { + name: 'Get Table Metrics Updates', + exclude: false, + }, + ], + destinations: [ + { + mode: 'internal', + category: 'Owners', + type: 'Email', + }, + { + mode: 'external', + category: 'Webhook', + inputValue: 'https://webhook.com', + secretKey: 'secret_key', + }, + ], + }, + { + source: 'ingestionPipeline', + sourceDisplayName: 'Ingestion Pipeline', + filters: [ + { + name: 'Ingestion Pipeline Name', + inputSelector: 'fqn-list-select', + inputValue: ingestionPipelineName, + exclude: false, + }, + { + name: 'Domain', + inputSelector: 'domain-select', + inputValue: domainName, + inputValueId: domainDisplayName, + exclude: false, + }, + { + name: 'Owner Name', + inputSelector: 'owner-name-select', + inputValue: userName, + exclude: true, + }, + ], + actions: [ + { + name: 'Get Ingestion Pipeline Status Updates', + inputs: [ + { + inputSelector: 'pipeline-status-select', + inputValue: 'Queued', + }, + ], + exclude: false, + }, + ], + destinations: [ + { + mode: 'internal', + category: 'Owners', + type: 'Email', + }, + { + mode: 'external', + category: 'Email', + inputValue: 'test@example.com', + }, + ], + }, + { + source: 'testCase', + sourceDisplayName: 'Test case', + filters: [ + { + name: 'Test Case Name', + inputSelector: 'fqn-list-select', + inputValue: testCaseName, + exclude: true, + }, + { + name: 'Domain', + inputSelector: 'domain-select', + inputValue: domainName, + inputValueId: domainDisplayName, + exclude: false, + }, + { + name: 'Owner Name', + inputSelector: 'owner-name-select', + inputValue: userName, + exclude: true, + }, + { + name: 'Table Name A Test Case Belongs To', + inputSelector: 'table-name-select', + inputValue: tableName2, + exclude: false, + }, + ], + actions: [ + { + name: 'Get Test Case Status Updates', + inputs: [ + { + inputSelector: 'test-result-select', + inputValue: 'Success', + }, + ], + exclude: false, + }, + { + name: 'Get Test Case Status Updates belonging to a Test Suite', + inputs: [ + { + inputSelector: 'test-suite-select', + inputValue: testSuiteFQN, + waitForAPI: true, + }, + { + inputSelector: 'test-status-select', + inputValue: 'Failed', + }, + ], + exclude: false, + }, + ], + destinations: [ + { + mode: 'internal', + category: 'Users', + inputSelector: 'User-select', + inputValue: userName, + type: 'Email', + }, + { + mode: 'external', + category: 'Webhook', + inputValue: 'https://webhook.com', + }, + ], + }, + { + source: 'testSuite', + sourceDisplayName: 'Test Suite', + filters: [ + { + name: 'Test Suite Name', + inputSelector: 'fqn-list-select', + inputValue: testSuiteFQN, + exclude: true, + }, + { + name: 'Domain', + inputSelector: 'domain-select', + inputValue: domainName, + inputValueId: domainDisplayName, + exclude: false, + }, + { + name: 'Owner Name', + inputSelector: 'owner-name-select', + inputValue: userName, + exclude: false, + }, + ], + actions: [], + destinations: [ + { + mode: 'external', + category: 'Slack', + inputValue: 'https://slack.com', + }, + ], + }, + ]; +}; + +export const editObservabilityAlert = async ({ + page, + alertDetails, + sourceName, + sourceDisplayName, + user, + domain, + pipeline, +}: { + page: Page; + alertDetails: AlertDetails; + sourceName: string; + sourceDisplayName: string; + user: UserClass; + domain: Domain; + pipeline: PipelineClass; +}) => { + await visitEditAlertPage(page, alertDetails, false); + + // Update description + await page.locator(descriptionBox).clear(); + await page.locator(descriptionBox).fill(ALERT_UPDATED_DESCRIPTION); + + // Update source + await page.click('[data-testid="source-select"]'); + await page + .getByTestId(`${sourceName}-option`) + .getByText(sourceDisplayName) + .click(); + + // Filters should reset after source change + await expect(page.getByTestId('filter-select-0')).not.toBeAttached(); + + // Add owner filter + await page.click('[data-testid="add-filters"]'); + await addOwnerFilter({ + page, + filterNumber: 0, + ownerName: user.getUserName(), + selectId: 'Owner Name', + }); + + // Add entityFQN filter + await page.click('[data-testid="add-filters"]'); + await addEntityFQNFilter({ + page, + filterNumber: 1, + entityFQN: (pipeline.entityResponseData as { fullyQualifiedName: string }) + .fullyQualifiedName, + selectId: 'Pipeline Name', + exclude: true, + }); + // Add domain filter + await page.click('[data-testid="add-filters"]'); + await addDomainFilter({ + page, + filterNumber: 2, + domainName: domain.responseData.name, + domainDisplayName: domain.responseData.displayName, + }); + + // Add trigger + await page.click('[data-testid="add-trigger"]'); + + await addPipelineStatusUpdatesAction({ + page, + filterNumber: 0, + statusName: 'Successful', + exclude: true, + }); + + // Add multiple destinations + await page.click('[data-testid="add-destination-button"]'); + await addInternalDestination({ + page, + destinationNumber: 0, + category: 'Owners', + type: 'G Chat', + }); + + // Add team Slack destination + await page.click('[data-testid="add-destination-button"]'); + await addInternalDestination({ + page, + destinationNumber: 1, + category: 'Teams', + type: 'Slack', + typeId: 'Team-select', + searchText: 'Organization', + }); +}; + +export const createCommonObservabilityAlert = async ({ + page, + alertName, + alertDetails, + sourceName, + sourceDisplayName, + filters, + actions, +}: { + page: Page; + alertName: string; + alertDetails: ObservabilityCreationDetails; + sourceName: string; + sourceDisplayName: string; + filters: { + name: string; + inputSelector: string; + inputValue: string; + inputValueId?: string; + exclude?: boolean; + }[]; + actions: { + name: string; + exclude?: boolean; + inputs?: Array<{ + inputSelector: string; + inputValue: string; + waitForAPI?: boolean; + }>; + }[]; +}) => { + await inputBasicAlertInformation({ + page, + name: alertName, + sourceName, + sourceDisplayName, + createButtonId: 'create-observability', + }); + + for (const filter of filters) { + const filterNumber = filters.indexOf(filter); + + await page.click('[data-testid="add-filters"]'); + + // Select filter + await page.click(`[data-testid="filter-select-${filterNumber}"]`); + await page.click( + `.ant-select-dropdown:visible [data-testid="${filter.name}-filter-option"]` + ); + + // Search and select filter input value + const searchOptions = page.waitForResponse('/api/v1/search/query?q=*'); + await page.fill( + `[data-testid="${filter.inputSelector}"] [role="combobox"]`, + filter.inputValue, + { + force: true, + } + ); + + await searchOptions; + + await page.click( + `.ant-select-dropdown:visible [title="${ + filter.inputValueId ?? filter.inputValue + }"]` + ); + + // Check if option is selected + await expect( + page.locator( + `[data-testid="${filter.inputSelector}"] [title="${ + filter.inputValueId ?? filter.inputValue + }"]` + ) + ).toBeAttached(); + + if (filter.exclude) { + // Change filter effect + await page.click(`[data-testid="filter-switch-${filterNumber}"]`); + } + } + + // Add triggers + for (const action of actions) { + const actionNumber = actions.indexOf(action); + + await page.click('[data-testid="add-trigger"]'); + + // Select action + await page.click(`[data-testid="trigger-select-${actionNumber}"]`); + + // Adding the dropdown visibility check to avoid flakiness here + await page.waitForSelector(`.ant-select-dropdown:visible`, { + state: 'visible', + }); + await page.click( + `.ant-select-dropdown:visible [data-testid="${action.name}-filter-option"]:visible` + ); + await page.waitForSelector(`.ant-select-dropdown:visible`, { + state: 'hidden', + }); + + if (action.inputs && action.inputs.length > 0) { + for (const input of action.inputs) { + const getSearchResult = page.waitForResponse( + '/api/v1/search/query?q=*' + ); + await page.fill( + `[data-testid="${input.inputSelector}"] [role="combobox"]`, + input.inputValue, + { + force: true, + } + ); + if (input.waitForAPI) { + await getSearchResult; + } + await page.click(`[title="${input.inputValue}"]:visible`); + + // eslint-disable-next-line jest/no-conditional-expect + await expect(page.getByTestId(input.inputSelector)).toHaveText( + input.inputValue + ); + + await clickOutside(page); + } + } + + if (action.exclude) { + // Change filter effect + await page.click(`[data-testid="trigger-switch-${actionNumber}"]`); + } + } + + // Add Destinations + for (const destination of alertDetails.destinations) { + const destinationNumber = alertDetails.destinations.indexOf(destination); + + await page.click('[data-testid="add-destination-button"]'); + + if (destination.mode === 'internal') { + await addInternalDestination({ + page, + destinationNumber, + category: destination.category, + type: destination.type, + typeId: destination.inputSelector, + searchText: destination.inputValue, + }); + } else { + await addExternalDestination({ + page, + destinationNumber, + category: destination.category, + input: destination.inputValue, + secretKey: destination.secretKey, + }); + } + } +}; + +export const checkAlertConfigDetails = async ({ + page, + sourceName, + tableName, +}: { + page: Page; + sourceName: string; + tableName: string; +}) => { + // Verify alert configs + await expect(page.getByTestId('source-select')).toHaveText(sourceName); + + await expect(page.getByTestId('filter-select-0')).toHaveText('Table Name'); + await expect( + page.getByTestId('fqn-list-select').getByTitle(tableName) + ).toBeAttached(); + + await expect( + page + .getByTestId('trigger-select-0') + .getByTestId('Get Schema Changes-filter-option') + ).toBeAttached(); + + await expect( + page + .getByTestId('destination-category-select-0') + .getByTestId('Slack-external-option') + ).toBeAttached(); + await expect(page.getByTestId('endpoint-input-0')).toHaveValue( + 'https://slack.com' + ); +}; + +export const checkAlertFlowForWithoutPermissionUser = async ({ + page, + alertDetails, + sourceName, + table, +}: { + page: Page; + alertDetails: AlertDetails; + sourceName: string; + table: TableClass; +}) => { + await visitObservabilityAlertPage(page); + + await expect(page.getByTestId('create-observability')).not.toBeAttached(); + + await expect( + page.getByTestId(`alert-edit-${alertDetails.name}`) + ).not.toBeAttached(); + + await expect( + page.getByTestId(`alert-delete-${alertDetails.name}`) + ).not.toBeAttached(); + + // Wait for events to finish execution + await waitForRecentEventsToFinishExecution(page, alertDetails.name, 1); + + await visitAlertDetailsPage(page, alertDetails); + + await expect(page.getByTestId('edit-owner')).not.toBeAttached(); + + await expect(page.getByTestId('edit-description')).not.toBeAttached(); + + await expect(page.getByTestId('edit-button')).not.toBeAttached(); + + await expect(page.getByTestId('delete-button')).not.toBeAttached(); + + await checkAlertConfigDetails({ + page, + sourceName, + tableName: table.entity.name, + }); + await checkRecentEventDetails({ + page, + alertDetails, + table, + totalEventsCount: 1, + }); +}; + +export const checkAlertDetailsForWithPermissionUser = async ({ + page, + alertDetails, + sourceName, + table, + user, +}: { + page: Page; + alertDetails: AlertDetails; + sourceName: string; + table: TableClass; + user: UserClass; +}) => { + await visitObservabilityAlertPage(page); + await visitAlertDetailsPage(page, alertDetails); + + // Change alert owner + await addMultiOwner({ + page, + ownerNames: [user.responseData.displayName], + activatorBtnDataTestId: 'edit-owner', + endpoint: EntityTypeEndpoint.NotificationAlert, + type: 'Users', + }); + + // UpdateDescription + await updateDescription(page, ALERT_UPDATED_DESCRIPTION, true); + + // Check other configs + await checkAlertConfigDetails({ + page, + sourceName, + tableName: table.entity.name, + }); + await checkRecentEventDetails({ + page, + alertDetails, + table, + totalEventsCount: 1, + }); +}; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/tag.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/tag.ts index d88aeb599305..536542f315ac 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/tag.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/tag.ts @@ -17,6 +17,7 @@ import { DashboardClass } from '../support/entity/DashboardClass'; import { EntityClass } from '../support/entity/EntityClass'; import { TableClass } from '../support/entity/TableClass'; import { TopicClass } from '../support/entity/TopicClass'; +import { TagClass } from '../support/tag/TagClass'; import { getApiContext, NAME_MIN_MAX_LENGTH_VALIDATION_ERROR, @@ -83,6 +84,7 @@ export const removeAssetsFromTag = async ( page: Page, assets: EntityClass[] ) => { + await page.getByTestId('assets').click(); for (const asset of assets) { const fqn = get(asset, 'entityResponseData.fullyQualifiedName'); await page.locator(`[data-testid="table-data-card_${fqn}"] input`).check(); @@ -227,3 +229,69 @@ export const addTagToTableColumn = async ( ) ).toBeVisible(); }; + +export const verifyTagPageUI = async ( + page: Page, + classificationName: string, + tag: TagClass, + limitedAccess = false +) => { + await redirectToHomePage(page); + const res = page.waitForResponse(`/api/v1/tags/name/*`); + await tag.visitPage(page); + await res; + + await expect(page.getByTestId('entity-header-name')).toContainText( + tag.data.name + ); + await expect(page.getByText(tag.data.description)).toBeVisible(); + + if (limitedAccess) { + await expect( + page.getByTestId('data-classification-add-button') + ).not.toBeVisible(); + await expect(page.getByTestId('manage-button')).not.toBeVisible(); + await expect(page.getByTestId('add-domain')).not.toBeVisible(); + + // Asset tab should show no data placeholder and not add asset button + await page.getByTestId('assets').click(); + + await expect(page.getByTestId('no-data-placeholder')).toBeVisible(); + } + + const classificationTable = page.waitForResponse( + `/api/v1/classifications/name/*` + ); + await page.getByRole('link', { name: classificationName }).click(); + classificationTable; + + await page.getByTestId(tag.data.name).click(); + await res; + + const classificationPage = page.waitForResponse(`/api/v1/classifications*`); + await page.getByRole('link', { name: 'Classifications' }).click(); + await classificationPage; +}; + +export const editTagPageDescription = async (page: Page, tag: TagClass) => { + await redirectToHomePage(page); + const res = page.waitForResponse(`/api/v1/tags/name/*`); + await tag.visitPage(page); + await res; + await page.getByTestId('edit-description').click(); + + await expect(page.getByRole('dialog')).toBeVisible(); + + await page.locator('.toastui-editor-pseudo-clipboard').clear(); + await page + .locator('.toastui-editor-pseudo-clipboard') + .fill(`This is updated test description for tag ${tag.data.name}.`); + + const editDescription = page.waitForResponse(`/api/v1/tags/*`); + await page.getByTestId('save').click(); + await editDescription; + + await expect(page.getByTestId('viewer-container')).toContainText( + `This is updated test description for tag ${tag.data.name}.` + ); +}; diff --git a/openmetadata-ui/src/main/resources/ui/public/locales/en-US/Search/workflows/metadata.md b/openmetadata-ui/src/main/resources/ui/public/locales/en-US/Search/workflows/metadata.md index 5c492a34b285..dba0a78378a6 100644 --- a/openmetadata-ui/src/main/resources/ui/public/locales/en-US/Search/workflows/metadata.md +++ b/openmetadata-ui/src/main/resources/ui/public/locales/en-US/Search/workflows/metadata.md @@ -53,6 +53,17 @@ This is applicable for fields like description, tags, owner and displayName $$ +$$section +### Include Index Template $(id="includeIndexTemplate") + +`Include Index Template` toggle to manage the ingestion of index templates metadata from the source. + +If the toggle is `enabled`, index templates metadata will be ingested from the source. + +If the toggle is `disabled`, index templates metadata will not be ingested from the source. + +$$ + $$section ### Sample Size $(id="sampleSize") diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertRecentEventsTab/AlertRecentEventsTab.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertRecentEventsTab/AlertRecentEventsTab.test.tsx new file mode 100644 index 000000000000..b21b1a3fcbef --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertRecentEventsTab/AlertRecentEventsTab.test.tsx @@ -0,0 +1,116 @@ +/* + * Copyright 2024 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { act, fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import { + mockAlertDetails, + MOCK_TYPED_EVENT_LIST_RESPONSE, +} from '../../../../mocks/Alerts.mock'; +import { getAlertEventsFromId } from '../../../../rest/alertsAPI'; +import AlertRecentEventsTab from './AlertRecentEventsTab'; + +jest.mock('../../../../hooks/paging/usePaging', () => ({ + usePaging: jest.fn().mockReturnValue({ + currentPage: 8, + paging: {}, + pageSize: 5, + handlePagingChange: jest.fn(), + handlePageChange: jest.fn(), + handlePageSizeChange: jest.fn(), + showPagination: true, + }), +})); + +jest.mock('../../../../rest/alertsAPI', () => ({ + getAlertEventsFromId: jest + .fn() + .mockImplementation(() => Promise.resolve(MOCK_TYPED_EVENT_LIST_RESPONSE)), +})); + +jest.mock('../../../../utils/ToastUtils', () => ({ + showErrorToast: jest.fn(), +})); + +jest.mock('../../../common/ErrorWithPlaceholder/ErrorPlaceHolder', () => + jest.fn().mockImplementation(() =>
ErrorPlaceHolder
) +); + +jest.mock('../../../common/NextPreviousWithOffset/NextPreviousWithOffset', () => + jest.fn().mockImplementation(() =>
NextPreviousWithOffset
) +); + +jest.mock('../../../Database/SchemaEditor/SchemaEditor', () => + jest.fn().mockImplementation(() =>
SchemaEditor
) +); + +describe('AlertRecentEventsTab', () => { + it('should render the component', async () => { + await act(async () => { + render(); + }); + + expect(screen.getByText('label.description:')).toBeInTheDocument(); + expect( + screen.getByText('message.alert-recent-events-description') + ).toBeInTheDocument(); + + expect(screen.getByTestId('recent-events-list')).toBeInTheDocument(); + }); + + it('should display loading skeletons when loading', async () => { + await act(async () => { + render(); + + expect( + await screen.findAllByTestId('skeleton-loading-panel') + ).toHaveLength(5); + }); + }); + + it('should display error placeholder when no data is available', async () => { + (getAlertEventsFromId as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ data: [] }) + ); + + await act(async () => { + render(); + }); + + expect(screen.getByText('ErrorPlaceHolder')).toBeInTheDocument(); + }); + + it('should display recent events list', async () => { + await act(async () => { + render(); + }); + + expect(screen.getByTestId('recent-events-list')).toBeInTheDocument(); + }); + + it('should handle filter change', async () => { + await act(async () => { + render(); + }); + + const filterButton = screen.getByTestId('filter-button'); + fireEvent.click(filterButton); + + const filterOption = await screen.findByText('label.successful'); + fireEvent.click(filterOption); + + expect(await screen.findByTestId('applied-filter-text')).toHaveTextContent( + ': label.successful' + ); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertRecentEventsTab/AlertRecentEventsTab.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertRecentEventsTab/AlertRecentEventsTab.tsx index eb874189bd53..4b0049fccb04 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertRecentEventsTab/AlertRecentEventsTab.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertDetails/AlertRecentEventsTab/AlertRecentEventsTab.tsx @@ -134,7 +134,11 @@ function AlertRecentEventsTab({ alertDetails }: AlertRecentEventsTabProps) { return ( {Array.from({ length: 5 }).map((_, index) => ( - } key={index} /> + } + key={index} + /> ))} ); @@ -158,10 +162,9 @@ function AlertRecentEventsTab({ alertDetails }: AlertRecentEventsTabProps) { return ( - + {alertRecentEvents?.map((typedEvent) => { @@ -172,7 +175,9 @@ function AlertRecentEventsTab({ alertDetails }: AlertRecentEventsTabProps) { return ( + @@ -211,23 +216,31 @@ function AlertRecentEventsTab({ alertDetails }: AlertRecentEventsTabProps) { } key={`${changeEventData.id}-${changeEventData.timestamp}`}> - + {Object.entries(changeEventDataToDisplay).map( ([key, value]) => isUndefined(value) ? null : ( - + - + {`${getLabelsForEventDetails( key as keyof AlertEventDetailsToDisplay )}:`} - + {value} @@ -326,7 +339,9 @@ function AlertRecentEventsTab({ alertDetails }: AlertRecentEventsTabProps) { data-testid="filter-button" icon={}> {filter !== AlertRecentEventFilters.ALL && ( - {` : ${getAlertEventsFilterLabels( + {` : ${getAlertEventsFilterLabels( filter as AlertRecentEventFilters )}`} )} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/QualityTab/QualityTab.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/QualityTab/QualityTab.component.tsx index 4cb10b0ae923..464c3c98fe29 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/QualityTab/QualityTab.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/QualityTab/QualityTab.component.tsx @@ -11,11 +11,22 @@ * limitations under the License. */ import { DownOutlined } from '@ant-design/icons'; -import { Button, Col, Dropdown, Form, Row, Select, Space, Tabs } from 'antd'; +import { + Button, + Col, + Dropdown, + Form, + Row, + Select, + Space, + Tabs, + Tooltip, +} from 'antd'; import { isEmpty } from 'lodash'; import React, { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useHistory } from 'react-router-dom'; +import { ReactComponent as SettingIcon } from '../../../../../assets/svg/ic-settings-primery.svg'; import { getEntityDetailsPath, INITIAL_PAGING_VALUE, @@ -64,6 +75,7 @@ export const QualityTab = () => { testCasePaging, table, testCaseSummary, + onSettingButtonClick, } = useTableProfiler(); const { getResourceLimit } = useLimitStore(); @@ -76,7 +88,12 @@ export const QualityTab = () => { showPagination, } = testCasePaging; - const editTest = permissions.EditAll || permissions.EditTests; + const { editTest, editDataProfile } = useMemo(() => { + return { + editTest: permissions?.EditAll || permissions?.EditTests, + editDataProfile: permissions?.EditAll || permissions?.EditDataProfile, + }; + }, [permissions]); const { fqn: datasetFQN } = useFqn(); const history = useHistory(); const { t } = useTranslation(); @@ -293,6 +310,19 @@ export const QualityTab = () => { )} + + {editDataProfile && ( + + + + )} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/QualityTab/QualityTab.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/QualityTab/QualityTab.test.tsx index f88e571fb727..1ff14abad5ce 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/QualityTab/QualityTab.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/QualityTab/QualityTab.test.tsx @@ -35,6 +35,7 @@ const mockTable = { const mockPush = jest.fn(); const mockUseTableProfiler = { tableProfiler: MOCK_TABLE, + onSettingButtonClick: jest.fn(), permissions: { EditAll: true, EditDataProfile: true, @@ -136,6 +137,9 @@ describe('QualityTab', () => { 'message.page-sub-header-for-data-quality' ); expect(await screen.findByTestId('mock-searchbar')).toBeInTheDocument(); + expect( + await screen.findByTestId('profiler-setting-btn') + ).toBeInTheDocument(); expect( await screen.findByText('label.test-case-plural') ).toBeInTheDocument(); @@ -262,4 +266,20 @@ describe('QualityTab', () => { expect(await screen.findByText('label.success')).toBeInTheDocument(); expect(await screen.findByText('label.aborted')).toBeInTheDocument(); }); + + it('should call onSettingButtonClick', async () => { + await act(async () => { + render(); + }); + + const profilerSettingBtn = await screen.findByTestId( + 'profiler-setting-btn' + ); + + await act(async () => { + fireEvent.click(profilerSettingBtn); + }); + + expect(mockUseTableProfiler.onSettingButtonClick).toHaveBeenCalled(); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/AdvanceSearchProvider/AdvanceSearchProvider.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Explore/AdvanceSearchProvider/AdvanceSearchProvider.component.tsx index 53c2e18f7be6..9f612e84c287 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Explore/AdvanceSearchProvider/AdvanceSearchProvider.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/AdvanceSearchProvider/AdvanceSearchProvider.component.tsx @@ -21,6 +21,7 @@ import React, { } from 'react'; import { Config, + Field, FieldGroup, ImmutableTree, JsonTree, @@ -28,7 +29,10 @@ import { ValueField, } from 'react-awesome-query-builder'; import { useHistory, useParams } from 'react-router-dom'; -import { emptyJsonTree } from '../../../constants/AdvancedSearch.constants'; +import { + emptyJsonTree, + TEXT_FIELD_OPERATORS, +} from '../../../constants/AdvancedSearch.constants'; import { SearchIndex } from '../../../enums/search.enum'; import useCustomLocation from '../../../hooks/useCustomLocation/useCustomLocation'; import { TabsInfoData } from '../../../pages/ExplorePage/ExplorePage.interface'; @@ -214,7 +218,7 @@ export const AdvanceSearchProvider = ({ }, [history, location.pathname]); const fetchCustomPropertyType = async () => { - const subfields: Record = {}; + const subfields: Record = {}; try { const res = await getAllCustomProperties(); @@ -226,6 +230,7 @@ export const AdvanceSearchProvider = ({ subfields[field.name] = { type: 'text', valueSources: ['value'], + operators: TEXT_FIELD_OPERATORS, }; } }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/AssetsTabs.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/AssetsTabs.component.tsx index eacc9417c362..579be93c4fd4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/AssetsTabs.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/AssetsTabs.component.tsx @@ -89,6 +89,7 @@ import { escapeESReservedCharacters, getEncodedFqn, } from '../../../../utils/StringsUtils'; +import { getTagAssetsQueryFilter } from '../../../../utils/TagsUtils'; import { showErrorToast } from '../../../../utils/ToastUtils'; import ErrorPlaceHolder from '../../../common/ErrorWithPlaceholder/ErrorPlaceHolder'; import { ManageButtonItemLabel } from '../../../common/ManageButtonContentItem/ManageButtonContentItem.component'; @@ -215,7 +216,7 @@ const AssetsTabs = forwardRef( return queryFilter ?? ''; default: - return `(tags.tagFQN:"${encodedFqn}")`; + return getTagAssetsQueryFilter(encodedFqn); } }, [type, fqn, entityFqn]); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Modals/StopScheduleRun/StopScheduleRunModal.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Modals/StopScheduleRun/StopScheduleRunModal.interface.ts new file mode 100644 index 000000000000..fc7a0caf7b3c --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Modals/StopScheduleRun/StopScheduleRunModal.interface.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2022 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface StopScheduleRunModalProps { + appName: string; + isModalOpen: boolean; + displayName: string; + onClose: () => void; + onStopWorkflowsUpdate?: () => void; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Modals/StopScheduleRun/StopScheduleRunModal.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Modals/StopScheduleRun/StopScheduleRunModal.test.tsx new file mode 100644 index 000000000000..067aabdb3944 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Modals/StopScheduleRun/StopScheduleRunModal.test.tsx @@ -0,0 +1,87 @@ +/* + * Copyright 2024 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { stopApp } from '../../../rest/applicationAPI'; +import { showErrorToast } from '../../../utils/ToastUtils'; +import StopScheduleModal from './StopScheduleRunModal'; + +jest.mock('../../../rest/applicationAPI', () => ({ + stopApp: jest.fn(), +})); + +jest.mock('../../../utils/ToastUtils', () => ({ + showErrorToast: jest.fn(), + showSuccessToast: jest.fn(), +})); + +describe('StopScheduleModal', () => { + const mockProps = { + appName: 'test-app', + displayName: 'Test App', + isModalOpen: true, + onClose: jest.fn(), + onStopWorkflowsUpdate: jest.fn(), + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render the modal', () => { + render(); + + expect(screen.getByTestId('stop-modal')).toBeInTheDocument(); + }); + + it('should call stop app and display success toast on confirm', async () => { + (stopApp as jest.Mock).mockResolvedValueOnce({ status: 200 }); + + render(); + + const confirmButton = screen.getByText('label.confirm'); + fireEvent.click(confirmButton); + + expect(stopApp).toHaveBeenCalledWith('test-app'); + + await waitFor(() => { + expect(mockProps.onStopWorkflowsUpdate).toHaveBeenCalled(); + expect(mockProps.onClose).toHaveBeenCalled(); + }); + }); + + it('should call stop app and display error toast on failure', async () => { + (stopApp as jest.Mock).mockRejectedValueOnce(new Error('API Error')); + + render(); + + const confirmButton = screen.getByText('label.confirm'); + fireEvent.click(confirmButton); + + expect(stopApp).toHaveBeenCalledWith('test-app'); + + await waitFor(() => { + expect(showErrorToast).toHaveBeenCalledWith(new Error('API Error')); + expect(mockProps.onClose).toHaveBeenCalled(); + }); + }); + + it('should call onClose when cancel button is clicked', () => { + render(); + + const cancelButton = screen.getByText('label.cancel'); + fireEvent.click(cancelButton); + + expect(mockProps.onClose).toHaveBeenCalled(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Modals/StopScheduleRun/StopScheduleRunModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Modals/StopScheduleRun/StopScheduleRunModal.tsx new file mode 100644 index 000000000000..5372fd4706a8 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Modals/StopScheduleRun/StopScheduleRunModal.tsx @@ -0,0 +1,76 @@ +/* + * Copyright 2022 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Modal, Typography } from 'antd'; +import { AxiosError } from 'axios'; +import React, { FC, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { stopApp } from '../../../rest/applicationAPI'; +import { showErrorToast, showSuccessToast } from '../../../utils/ToastUtils'; +import { StopScheduleRunModalProps } from './StopScheduleRunModal.interface'; + +const StopScheduleModal: FC = ({ + appName, + isModalOpen, + displayName, + onClose, + onStopWorkflowsUpdate, +}) => { + const { t } = useTranslation(); + const [isLoading, setIsLoading] = useState(false); + + const handleConfirm = async () => { + setIsLoading(true); + try { + const { status } = await stopApp(appName); + if (status === 200) { + showSuccessToast( + t('message.application-stop', { + pipelineName: displayName, + }) + ); + onStopWorkflowsUpdate?.(); + } + } catch (error) { + // catch block error is unknown type so we have to cast it to respective type + showErrorToast(error as AxiosError); + } finally { + onClose(); + setIsLoading(false); + } + }; + + return ( + + + {t('message.are-you-sure-action-property', { + action: 'Stop', + propertyName: displayName, + })} + + + ); +}; + +export default StopScheduleModal; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/WelcomeScreen/WelcomeScreen.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MyData/WelcomeScreen/WelcomeScreen.component.tsx index f7e7d97c7f9a..25d7a3da02b4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/WelcomeScreen/WelcomeScreen.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/MyData/WelcomeScreen/WelcomeScreen.component.tsx @@ -49,7 +49,7 @@ const WelcomeScreen = ({ onClose }: WelcomeScreenProps) => { onClick={onClose} /> }> - + welcome screen image([]); const [expandedRowKeys, setExpandedRowKeys] = useState([]); + const [isStopModalOpen, setIsStopModalOpen] = useState(false); const { currentPage, @@ -132,29 +134,35 @@ const AppRunsHistory = forwardRef( const getActionButton = useCallback( (record: AppRunRecordWithId, index: number) => { - if (appData?.appType === AppType.Internal) { + if ( + appData?.appType === AppType.Internal || + (isExternalApp && index === 0) + ) { return ( - - ); - } else if (isExternalApp && index === 0) { - return ( - + <> + + {/* For status running or activewitherror and supportsInterrupt is true, show stop button */} + {(record.status === Status.Running || + record.status === Status.ActiveError) && + Boolean(appData?.supportsInterrupt) && ( + + )} + ); } else { return NO_DATA_PLACEHOLDER; @@ -347,47 +355,62 @@ const AppRunsHistory = forwardRef( }, [socket]); return ( - - - ( - - ), - showExpandColumn: false, - rowExpandable: (record) => !showLogAction(record), - expandedRowKeys, + <> + + +
( + + ), + showExpandColumn: false, + rowExpandable: (record) => !showLogAction(record), + expandedRowKeys, + }} + loading={isLoading} + locale={{ + emptyText: , + }} + pagination={false} + rowKey="id" + size="small" + /> + + + {showPagination && paginationVisible && ( + + )} + + + {isStopModalOpen && ( + { + setIsStopModalOpen(false); }} - loading={isLoading} - locale={{ - emptyText: , + onStopWorkflowsUpdate={() => { + fetchAppHistory(); }} - pagination={false} - rowKey="id" - size="small" /> - - - {showPagination && paginationVisible && ( - - )} - - + )} + ); } ); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/AppRunsHistory/AppRunsHistory.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/AppRunsHistory/AppRunsHistory.test.tsx index 9b4223c9ed15..b8f49f1a728e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/AppRunsHistory/AppRunsHistory.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/AppRunsHistory/AppRunsHistory.test.tsx @@ -142,6 +142,15 @@ const mockProps2 = { }, }; +const mockProps3 = { + ...mockProps1, + appData: { + ...mockProps1.appData, + supportsInterrupt: true, + status: Status.Running, + }, +}; + describe('AppRunsHistory component', () => { it('should contain all necessary elements based on mockProps1', async () => { render(); @@ -160,6 +169,11 @@ describe('AppRunsHistory component', () => { expect(screen.queryByText('--')).not.toBeInTheDocument(); expect(screen.getByText('NextPrevious')).toBeInTheDocument(); + + // Verify Stop button is not present as initial status is success + const stopButton = screen.queryByTestId('stop-button'); + + expect(stopButton).not.toBeInTheDocument(); }); it('should show the error toast if fail in fetching app history', async () => { @@ -247,4 +261,32 @@ describe('AppRunsHistory component', () => { expect(screen.getByText('--')).toBeInTheDocument(); }); + + it('should render the stop button when conditions are met', async () => { + const mockRunRecordWithStopButton = { + ...mockApplicationData, + status: Status.Running, // Ensures Stop button condition is met + supportsInterrupt: true, + }; + mockGetApplicationRuns.mockReturnValueOnce({ + data: [mockRunRecordWithStopButton], + paging: { + offset: 0, + total: 1, + }, + }); + + render(); + await waitForElementToBeRemoved(() => screen.getByText('TableLoader')); + + const stopButton = screen.getByTestId('stop-button'); + + expect(stopButton).toBeInTheDocument(); + + act(() => { + userEvent.click(stopButton); + }); + + expect(screen.getByTestId('stop-modal')).toBeInTheDocument(); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/AddIngestion/Steps/ScheduleInterval.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/AddIngestion/Steps/ScheduleInterval.interface.ts index dbfbc53f6ba8..8a893a814d90 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/AddIngestion/Steps/ScheduleInterval.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/AddIngestion/Steps/ScheduleInterval.interface.ts @@ -13,6 +13,7 @@ import { LoadingState } from 'Models'; import { ReactNode } from 'react'; +import { SchedularOptions } from '../../../../../enums/Schedular.enum'; export type ScheduleIntervalProps = { status: LoadingState; @@ -34,6 +35,11 @@ export type ScheduleIntervalProps = { }; topChildren?: ReactNode; showActionButtons?: boolean; + schedularOptions?: { + title: string; + description: string; + value: SchedularOptions; + }[]; }; export interface WorkflowExtraConfig { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/AddIngestion/Steps/ScheduleInterval.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/AddIngestion/Steps/ScheduleInterval.tsx index a854a5b9da78..61c883551519 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/AddIngestion/Steps/ScheduleInterval.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/AddIngestion/Steps/ScheduleInterval.tsx @@ -79,6 +79,7 @@ const ScheduleInterval = ({ defaultSchedule, topChildren, showActionButtons = true, + schedularOptions = SCHEDULAR_OPTIONS, }: ScheduleIntervalProps) => { const { t } = useTranslation(); // Since includePeriodOptions can limit the schedule options @@ -224,7 +225,7 @@ const ScheduleInterval = ({ className="schedular-card-container" data-testid="schedular-card-container" value={selectedSchedular}> - {SCHEDULAR_OPTIONS.map(({ description, title, value }) => ( + {schedularOptions.map(({ description, title, value }) => ( !isOrganization && !isUndefined(currentUser) && + isGroupType && (isAlreadyJoinedTeam ? ( - {editPermission && ( - - - {`${t( - 'label.description' - )} :`} - - - )} + + + ({ + Link: jest + .fn() + .mockImplementation( + ({ children, ...props }: { children: React.ReactNode }) => ( +

{children}

+ ) + ), + useHistory: jest.fn().mockImplementation(() => ({ + push: mockPush, + })), +})); jest.mock('../../rest/alertsAPI', () => ({ getAllAlerts: jest.fn().mockImplementation(() => @@ -85,12 +101,43 @@ jest.mock( } ); +jest.mock('../../components/common/DeleteWidget/DeleteWidgetModal', () => { + return jest + .fn() + .mockImplementation(({ visible }) => + visible ?

DeleteWidgetModal

: null + ); +}); + jest.mock('../../hoc/LimitWrapper', () => { return jest .fn() .mockImplementation(({ children }) => <>LimitWrapper{children}); }); +jest.mock('../../context/PermissionProvider/PermissionProvider', () => ({ + usePermissionProvider: jest.fn().mockReturnValue({ + getEntityPermissionByFqn: jest.fn().mockReturnValue({ + Create: true, + Delete: true, + ViewAll: true, + EditAll: true, + EditDescription: true, + EditDisplayName: true, + EditCustomFields: true, + }), + getResourcePermission: jest.fn().mockReturnValue({ + Create: true, + Delete: true, + ViewAll: true, + EditAll: true, + EditDescription: true, + EditDisplayName: true, + EditCustomFields: true, + }), + }), +})); + describe('Notification Alerts Page Tests', () => { it('Title should be rendered', async () => { await act(async () => { @@ -139,7 +186,7 @@ describe('Notification Alerts Page Tests', () => { }); it('Table should render no data', async () => { - (getAllAlerts as jest.Mock).mockImplementation(() => + (getAllAlerts as jest.Mock).mockImplementationOnce(() => Promise.resolve({ data: [], paging: { total: 1 }, @@ -168,4 +215,88 @@ describe('Notification Alerts Page Tests', () => { {} ); }); + + it('should render edit and delete buttons for alerts with permissions', async () => { + await act(async () => { + render(, { + wrapper: MemoryRouter, + }); + }); + + const editButton = await screen.findByTestId('alert-edit-alert-test'); + const deleteButton = await screen.findByTestId('alert-delete-alert-test'); + + expect(editButton).toBeInTheDocument(); + expect(deleteButton).toBeInTheDocument(); + }); + + it('should open delete modal on delete button click', async () => { + await act(async () => { + render(, { + wrapper: MemoryRouter, + }); + }); + + const deleteButton = await screen.findByTestId('alert-delete-alert-test'); + + await act(async () => { + userEvent.click(deleteButton); + }); + + const deleteModal = await screen.findByText('DeleteWidgetModal'); + + expect(deleteModal).toBeInTheDocument(); + }); + + it('should navigate to add notification page on add button click', async () => { + await act(async () => { + render(, { + wrapper: MemoryRouter, + }); + }); + + const addButton = await screen.findByText(/label.add-entity/); + fireEvent.click(addButton); + + expect(mockPush).toHaveBeenCalledWith( + ROUTES.SETTINGS + '/notifications/add-notification' + ); + }); + + it('should not render edit and delete buttons for alerts without permissions', async () => { + (usePermissionProvider as jest.Mock).mockImplementation(() => ({ + getEntityPermissionByFqn: jest.fn().mockImplementation(() => ({ + Create: false, + Delete: false, + ViewAll: true, + EditAll: false, + EditDescription: false, + EditDisplayName: false, + EditCustomFields: false, + })), + getResourcePermission: jest.fn().mockImplementation(() => ({ + Create: false, + Delete: false, + ViewAll: true, + EditAll: false, + EditDescription: false, + EditDisplayName: false, + EditCustomFields: false, + })), + })); + + await act(async () => { + render(, { + wrapper: MemoryRouter, + }); + }); + + const addButton = screen.queryByText(/label.add-entity/); + const editButton = screen.queryByTestId('alert-edit-alert-test'); + const deleteButton = screen.queryByTestId('alert-delete-alert-test'); + + expect(addButton).not.toBeInTheDocument(); + expect(editButton).not.toBeInTheDocument(); + expect(deleteButton).not.toBeInTheDocument(); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/NotificationListPage/NotificationListPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/NotificationListPage/NotificationListPage.tsx index 1b7cb29ca247..c0547857265c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/NotificationListPage/NotificationListPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/NotificationListPage/NotificationListPage.tsx @@ -10,7 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Button, Col, Row, Tooltip, Typography } from 'antd'; +import { Button, Col, Row, Skeleton, Tooltip, Typography } from 'antd'; import { AxiosError } from 'axios'; import { isEmpty, isUndefined } from 'lodash'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; @@ -27,6 +27,7 @@ import TitleBreadcrumb from '../../components/common/TitleBreadcrumb/TitleBreadc import { TitleBreadcrumbProps } from '../../components/common/TitleBreadcrumb/TitleBreadcrumb.interface'; import PageHeader from '../../components/PageHeader/PageHeader.component'; import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1'; +import { NO_DATA_PLACEHOLDER } from '../../constants/constants'; import { ALERTS_DOCS } from '../../constants/docs.constants'; import { GlobalSettingOptions, @@ -34,6 +35,11 @@ import { } from '../../constants/GlobalSettings.constants'; import { PAGE_HEADERS } from '../../constants/PageHeaders.constant'; import { useLimitStore } from '../../context/LimitsProvider/useLimitsStore'; +import { usePermissionProvider } from '../../context/PermissionProvider/PermissionProvider'; +import { + OperationPermission, + ResourceEntity, +} from '../../context/PermissionProvider/PermissionProvider.interface'; import { ERROR_PLACEHOLDER_TYPE } from '../../enums/common.enum'; import { EntityType } from '../../enums/entity.enum'; import { @@ -70,6 +76,61 @@ const NotificationListPage = () => { paging, } = usePaging(); const { getResourceLimit } = useLimitStore(); + const { getEntityPermissionByFqn, getResourcePermission } = + usePermissionProvider(); + const [alertPermissions, setAlertPermissions] = useState< + { + id: string; + edit: boolean; + delete: boolean; + }[] + >(); + const [alertResourcePermission, setAlertResourcePermission] = + useState(); + + const fetchAlertPermissionByFqn = async (alertDetails: EventSubscription) => { + const permission = await getEntityPermissionByFqn( + ResourceEntity.EVENT_SUBSCRIPTION, + alertDetails.fullyQualifiedName ?? '' + ); + + const editPermission = permission.EditAll; + const deletePermission = permission.Delete; + + return { + id: alertDetails.id, + edit: editPermission, + delete: deletePermission, + }; + }; + + const fetchAlertResourcePermission = async () => { + try { + setLoadingCount((count) => count + 1); + const permission = await getResourcePermission( + ResourceEntity.EVENT_SUBSCRIPTION + ); + + setAlertResourcePermission(permission); + } catch { + // Error + } finally { + setLoadingCount((count) => count - 1); + } + }; + + const fetchAllAlertsPermission = async (alerts: EventSubscription[]) => { + try { + setLoadingCount((count) => count + 1); + const response = alerts.map((alert) => fetchAlertPermissionByFqn(alert)); + + setAlertPermissions(await Promise.all(response)); + } catch { + // Error + } finally { + setLoadingCount((count) => count - 1); + } + }; const breadcrumbs: TitleBreadcrumbProps['titleLinks'] = useMemo( () => @@ -99,6 +160,7 @@ const NotificationListPage = () => { } handlePagingChange(paging); + fetchAllAlertsPermission(data); } catch (error) { showErrorToast( t('server.entity-fetch-error', { entity: t('label.alert-plural') }) @@ -110,6 +172,10 @@ const NotificationListPage = () => { [pageSize] ); + useEffect(() => { + fetchAlertResourcePermission(); + }, []); + useEffect(() => { fetchAlerts(); }, [pageSize]); @@ -181,38 +247,60 @@ const NotificationListPage = () => { { title: t('label.action-plural'), dataIndex: 'fullyQualifiedName', - width: 120, + width: 90, key: 'fullyQualifiedName', - render: (id: string, record: EventSubscription) => { + render: (fullyQualifiedName: string, record: EventSubscription) => { + const alertPermission = alertPermissions?.find( + (alert) => alert.id === record.id + ); + if (loadingCount > 0) { + return ; + } + + if ( + isUndefined(alertPermission) || + (!alertPermission.edit && !alertPermission.delete) + ) { + return ( + + {NO_DATA_PLACEHOLDER} + + ); + } + return (
- - + {alertPermission.edit && ( + + +
); }, }, ], - [handleAlertDelete] + [alertPermissions, loadingCount] ); return ( @@ -224,21 +312,24 @@ const NotificationListPage = () => {
- - - + }> + {t('label.add-entity', { entity: t('label.alert') })} + + + )}
diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ObservabilityAlertsPage/ObservabilityAlertsPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ObservabilityAlertsPage/ObservabilityAlertsPage.test.tsx index f4ff7a4c9aca..296a7c189124 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ObservabilityAlertsPage/ObservabilityAlertsPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ObservabilityAlertsPage/ObservabilityAlertsPage.test.tsx @@ -10,9 +10,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { act, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; import { MemoryRouter } from 'react-router-dom'; +import { usePermissionProvider } from '../../context/PermissionProvider/PermissionProvider'; import LimitWrapper from '../../hoc/LimitWrapper'; import { getAllAlerts } from '../../rest/alertsAPI'; import ObservabilityAlertsPage from './ObservabilityAlertsPage'; @@ -48,6 +49,20 @@ const MOCK_DATA = [ provider: 'user', }, ]; +const mockPush = jest.fn(); + +jest.mock('react-router-dom', () => ({ + Link: jest + .fn() + .mockImplementation( + ({ children, ...props }: { children: React.ReactNode }) => ( +

{children}

+ ) + ), + useHistory: jest.fn().mockImplementation(() => ({ + push: mockPush, + })), +})); jest.mock('../../rest/alertsAPI', () => ({ getAllAlerts: jest.fn().mockImplementation(() => @@ -57,6 +72,15 @@ jest.mock('../../rest/alertsAPI', () => ({ }) ), })); + +jest.mock('../../components/common/DeleteWidget/DeleteWidgetModal', () => { + return jest + .fn() + .mockImplementation(({ visible }) => + visible ?

DeleteWidgetModal

: null + ); +}); + jest.mock('../../components/PageLayoutV1/PageLayoutV1', () => { return jest.fn().mockImplementation(({ children }) =>
{children}
); }); @@ -67,6 +91,29 @@ jest.mock('../../hoc/LimitWrapper', () => { .mockImplementation(({ children }) => <>LimitWrapper{children}); }); +jest.mock('../../context/PermissionProvider/PermissionProvider', () => ({ + usePermissionProvider: jest.fn().mockReturnValue({ + getEntityPermissionByFqn: jest.fn().mockReturnValue({ + Create: true, + Delete: true, + ViewAll: true, + EditAll: true, + EditDescription: true, + EditDisplayName: true, + EditCustomFields: true, + }), + getResourcePermission: jest.fn().mockReturnValue({ + Create: true, + Delete: true, + ViewAll: true, + EditAll: true, + EditDescription: true, + EditDisplayName: true, + EditCustomFields: true, + }), + }), +})); + describe('Observability Alerts Page Tests', () => { it('Title should be rendered', async () => { await act(async () => { @@ -113,7 +160,7 @@ describe('Observability Alerts Page Tests', () => { }); it('Table should render no data', async () => { - (getAllAlerts as jest.Mock).mockImplementation(() => + (getAllAlerts as jest.Mock).mockImplementationOnce(() => Promise.resolve({ data: [], paging: { total: 1 }, @@ -144,4 +191,83 @@ describe('Observability Alerts Page Tests', () => { {} ); }); + + it('should render edit and delete buttons for alerts with permissions', async () => { + await act(async () => { + render(, { + wrapper: MemoryRouter, + }); + }); + + const editButton = await screen.findByTestId('alert-edit-alert-test'); + const deleteButton = await screen.findByTestId('alert-delete-alert-test'); + + expect(editButton).toBeInTheDocument(); + expect(deleteButton).toBeInTheDocument(); + }); + + it('should open delete modal on delete button click', async () => { + await act(async () => { + render(, { + wrapper: MemoryRouter, + }); + }); + const deleteButton = await screen.findByTestId('alert-delete-alert-test'); + + fireEvent.click(deleteButton); + + const deleteModal = await screen.findByText('DeleteWidgetModal'); + + expect(deleteModal).toBeInTheDocument(); + }); + + it('should navigate to add observability alert page on add button click', async () => { + await act(async () => { + render(, { + wrapper: MemoryRouter, + }); + }); + + const addButton = await screen.findByText(/label.add-entity/); + fireEvent.click(addButton); + + expect(mockPush).toHaveBeenCalledWith('/observability/alerts/add'); + }); + + it('should not render add, edit and delete buttons for alerts without permissions', async () => { + (usePermissionProvider as jest.Mock).mockImplementation(() => ({ + getEntityPermissionByFqn: jest.fn().mockImplementation(() => ({ + Create: false, + Delete: false, + ViewAll: true, + EditAll: false, + EditDescription: false, + EditDisplayName: false, + EditCustomFields: false, + })), + getResourcePermission: jest.fn().mockImplementation(() => ({ + Create: false, + Delete: false, + ViewAll: true, + EditAll: false, + EditDescription: false, + EditDisplayName: false, + EditCustomFields: false, + })), + })); + + await act(async () => { + render(, { + wrapper: MemoryRouter, + }); + }); + + const addButton = screen.queryByText(/label.add-entity/); + const editButton = screen.queryByTestId('alert-edit-alert-test'); + const deleteButton = screen.queryByTestId('alert-delete-alert-test'); + + expect(addButton).not.toBeInTheDocument(); + expect(editButton).not.toBeInTheDocument(); + expect(deleteButton).not.toBeInTheDocument(); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ObservabilityAlertsPage/ObservabilityAlertsPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ObservabilityAlertsPage/ObservabilityAlertsPage.tsx index 92f13624ee6a..d82a29e3c1f5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ObservabilityAlertsPage/ObservabilityAlertsPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ObservabilityAlertsPage/ObservabilityAlertsPage.tsx @@ -10,9 +10,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Button, Col, Row, Tooltip, Typography } from 'antd'; +import { Button, Col, Row, Skeleton, Tooltip, Typography } from 'antd'; import { AxiosError } from 'axios'; -import { isEmpty } from 'lodash'; +import { isEmpty, isUndefined } from 'lodash'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link, useHistory } from 'react-router-dom'; @@ -25,9 +25,14 @@ import { PagingHandlerParams } from '../../components/common/NextPrevious/NextPr import Table from '../../components/common/Table/Table'; import PageHeader from '../../components/PageHeader/PageHeader.component'; import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1'; -import { ROUTES } from '../../constants/constants'; +import { NO_DATA_PLACEHOLDER, ROUTES } from '../../constants/constants'; import { ALERTS_DOCS } from '../../constants/docs.constants'; import { useLimitStore } from '../../context/LimitsProvider/useLimitsStore'; +import { usePermissionProvider } from '../../context/PermissionProvider/PermissionProvider'; +import { + OperationPermission, + ResourceEntity, +} from '../../context/PermissionProvider/PermissionProvider.interface'; import { ERROR_PLACEHOLDER_TYPE } from '../../enums/common.enum'; import { EntityType } from '../../enums/entity.enum'; import { @@ -50,6 +55,7 @@ const ObservabilityAlertsPage = () => { const { t } = useTranslation(); const history = useHistory(); const [loading, setLoading] = useState(true); + const [loadingCount, setLoadingCount] = useState(0); const [alerts, setAlerts] = useState([]); const [selectedAlert, setSelectedAlert] = useState(); const { @@ -62,6 +68,61 @@ const ObservabilityAlertsPage = () => { paging, } = usePaging(); const { getResourceLimit } = useLimitStore(); + const { getEntityPermissionByFqn, getResourcePermission } = + usePermissionProvider(); + const [alertPermissions, setAlertPermissions] = useState< + { + id: string; + edit: boolean; + delete: boolean; + }[] + >(); + const [alertResourcePermission, setAlertResourcePermission] = + useState(); + + const fetchAlertResourcePermission = async () => { + try { + setLoadingCount((count) => count + 1); + const permission = await getResourcePermission( + ResourceEntity.EVENT_SUBSCRIPTION + ); + + setAlertResourcePermission(permission); + } catch { + // Error + } finally { + setLoadingCount((count) => count - 1); + } + }; + + const fetchAlertPermissionByFqn = async (alertDetails: EventSubscription) => { + const permission = await getEntityPermissionByFqn( + ResourceEntity.EVENT_SUBSCRIPTION, + alertDetails.fullyQualifiedName ?? '' + ); + + const editPermission = permission.EditAll; + const deletePermission = permission.Delete; + + return { + id: alertDetails.id, + edit: editPermission, + delete: deletePermission, + }; + }; + + const fetchAllAlertsPermission = async (alerts: EventSubscription[]) => { + try { + setLoadingCount((count) => count + 1); + const response = alerts.map((alert) => fetchAlertPermissionByFqn(alert)); + + setAlertPermissions(await Promise.all(response)); + } catch { + // Error + } finally { + setLoadingCount((count) => count - 1); + } + }; const fetchAlerts = useCallback( async (params?: Partial) => { @@ -73,9 +134,13 @@ const ObservabilityAlertsPage = () => { limit: pageSize, alertType: AlertType.Observability, }); + const alertsList = data.filter( + (d) => d.provider !== ProviderType.System + ); - setAlerts(data.filter((d) => d.provider !== ProviderType.System)); + setAlerts(alertsList); handlePagingChange(paging); + fetchAllAlertsPermission(alertsList); } catch (error) { showErrorToast( t('server.entity-fetch-error', { entity: t('label.alert-plural') }) @@ -87,6 +152,10 @@ const ObservabilityAlertsPage = () => { [pageSize] ); + useEffect(() => { + fetchAlertResourcePermission(); + }, []); + useEffect(() => { fetchAlerts(); }, [pageSize]); @@ -158,37 +227,59 @@ const ObservabilityAlertsPage = () => { { title: t('label.action-plural'), dataIndex: 'fullyQualifiedName', - width: 120, + width: 90, key: 'fullyQualifiedName', render: (fqn: string, record: EventSubscription) => { + const alertPermission = alertPermissions?.find( + (alert) => alert.id === record.id + ); + if (loadingCount > 0) { + return ; + } + + if ( + isUndefined(alertPermission) || + (!alertPermission.edit && !alertPermission.delete) + ) { + return ( + + {NO_DATA_PLACEHOLDER} + + ); + } + return ( -
- - +
+ {alertPermission.edit && ( + + +
); }, }, ], - [handleAlertDelete] + [alertPermissions, loadingCount] ); const pageHeaderData = useMemo( @@ -205,14 +296,17 @@ const ObservabilityAlertsPage = () => {
- - - + {(alertResourcePermission?.Create || + alertResourcePermission?.All) && ( + + + + )}
diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.tsx index 467d5d6e26a1..626a3e782eb3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.tsx @@ -78,7 +78,6 @@ import { SearchIndex } from '../../enums/search.enum'; import { ProviderType, Tag } from '../../generated/entity/classification/tag'; import { Style } from '../../generated/type/tagLabel'; import { useFqn } from '../../hooks/useFqn'; -import { MOCK_TAG_PERMISSIONS } from '../../mocks/Tags.mock'; import { searchData } from '../../rest/miscAPI'; import { deleteTag, getTagByFqn, patchTag } from '../../rest/tagAPI'; import { getEntityDeleteMessage } from '../../utils/CommonUtils'; @@ -92,7 +91,10 @@ import { escapeESReservedCharacters, getEncodedFqn, } from '../../utils/StringsUtils'; -import { getQueryFilterToExcludeTerms } from '../../utils/TagsUtils'; +import { + getQueryFilterToExcludeTerms, + getTagAssetsQueryFilter, +} from '../../utils/TagsUtils'; import { showErrorToast, showSuccessToast } from '../../utils/ToastUtils'; import './tag-page.less'; import { TagTabs } from './TagPage.inteface'; @@ -149,13 +151,10 @@ const TagPage = () => { const isEditable = !tagItem.disabled && !tagItem.deleted; return { - editTagsPermission: - isEditable && (tagPermissions.EditTags || tagPermissions.EditAll), + editTagsPermission: isEditable && tagPermissions.EditAll, editDescriptionPermission: isEditable && - (tagPermissions.EditDescription || - tagPermissions.EditAll || - tagPermissions.EditTags), + (tagPermissions.EditDescription || tagPermissions.EditAll), }; } @@ -317,7 +316,7 @@ const TagPage = () => { '', 1, 0, - `(tags.tagFQN:"${encodedFqn}")`, + getTagAssetsQueryFilter(encodedFqn), '', '', SearchIndex.ALL @@ -478,7 +477,7 @@ const TagPage = () => { assetCount={assetCount} entityFqn={tagItem?.fullyQualifiedName ?? ''} isSummaryPanelOpen={Boolean(previewAsset)} - permissions={MOCK_TAG_PERMISSIONS} + permissions={tagPermissions} ref={assetTabRef} type={AssetsOfEntity.TAG} onAddAsset={() => setAssetModalVisible(true)} @@ -573,7 +572,7 @@ const TagPage = () => { { return response.data; }; + +export const stopApp = async (name: string) => { + return await APIClient.post(`${BASE_URL}/stop/${getEncodedFqn(name)}`); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/Alerts/AlertsUtil.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/Alerts/AlertsUtil.tsx index 57418219bc1f..d617a198ff6e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/Alerts/AlertsUtil.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/Alerts/AlertsUtil.tsx @@ -73,9 +73,11 @@ import { import { PAGE_SIZE_LARGE } from '../../constants/constants'; import { OPEN_METADATA } from '../../constants/Services.constant'; import { AlertRecentEventFilters } from '../../enums/Alerts.enum'; +import { EntityType } from '../../enums/entity.enum'; import { SearchIndex } from '../../enums/search.enum'; import { StatusType } from '../../generated/entity/data/pipeline'; import { PipelineState } from '../../generated/entity/services/ingestionPipelines/ingestionPipeline'; +import { User } from '../../generated/entity/teams/user'; import { CreateEventSubscription } from '../../generated/events/api/createEventSubscription'; import { EventsRecord } from '../../generated/events/api/eventsRecord'; import { @@ -1046,6 +1048,7 @@ export const handleAlertSave = async ({ updateAlertAPI, afterSaveAction, setInlineAlertDetails, + currentUser, }: { initialData?: EventSubscription; data: ModifiedCreateEventSubscription; @@ -1056,6 +1059,7 @@ export const handleAlertSave = async ({ afterSaveAction: (fqn: string) => Promise; setInlineAlertDetails: (alertDetails?: InlineAlertProps | undefined) => void; fqn?: string; + currentUser?: User; }) => { try { const destinations = data.destinations?.map((d) => { @@ -1110,6 +1114,16 @@ export const handleAlertSave = async ({ destinations, name: alertName, displayName: alertDisplayName, + ...(currentUser?.id + ? { + owners: [ + { + id: currentUser.id, + type: EntityType.USER, + }, + ], + } + : {}), }); } @@ -1326,18 +1340,21 @@ export const getAlertExtraInfo = ( return ( <> { it('getEntityStatsData should return stats data in array', () => { const resultData = getEntityStatsData(MOCK_APPLICATION_ENTITY_STATS); - expect(resultData).toEqual( - MOCK_APPLICATION_ENTITY_STATS_DATA.map((data) => ({ - ...data, - name: upperFirst(data.name), - })) - ); + const sortedMockData = MOCK_APPLICATION_ENTITY_STATS_DATA.map((data) => ({ + ...data, + name: upperFirst(data.name), + })).sort((a, b) => a.name.localeCompare(b.name)); + + // Verify the result matches the sorted mock data + expect(resultData).toEqual(sortedMockData); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/ApplicationUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/ApplicationUtils.tsx index 089cf46d136d..7336d3533e64 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/ApplicationUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ApplicationUtils.tsx @@ -60,7 +60,7 @@ export const getStatusFromPipelineState = (status: PipelineState) => { export const getEntityStatsData = (data: EntityStats): EntityStatsData[] => { const filteredRow = ['failedRecords', 'totalRecords', 'successRecords']; - return Object.keys(data).reduce((acc, key) => { + const result = Object.keys(data).reduce((acc, key) => { if (filteredRow.includes(key)) { return acc; } @@ -73,4 +73,6 @@ export const getEntityStatsData = (data: EntityStats): EntityStatsData[] => { }, ]; }, [] as EntityStatsData[]); + + return result.sort((a, b) => a.name.localeCompare(b.name)); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/JSONLogicSearchClassBase.ts b/openmetadata-ui/src/main/resources/ui/src/utils/JSONLogicSearchClassBase.ts index 1d68f486ae89..25bc69ed366a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/JSONLogicSearchClassBase.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/JSONLogicSearchClassBase.ts @@ -20,6 +20,7 @@ import { SelectFieldSettings, } from 'react-awesome-query-builder'; import AntdConfig from 'react-awesome-query-builder/lib/config/antd'; +import { TEXT_FIELD_OPERATORS } from '../constants/AdvancedSearch.constants'; import { PAGE_SIZE_BASE } from '../constants/constants'; import { EntityFields, @@ -287,16 +288,7 @@ class JSONLogicSearchClassBase { label: t('label.description'), type: 'text', mainWidgetProps: this.mainWidgetProps, - operators: [ - 'equal', - 'not_equal', - 'like', - 'not_like', - 'starts_with', - 'ends_with', - 'is_null', - 'is_not_null', - ], + operators: TEXT_FIELD_OPERATORS, }, [EntityReferenceFields.TAG]: { label: t('label.tag-plural'), @@ -313,11 +305,12 @@ class JSONLogicSearchClassBase { }, }, - extension: { + [EntityReferenceFields.EXTENSION]: { label: t('label.custom-property-plural'), - type: '!group', + type: '!struct', mainWidgetProps: this.mainWidgetProps, subfields: {}, + operators: TEXT_FIELD_OPERATORS, }, }; }; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderUtils.tsx index f7b7c25a2cad..de07b53e9149 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderUtils.tsx @@ -15,7 +15,12 @@ import { Button } from 'antd'; import { t } from 'i18next'; import { isUndefined } from 'lodash'; import React from 'react'; -import { Fields, RenderSettings } from 'react-awesome-query-builder'; +import { + FieldGroup, + Fields, + RenderSettings, +} from 'react-awesome-query-builder'; +import { EntityReferenceFields } from '../enums/AdvancedSearch.enum'; import { EsBoolQuery, EsExistsQuery, @@ -26,6 +31,34 @@ import { } from '../pages/ExplorePage/ExplorePage.interface'; import { generateUUID } from './StringsUtils'; +export const JSONLOGIC_FIELDS_TO_IGNORE_SPLIT = [ + EntityReferenceFields.EXTENSION, +]; + +const resolveFieldType = ( + fields: Fields, + field: string +): string | undefined => { + // Split the field into parts (e.g., "extension.expert") + const fieldParts = field.split('.'); + let currentField = fields[fieldParts[0]]; + + // If the top-level field doesn't exist, return undefined + if (!currentField) { + return undefined; + } + + // Traverse nested subfields if there are more parts + for (let i = 1; i < fieldParts.length; i++) { + if (!(currentField as FieldGroup)?.subfields?.[fieldParts[i]]) { + return undefined; // Subfield not found + } + currentField = (currentField as FieldGroup).subfields[fieldParts[i]]; + } + + return currentField?.type; +}; + export const getSelectEqualsNotEqualsProperties = ( parentPath: Array, field: string, @@ -33,6 +66,12 @@ export const getSelectEqualsNotEqualsProperties = ( operator: string ) => { const id = generateUUID(); + const isEqualNotEqualOp = ['equal', 'not_equal'].includes(operator); + const valueType = isEqualNotEqualOp + ? ['text'] + : Array.isArray(value) + ? ['multiselect'] + : ['select']; return { [id]: { @@ -43,20 +82,12 @@ export const getSelectEqualsNotEqualsProperties = ( value: [value], valueSrc: ['value'], operatorOptions: null, - valueType: Array.isArray(value) ? ['multiselect'] : ['select'], - asyncListValues: Array.isArray(value) - ? value.map((valueItem) => ({ - key: valueItem, - value: valueItem, - children: valueItem, - })) - : [ - { - key: value, - value, - children: value, - }, - ], + valueType: valueType, + asyncListValues: isEqualNotEqualOp + ? undefined + : Array.isArray(value) + ? value.map((item) => ({ key: item, value: item, children: item })) + : [{ key: value, value, children: value }], }, id, path: [...parentPath, id], @@ -177,7 +208,8 @@ export const getEqualFieldProperties = ( export const getJsonTreePropertyFromQueryFilter = ( parentPath: Array, - queryFilter: QueryFieldInterface[] + queryFilter: QueryFieldInterface[], + fields?: Fields ) => { const convertedObj = queryFilter.reduce( (acc, curr: QueryFieldInterface): Record => { @@ -187,27 +219,34 @@ export const getJsonTreePropertyFromQueryFilter = ( ...getEqualFieldProperties(parentPath, curr.term?.deleted as boolean), }; } else if (!isUndefined(curr.term)) { + const [field, value] = Object.entries(curr.term)[0]; + const fieldType = fields ? resolveFieldType(fields, field) : ''; + const op = fieldType === 'text' ? 'equal' : 'select_equals'; + return { ...acc, ...getSelectEqualsNotEqualsProperties( parentPath, - Object.keys(curr.term)[0], - Object.values(curr.term)[0] as string, - 'select_equals' + field, + value as string, + op ), }; } else if ( !isUndefined((curr.bool?.must_not as QueryFieldInterface)?.term) ) { const value = Object.values((curr.bool?.must_not as EsTerm)?.term)[0]; + const key = Object.keys((curr.bool?.must_not as EsTerm)?.term)[0]; + const fieldType = fields ? resolveFieldType(fields, key) : ''; + const op = fieldType === 'text' ? 'not_equal' : 'select_not_equals'; return { ...acc, ...getSelectEqualsNotEqualsProperties( parentPath, - Object.keys((curr.bool?.must_not as EsTerm)?.term)[0], + key, value as string, - Array.isArray(value) ? 'select_not_any_in' : 'select_not_equals' + Array.isArray(value) ? 'select_not_any_in' : op ), }; } else if ( @@ -292,7 +331,8 @@ export const getJsonTreePropertyFromQueryFilter = ( }; export const getJsonTreeFromQueryFilter = ( - queryFilter: QueryFilterInterface + queryFilter: QueryFilterInterface, + fields?: Fields ) => { try { const id1 = generateUUID(); @@ -309,7 +349,8 @@ export const getJsonTreeFromQueryFilter = ( children1: getJsonTreePropertyFromQueryFilter( [id1, id2], (mustFilters?.[0]?.bool as EsBoolQuery) - .must as QueryFieldInterface[] + .must as QueryFieldInterface[], + fields ), id: id2, path: [id1, id2], @@ -437,36 +478,41 @@ export const elasticsearchToJsonLogic = ( if (query.term) { const termQuery = query.term; - const field = Object.keys(termQuery)[0]; - const value = termQuery[field]; + const [field, value] = Object.entries(termQuery)[0]; const op = Array.isArray(value) ? 'in' : '=='; + if (field.includes('.')) { const [parentField, childField] = field.split('.'); - return { - some: [ - { var: parentField }, - { - [op]: [{ var: childField }, value], - }, - ], - }; + return JSONLOGIC_FIELDS_TO_IGNORE_SPLIT.includes( + parentField as EntityReferenceFields + ) + ? { '==': [{ var: field }, value] } + : { + some: [ + { var: parentField }, + { [op]: [{ var: childField }, value] }, + ], + }; } - return { - '==': [{ var: field }, value], - }; + return { '==': [{ var: field }, value] }; } if (query.exists) { - const existsQuery = query.exists; - const field = existsQuery.field; + const { field } = query.exists; if (field.includes('.')) { const [parentField] = field.split('.'); return { - '!!': { var: parentField }, + '!!': { + var: JSONLOGIC_FIELDS_TO_IGNORE_SPLIT.includes( + parentField as EntityReferenceFields + ) + ? field + : parentField, + }, }; } @@ -478,13 +524,22 @@ export const elasticsearchToJsonLogic = ( if (query.wildcard) { const wildcardQuery = query.wildcard; const field = Object.keys(wildcardQuery)[0]; - // const value = field.value; const value = wildcardQuery[field].value; if (field.includes('.')) { // use in operator for wildcards const [parentField, childField] = field.split('.'); + if ( + JSONLOGIC_FIELDS_TO_IGNORE_SPLIT.includes( + parentField as EntityReferenceFields + ) + ) { + return { + in: [{ var: field }, value], + }; + } + return { some: [ { var: parentField }, @@ -556,7 +611,15 @@ export const jsonLogicToElasticsearch = ( if (logic['==']) { const [field, value] = logic['==']; const fieldVar = parentField ? `${parentField}.${field.var}` : field.var; - if (typeof field === 'object' && field.var && field.var.includes('.')) { + const [parentKey] = field.var.split('.'); + if ( + typeof field === 'object' && + field.var && + field.var.includes('.') && + !JSONLOGIC_FIELDS_TO_IGNORE_SPLIT.includes( + parentKey as EntityReferenceFields + ) + ) { return { bool: { must: [ diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/TagClassBase.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/TagClassBase.test.ts index 396cce6ac06b..b4e7dc1762d3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/TagClassBase.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/TagClassBase.test.ts @@ -10,6 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { queryFilterToRemoveSomeClassification } from '../constants/Tag.constants'; import { SearchIndex } from '../enums/search.enum'; import { searchQuery } from '../rest/searchAPI'; import tagClassBase, { TagClassBase } from './TagClassBase'; @@ -57,7 +58,7 @@ describe('TagClassBase', () => { filters: 'disabled:false', pageNumber: page, pageSize: 10, // Assuming PAGE_SIZE is 10 - queryFilter: {}, + queryFilter: queryFilterToRemoveSomeClassification, searchIndex: SearchIndex.TAG, }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/TagClassBase.ts b/openmetadata-ui/src/main/resources/ui/src/utils/TagClassBase.ts index 554b6317e93b..decb520cc99d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/TagClassBase.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/TagClassBase.ts @@ -11,6 +11,7 @@ * limitations under the License. */ import { PAGE_SIZE } from '../constants/constants'; +import { queryFilterToRemoveSomeClassification } from '../constants/Tag.constants'; import { SearchIndex } from '../enums/search.enum'; import { searchQuery } from '../rest/searchAPI'; import { escapeESReservedCharacters, getEncodedFqn } from './StringsUtils'; @@ -24,7 +25,7 @@ class TagClassBase { filters: 'disabled:false', pageNumber: page, pageSize: PAGE_SIZE, - queryFilter: {}, + queryFilter: queryFilterToRemoveSomeClassification, searchIndex: SearchIndex.TAG, }); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/TagsUtils.test.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/TagsUtils.test.tsx index 421d49e8cdc6..f9cb82e3f13c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/TagsUtils.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/TagsUtils.test.tsx @@ -11,7 +11,11 @@ * limitations under the License. */ import { render } from '@testing-library/react'; -import { getDeleteIcon, getUsageCountLink } from './TagsUtils'; +import { + getDeleteIcon, + getTagAssetsQueryFilter, + getUsageCountLink, +} from './TagsUtils'; describe('getDeleteIcon', () => { it('renders CheckOutlined icon when deleteTagId matches id and status is "success"', () => { @@ -82,3 +86,26 @@ describe('getUsageCountLink', () => { ); }); }); + +describe('getTagAssetsQueryFilter', () => { + it('returns query filter for tagFQN starting with "Tier"', () => { + const tagFQN = 'Tier.Tier1'; + const result = getTagAssetsQueryFilter(tagFQN); + + expect(result).toBe(`(tier.tagFQN:"${tagFQN}")`); + }); + + it('returns query filter for tagFQN starting with "Certification"', () => { + const tagFQN = 'Certification.Gold'; + const result = getTagAssetsQueryFilter(tagFQN); + + expect(result).toBe(`(certification.tagLabel.tagFQN:"${tagFQN}")`); + }); + + it('returns common query filter for tagFQN starting with any name expect "Tier and Certification"', () => { + const tagFQN = 'ClassificationTag.Gold'; + const result = getTagAssetsQueryFilter(tagFQN); + + expect(result).toBe(`(tags.tagFQN:"${tagFQN}")`); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/TagsUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/TagsUtils.tsx index 2a54c6ad35ad..93dc8a9790ce 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/TagsUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/TagsUtils.tsx @@ -353,3 +353,13 @@ export const getQueryFilterToExcludeTerms = (fqn: string) => ({ }, }, }); + +export const getTagAssetsQueryFilter = (fqn: string) => { + if (fqn.includes('Tier.')) { + return `(tier.tagFQN:"${fqn}")`; + } else if (fqn.includes('Certification.')) { + return `(certification.tagLabel.tagFQN:"${fqn}")`; + } else { + return `(tags.tagFQN:"${fqn}")`; + } +};