diff --git a/.github/workflows/auto-cherry-pick-labeled-prs.yaml b/.github/workflows/auto-cherry-pick-labeled-prs.yaml new file mode 100644 index 000000000000..d3c8529fea42 --- /dev/null +++ b/.github/workflows/auto-cherry-pick-labeled-prs.yaml @@ -0,0 +1,76 @@ +--- +name: Cherry-pick labeled PRs to OpenMetadata release branch on merge +# yamllint disable-line rule:comments +run-name: OpenMetadata release cherry-pick PR #${{ github.event.pull_request.number }} + +# yamllint disable-line rule:truthy +on: + pull_request: + types: [closed] + branches: + - main +permissions: + contents: write + pull-requests: write +env: + CURRENT_RELEASE_ENDPOINT: ${{ vars.CURRENT_RELEASE_ENDPOINT }} # Endpoint that returns the current release version in json format +jobs: + cherry_pick_to_release_branch: + if: github.event.pull_request.merged == true && + contains(github.event.pull_request.labels.*.name, 'To release') + runs-on: ubuntu-latest # Running it on ubuntu-latest on purpose (we're not using all the free minutes) + steps: + - name: Checkout main branch + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + - name: Get the release version + id: get_release_version + run: | + CURRENT_RELEASE=$(curl -s $CURRENT_RELEASE_ENDPOINT | jq -r .om_branch) + echo "CURRENT_RELEASE=${CURRENT_RELEASE}" >> $GITHUB_ENV + - name: Cherry-pick changes from PR + id: cherry_pick + continue-on-error: true + run: | + git config --global user.email "release-bot@open-metadata.org" + git config --global user.name "OpenMetadata Release Bot" + git fetch origin ${CURRENT_RELEASE} + git checkout ${CURRENT_RELEASE} + git cherry-pick -x ${{ github.event.pull_request.merge_commit_sha }} + - name: Push changes to release branch + id: push_changes + continue-on-error: true + if: steps.cherry_pick.outcome == 'success' + run: | + git push origin ${CURRENT_RELEASE} + - name: Post a comment on failure + if: steps.cherry_pick.outcome != 'success' || steps.push_changes.outcome != 'success' + uses: actions/github-script@v7 + with: + script: | + const prNumber = context.payload.pull_request.number; + const releaseVersion = process.env.CURRENT_RELEASE; + const workflowRunUrl = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`; + github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: `Failed to cherry-pick changes to the ${releaseVersion} branch. + Please cherry-pick the changes manually. + You can find more details [here](${workflowRunUrl}).` + }) + - name: Post a comment on success + if: steps.cherry_pick.outcome == 'success' && steps.push_changes.outcome == 'success' + uses: actions/github-script@v7 + with: + script: | + const prNumber = context.payload.pull_request.number; + const releaseVersion = process.env.CURRENT_RELEASE; + github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: `Changes have been cherry-picked to the ${releaseVersion} branch.` + }) diff --git a/ingestion/pyproject.toml b/ingestion/pyproject.toml index ac88a38ee746..ec2b46ea578a 100644 --- a/ingestion/pyproject.toml +++ b/ingestion/pyproject.toml @@ -280,3 +280,4 @@ ignore = [ reportDeprecated = false reportMissingTypeStubs = false reportAny = false +reportExplicitAny = false diff --git a/ingestion/setup.py b/ingestion/setup.py index df5f3a7e1fcb..ff54d8406ba7 100644 --- a/ingestion/setup.py +++ b/ingestion/setup.py @@ -142,6 +142,7 @@ "typing-inspect", "packaging", # For version parsing "shapely", + "collate-data-diff", } plugins: Dict[str, Set[str]] = { 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/table_diff_params_setter.py b/ingestion/src/metadata/data_quality/validations/runtime_param_setter/table_diff_params_setter.py index b721e22910ba..1bd8c0a7732b 100644 --- a/ingestion/src/metadata/data_quality/validations/runtime_param_setter/table_diff_params_setter.py +++ b/ingestion/src/metadata/data_quality/validations/runtime_param_setter/table_diff_params_setter.py @@ -10,7 +10,7 @@ # limitations under the License. """Module that defines the TableDiffParamsSetter class.""" from ast import literal_eval -from typing import List, Optional +from typing import List, Optional, Set from urllib.parse import urlparse from metadata.data_quality.validations import utils @@ -75,7 +75,9 @@ def get_parameters(self, test_case) -> TableDiffRuntimeParameters: DatabaseService, table2.service.id, nullable=False ) key_columns = self.get_key_columns(test_case) - extra_columns = self.get_extra_columns(key_columns, test_case) + extra_columns = self.get_extra_columns( + key_columns, test_case, self.table_entity.columns, table2.columns + ) return TableDiffRuntimeParameters( table_profile_config=self.table_entity.tableProfilerConfig, table1=TableParameter( @@ -111,8 +113,8 @@ def get_parameters(self, test_case) -> TableDiffRuntimeParameters: case_sensitive=case_sensitive_columns, ), ), - keyColumns=key_columns, - extraColumns=extra_columns, + keyColumns=list(key_columns), + extraColumns=list(extra_columns), whereClause=self.build_where_clause(test_case), ) @@ -134,21 +136,25 @@ def build_where_clause(self, test_case) -> Optional[str]: return " AND ".join(where_clauses) def get_extra_columns( - self, key_columns: List[str], test_case - ) -> Optional[List[str]]: + self, + key_columns: Set[str], + test_case, + left_columns: List[Column], + right_columns: List[Column], + ) -> Optional[Set[str]]: extra_columns_param = self.get_parameter(test_case, "useColumns", None) if extra_columns_param is not None: extra_columns: List[str] = literal_eval(extra_columns_param) self.validate_columns(extra_columns) - return extra_columns + return set(extra_columns) if extra_columns_param is None: extra_columns_param = [] - for column in self.table_entity.columns: + for column in left_columns + right_columns: if column.name.root not in key_columns: extra_columns_param.insert(0, column.name.root) - return extra_columns_param + return set(extra_columns_param) - def get_key_columns(self, test_case) -> List[str]: + def get_key_columns(self, test_case) -> Set[str]: key_columns_param = self.get_parameter(test_case, "keyColumns", "[]") key_columns: List[str] = literal_eval(key_columns_param) if key_columns: @@ -167,13 +173,13 @@ def get_key_columns(self, test_case) -> List[str]: "Could not find primary key or unique constraint columns.\n", "Specify 'keyColumns' to explicitly set the columns to use as keys.", ) - return key_columns + return set(key_columns) @staticmethod def filter_relevant_columns( columns: List[Column], - key_columns: List[str], - extra_columns: List[str], + key_columns: Set[str], + extra_columns: Set[str], case_sensitive: bool, ) -> List[Column]: validated_columns = ( diff --git a/ingestion/src/metadata/data_quality/validations/table/sqlalchemy/tableDiff.py b/ingestion/src/metadata/data_quality/validations/table/sqlalchemy/tableDiff.py index 08f31daabf41..1221f66f8dff 100644 --- a/ingestion/src/metadata/data_quality/validations/table/sqlalchemy/tableDiff.py +++ b/ingestion/src/metadata/data_quality/validations/table/sqlalchemy/tableDiff.py @@ -273,15 +273,16 @@ def get_incomparable_columns(self) -> List[str]: ).with_schema() result = [] for column in table1.key_columns + table1.extra_columns: - col1_type = self._get_column_python_type( - table1._schema[column] # pylint: disable=protected-access - ) - # Skip columns that are not in the second table. We cover this case in get_changed_added_columns. - if table2._schema.get(column) is None: # pylint: disable=protected-access + col1 = table1._schema.get(column) # pylint: disable=protected-access + if col1 is None: + # Skip columns that are not in the first table. We cover this case in get_changed_added_columns. continue - col2_type = self._get_column_python_type( - table2._schema[column] # pylint: disable=protected-access - ) + col2 = table2._schema.get(column) # pylint: disable=protected-access + if col2 is None: + # Skip columns that are not in the second table. We cover this case in get_changed_added_columns. + continue + col1_type = self._get_column_python_type(col1) + col2_type = self._get_column_python_type(col2) if is_numeric(col1_type) and is_numeric(col2_type): continue if col1_type != col2_type: 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/dashboard/powerbi/client.py b/ingestion/src/metadata/ingestion/source/dashboard/powerbi/client.py index 51da219b97c9..d1acc7901ac9 100644 --- a/ingestion/src/metadata/ingestion/source/dashboard/powerbi/client.py +++ b/ingestion/src/metadata/ingestion/source/dashboard/powerbi/client.py @@ -64,11 +64,10 @@ def __init__(self, config: PowerBIConnection): client_credential=self.config.clientSecret.get_secret_value(), authority=self.config.authorityURI + self.config.tenantId, ) - self.auth_token = self.get_auth_token() client_config = ClientConfig( base_url="https://api.powerbi.com", api_version="v1.0", - auth_token=lambda: self.auth_token, + auth_token=self.get_auth_token, auth_header="Authorization", allow_redirects=True, retry_codes=[429], diff --git a/ingestion/src/metadata/ingestion/source/dashboard/superset/db_source.py b/ingestion/src/metadata/ingestion/source/dashboard/superset/db_source.py index 636641b61f8e..a9a2e9ef8454 100644 --- a/ingestion/src/metadata/ingestion/source/dashboard/superset/db_source.py +++ b/ingestion/src/metadata/ingestion/source/dashboard/superset/db_source.py @@ -201,7 +201,7 @@ def yield_dashboard_chart( except Exception as exc: yield Either( left=StackTraceError( - name=chart_json.id, + name=str(chart_json.id), error=f"Error yielding Chart [{chart_json.id} - {chart_json.slice_name}]: {exc}", stackTrace=traceback.format_exc(), ) 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/ingestion/tests/integration/data_quality/test_data_diff.py b/ingestion/tests/integration/data_quality/test_data_diff.py index 4f5c93281c94..a515928eb128 100644 --- a/ingestion/tests/integration/data_quality/test_data_diff.py +++ b/ingestion/tests/integration/data_quality/test_data_diff.py @@ -305,7 +305,7 @@ def __init__(self, *args, **kwargs): testCaseStatus=TestCaseStatus.Failed, testResultValue=[ TestResultValue(name="removedColumns", value="1"), - TestResultValue(name="addedColumns", value="0"), + TestResultValue(name="addedColumns", value="1"), TestResultValue(name="changedColumns", value="0"), ], ), diff --git a/openmetadata-docs/content/partials/v1.6/deployment/upgrade/upgrade-prerequisites.md b/openmetadata-docs/content/partials/v1.6/deployment/upgrade/upgrade-prerequisites.md index 05ae8bf4c51a..eae2e9fcd8ab 100644 --- a/openmetadata-docs/content/partials/v1.6/deployment/upgrade/upgrade-prerequisites.md +++ b/openmetadata-docs/content/partials/v1.6/deployment/upgrade/upgrade-prerequisites.md @@ -144,3 +144,13 @@ What you will need to do: removing these properties as well. - If you still want to use the Auto PII Classification and sampling features, you can create the new workflow from the UI. + +### Service Spec for the Ingestion Framework + +This impacts users who maintain their own connectors for the ingestion framework that are **NOT** part of the +[OpenMetadata python library (openmetadata-ingestion)](https://github.com/open-metadata/OpenMetadata/tree/ff261fb3738f3a56af1c31f7151af9eca7a602d5/ingestion/src/metadata/ingestion/source). +Introducing the ["connector specifcication class (`ServiceSpec`)"](https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/src/metadata/utils/service_spec/service_spec.py). +The `ServiceSpec` class serves as the entrypoint for the connector and holds the references for the classes that will be used +to ingest and process the metadata from the source. +You can see [postgres](https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/src/metadata/ingestion/source/database/postgres/service_spec.py) for an +implementation example. diff --git a/openmetadata-docs/content/v1.6.x-SNAPSHOT/releases/releases/index.md b/openmetadata-docs/content/v1.6.x-SNAPSHOT/releases/releases/index.md index fe825c9835d3..feb0596eaaf9 100644 --- a/openmetadata-docs/content/v1.6.x-SNAPSHOT/releases/releases/index.md +++ b/openmetadata-docs/content/v1.6.x-SNAPSHOT/releases/releases/index.md @@ -14,13 +14,6 @@ version. To see what's coming in next releases, please check our [Roadmap](/rele {% partial file="/v1.5/releases/latest.md" /%} -# 1.6.0 - -## Breaking Changes - -- The ingestion Framework now uses the OpenMetadata Ingestion Service Specification (OMISS) to specify - entrypoints to ingestion operations. [Click here](./todo-need-link) for more info. - # 1.5.9 Release {% note noteType="Tip" %} 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/governance/workflows/MainWorkflowTerminationListener.java b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/MainWorkflowTerminationListener.java index 9af9538935f5..f5e075d97e4d 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/MainWorkflowTerminationListener.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/MainWorkflowTerminationListener.java @@ -1,30 +1,42 @@ package org.openmetadata.service.governance.workflows; import static org.openmetadata.service.governance.workflows.Workflow.STAGE_INSTANCE_STATE_ID_VARIABLE; +import static org.openmetadata.service.governance.workflows.WorkflowHandler.getProcessDefinitionKeyFromId; import java.util.UUID; +import lombok.extern.slf4j.Slf4j; import org.flowable.engine.delegate.DelegateExecution; import org.flowable.engine.delegate.JavaDelegate; import org.openmetadata.service.Entity; import org.openmetadata.service.jdbi3.WorkflowInstanceRepository; import org.openmetadata.service.jdbi3.WorkflowInstanceStateRepository; +@Slf4j public class MainWorkflowTerminationListener implements JavaDelegate { @Override public void execute(DelegateExecution execution) { - WorkflowInstanceStateRepository workflowInstanceStateRepository = - (WorkflowInstanceStateRepository) - Entity.getEntityTimeSeriesRepository(Entity.WORKFLOW_INSTANCE_STATE); + try { + WorkflowInstanceStateRepository workflowInstanceStateRepository = + (WorkflowInstanceStateRepository) + Entity.getEntityTimeSeriesRepository(Entity.WORKFLOW_INSTANCE_STATE); - UUID workflowInstanceStateId = (UUID) execution.getVariable(STAGE_INSTANCE_STATE_ID_VARIABLE); - workflowInstanceStateRepository.updateStage( - workflowInstanceStateId, System.currentTimeMillis(), execution.getVariables()); + UUID workflowInstanceStateId = (UUID) execution.getVariable(STAGE_INSTANCE_STATE_ID_VARIABLE); + workflowInstanceStateRepository.updateStage( + workflowInstanceStateId, System.currentTimeMillis(), execution.getVariables()); - WorkflowInstanceRepository workflowInstanceRepository = - (WorkflowInstanceRepository) Entity.getEntityTimeSeriesRepository(Entity.WORKFLOW_INSTANCE); + WorkflowInstanceRepository workflowInstanceRepository = + (WorkflowInstanceRepository) + Entity.getEntityTimeSeriesRepository(Entity.WORKFLOW_INSTANCE); - UUID workflowInstanceId = UUID.fromString(execution.getProcessInstanceBusinessKey()); - workflowInstanceRepository.updateWorkflowInstance( - workflowInstanceId, System.currentTimeMillis()); + UUID workflowInstanceId = UUID.fromString(execution.getProcessInstanceBusinessKey()); + workflowInstanceRepository.updateWorkflowInstance( + workflowInstanceId, System.currentTimeMillis(), execution.getVariables()); + } catch (Exception exc) { + LOG.error( + String.format( + "[%s] Failed due to: %s ", + getProcessDefinitionKeyFromId(execution.getProcessDefinitionId()), exc.getMessage()), + exc); + } } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/Workflow.java b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/Workflow.java index 013d7ef43a72..3b9b437e3611 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/Workflow.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/Workflow.java @@ -13,6 +13,8 @@ public class Workflow { public static final String STAGE_INSTANCE_STATE_ID_VARIABLE = "stageInstanceStateId"; public static final String WORKFLOW_INSTANCE_EXECUTION_ID_VARIABLE = "workflowInstanceExecutionId"; + public static final String WORKFLOW_RUNTIME_EXCEPTION = "workflowRuntimeException"; + public static final String EXCEPTION_VARIABLE = "exception"; private final TriggerWorkflow triggerWorkflow; private final MainWorkflow mainWorkflow; diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/WorkflowFailureListener.java b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/WorkflowFailureListener.java new file mode 100644 index 000000000000..ad197d0f7e3a --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/WorkflowFailureListener.java @@ -0,0 +1,33 @@ +package org.openmetadata.service.governance.workflows; + +import lombok.extern.slf4j.Slf4j; +import org.flowable.common.engine.api.delegate.event.FlowableEngineEventType; +import org.flowable.common.engine.api.delegate.event.FlowableEvent; +import org.flowable.common.engine.api.delegate.event.FlowableEventListener; + +@Slf4j +public class WorkflowFailureListener implements FlowableEventListener { + + @Override + public void onEvent(FlowableEvent event) { + if (FlowableEngineEventType.JOB_EXECUTION_FAILURE.equals(event.getType())) { + LOG.error("Workflow Failed: " + event); + } + } + + @Override + public boolean isFailOnException() { + // Return true if the listener should fail the operation on an exception + return false; + } + + @Override + public boolean isFireOnTransactionLifecycleEvent() { + return false; + } + + @Override + public String getOnTransaction() { + return null; + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/WorkflowHandler.java b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/WorkflowHandler.java index 48174ddbd1be..ae3ecab0ac34 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/WorkflowHandler.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/WorkflowHandler.java @@ -50,6 +50,9 @@ private WorkflowHandler(OpenMetadataApplicationConfig config) { .setJdbcDriver(config.getDataSourceFactory().getDriverClass()) .setDatabaseSchemaUpdate(ProcessEngineConfiguration.DB_SCHEMA_UPDATE_FALSE); + // Add Global Failure Listener + processEngineConfiguration.setEventListeners(List.of(new WorkflowFailureListener())); + if (ConnectionType.MYSQL.label.equals(config.getDataSourceFactory().getDriverClass())) { processEngineConfiguration.setDatabaseType(ProcessEngineConfiguration.DATABASE_TYPE_MYSQL); } else { @@ -256,4 +259,16 @@ public void resumeWorkflow(String workflowName) { repositoryService.activateProcessDefinitionByKey( getTriggerWorkflowId(workflowName), true, null); } + + public void terminateWorkflow(String workflowName) { + runtimeService + .createProcessInstanceQuery() + .processDefinitionKey(getTriggerWorkflowId(workflowName)) + .list() + .forEach( + instance -> { + runtimeService.deleteProcessInstance( + instance.getId(), "Terminating all instances due to user request."); + }); + } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/WorkflowInstanceExecutionIdSetterListener.java b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/WorkflowInstanceExecutionIdSetterListener.java index 6cda1b8c408b..9651615cd781 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/WorkflowInstanceExecutionIdSetterListener.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/WorkflowInstanceExecutionIdSetterListener.java @@ -1,15 +1,34 @@ package org.openmetadata.service.governance.workflows; +import static org.openmetadata.service.governance.workflows.Workflow.RELATED_ENTITY_VARIABLE; import static org.openmetadata.service.governance.workflows.Workflow.WORKFLOW_INSTANCE_EXECUTION_ID_VARIABLE; +import static org.openmetadata.service.governance.workflows.WorkflowHandler.getProcessDefinitionKeyFromId; import java.util.UUID; +import lombok.extern.slf4j.Slf4j; import org.flowable.engine.delegate.DelegateExecution; import org.flowable.engine.delegate.JavaDelegate; +@Slf4j public class WorkflowInstanceExecutionIdSetterListener implements JavaDelegate { @Override public void execute(DelegateExecution execution) { - UUID workflowInstanceExecutionId = UUID.randomUUID(); - execution.setVariable(WORKFLOW_INSTANCE_EXECUTION_ID_VARIABLE, workflowInstanceExecutionId); + try { + String workflowName = getProcessDefinitionKeyFromId(execution.getProcessDefinitionId()); + String relatedEntity = (String) execution.getVariable(RELATED_ENTITY_VARIABLE); + LOG.debug( + String.format( + "New Execution for Workflow '%s'. Related Entity: '%s'", + workflowName, relatedEntity)); + + UUID workflowInstanceExecutionId = UUID.randomUUID(); + execution.setVariable(WORKFLOW_INSTANCE_EXECUTION_ID_VARIABLE, workflowInstanceExecutionId); + } catch (Exception exc) { + LOG.error( + String.format( + "[%s] Failed due to: %s ", + getProcessDefinitionKeyFromId(execution.getProcessDefinitionId()), exc.getMessage()), + exc); + } } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/WorkflowInstanceListener.java b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/WorkflowInstanceListener.java index b6b1c0069ac3..261afe62ab83 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/WorkflowInstanceListener.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/WorkflowInstanceListener.java @@ -13,16 +13,25 @@ public class WorkflowInstanceListener implements JavaDelegate { @Override public void execute(DelegateExecution execution) { - WorkflowInstanceRepository workflowInstanceRepository = - (WorkflowInstanceRepository) Entity.getEntityTimeSeriesRepository(Entity.WORKFLOW_INSTANCE); + try { + WorkflowInstanceRepository workflowInstanceRepository = + (WorkflowInstanceRepository) + Entity.getEntityTimeSeriesRepository(Entity.WORKFLOW_INSTANCE); - switch (execution.getEventName()) { - case "start" -> addWorkflowInstance(execution, workflowInstanceRepository); - case "end" -> updateWorkflowInstance(execution, workflowInstanceRepository); - default -> LOG.debug( + switch (execution.getEventName()) { + case "start" -> addWorkflowInstance(execution, workflowInstanceRepository); + case "end" -> updateWorkflowInstance(execution, workflowInstanceRepository); + default -> LOG.debug( + String.format( + "WorkflowStageUpdaterListener does not support listening for the event: '%s'", + execution.getEventName())); + } + } catch (Exception exc) { + LOG.error( String.format( - "WorkflowStageUpdaterListener does not support listening for the event: '%s'", - execution.getEventName())); + "[%s] Failed due to: %s ", + getProcessDefinitionKeyFromId(execution.getProcessDefinitionId()), exc.getMessage()), + exc); } } @@ -45,13 +54,22 @@ private void addWorkflowInstance( workflowInstanceId, System.currentTimeMillis(), execution.getVariables()); + LOG.debug( + String.format( + "Workflow '%s' Triggered. Instance: '%s'", workflowDefinitionName, workflowInstanceId)); } private void updateWorkflowInstance( DelegateExecution execution, WorkflowInstanceRepository workflowInstanceRepository) { + String workflowDefinitionName = + getMainWorkflowDefinitionNameFromTrigger( + getProcessDefinitionKeyFromId(execution.getProcessDefinitionId())); UUID workflowInstanceId = UUID.fromString(execution.getProcessInstanceBusinessKey()); workflowInstanceRepository.updateWorkflowInstance( - workflowInstanceId, System.currentTimeMillis()); + workflowInstanceId, System.currentTimeMillis(), execution.getVariables()); + LOG.debug( + String.format( + "Workflow '%s' Finished. Instance: '%s'", workflowDefinitionName, workflowInstanceId)); } private String getMainWorkflowDefinitionNameFromTrigger(String triggerWorkflowDefinitionName) { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/WorkflowInstanceStageListener.java b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/WorkflowInstanceStageListener.java index 3dd81d4d558d..3730a42537b0 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/WorkflowInstanceStageListener.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/WorkflowInstanceStageListener.java @@ -16,17 +16,25 @@ public class WorkflowInstanceStageListener implements JavaDelegate { @Override public void execute(DelegateExecution execution) { - WorkflowInstanceStateRepository workflowInstanceStateRepository = - (WorkflowInstanceStateRepository) - Entity.getEntityTimeSeriesRepository(Entity.WORKFLOW_INSTANCE_STATE); + try { + WorkflowInstanceStateRepository workflowInstanceStateRepository = + (WorkflowInstanceStateRepository) + Entity.getEntityTimeSeriesRepository(Entity.WORKFLOW_INSTANCE_STATE); - switch (execution.getEventName()) { - case "start" -> addNewStage(execution, workflowInstanceStateRepository); - case "end" -> updateStage(execution, workflowInstanceStateRepository); - default -> LOG.debug( + switch (execution.getEventName()) { + case "start" -> addNewStage(execution, workflowInstanceStateRepository); + case "end" -> updateStage(execution, workflowInstanceStateRepository); + default -> LOG.debug( + String.format( + "WorkflowStageUpdaterListener does not support listening for the event: '%s'", + execution.getEventName())); + } + } catch (Exception exc) { + LOG.error( String.format( - "WorkflowStageUpdaterListener does not support listening for the event: '%s'", - execution.getEventName())); + "[%s] Failed due to: %s ", + getProcessDefinitionKeyFromId(execution.getProcessDefinitionId()), exc.getMessage()), + exc); } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/NodeInterface.java b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/NodeInterface.java index d2c2fbcaef3a..536ee685b9c5 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/NodeInterface.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/NodeInterface.java @@ -1,9 +1,15 @@ package org.openmetadata.service.governance.workflows.elements; +import static org.openmetadata.service.governance.workflows.Workflow.WORKFLOW_RUNTIME_EXCEPTION; +import static org.openmetadata.service.governance.workflows.Workflow.getFlowableElementId; + import java.util.ArrayList; import java.util.List; +import org.flowable.bpmn.model.Activity; +import org.flowable.bpmn.model.BoundaryEvent; import org.flowable.bpmn.model.BpmnModel; import org.flowable.bpmn.model.EndEvent; +import org.flowable.bpmn.model.ErrorEventDefinition; import org.flowable.bpmn.model.FlowNode; import org.flowable.bpmn.model.FlowableListener; import org.flowable.bpmn.model.Process; @@ -16,6 +22,10 @@ public interface NodeInterface { void addToWorkflow(BpmnModel model, Process process); + default BoundaryEvent getRuntimeExceptionBoundaryEvent() { + return null; + } + default void attachWorkflowInstanceStageListeners(FlowNode flowableNode) { List events = List.of("start", "end"); attachWorkflowInstanceStageListeners(flowableNode, events); @@ -59,4 +69,17 @@ default void attachMainWorkflowTerminationListener(EndEvent endEvent) { .build(); endEvent.getExecutionListeners().add(listener); } + + default BoundaryEvent getRuntimeExceptionBoundaryEvent(Activity activity) { + ErrorEventDefinition runtimeExceptionDefinition = new ErrorEventDefinition(); + runtimeExceptionDefinition.setErrorCode(WORKFLOW_RUNTIME_EXCEPTION); + + BoundaryEvent runtimeExceptionBoundaryEvent = new BoundaryEvent(); + runtimeExceptionBoundaryEvent.setId( + getFlowableElementId(activity.getId(), "runtimeExceptionBoundaryEvent")); + runtimeExceptionBoundaryEvent.addEventDefinition(runtimeExceptionDefinition); + + runtimeExceptionBoundaryEvent.setAttachedToRef(activity); + return runtimeExceptionBoundaryEvent; + } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/automatedTask/CheckEntityAttributesTask.java b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/automatedTask/CheckEntityAttributesTask.java index 6b379923b355..e4a650797021 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/automatedTask/CheckEntityAttributesTask.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/automatedTask/CheckEntityAttributesTask.java @@ -2,6 +2,7 @@ import static org.openmetadata.service.governance.workflows.Workflow.getFlowableElementId; +import org.flowable.bpmn.model.BoundaryEvent; import org.flowable.bpmn.model.BpmnModel; import org.flowable.bpmn.model.EndEvent; import org.flowable.bpmn.model.FieldExtension; @@ -21,6 +22,7 @@ public class CheckEntityAttributesTask implements NodeInterface { private final SubProcess subProcess; + private final BoundaryEvent runtimeExceptionBoundaryEvent; public CheckEntityAttributesTask(CheckEntityAttributesTaskDefinition nodeDefinition) { String subProcessId = nodeDefinition.getName(); @@ -45,9 +47,15 @@ public CheckEntityAttributesTask(CheckEntityAttributesTaskDefinition nodeDefinit attachWorkflowInstanceStageListeners(subProcess); + this.runtimeExceptionBoundaryEvent = getRuntimeExceptionBoundaryEvent(subProcess); this.subProcess = subProcess; } + @Override + public BoundaryEvent getRuntimeExceptionBoundaryEvent() { + return runtimeExceptionBoundaryEvent; + } + private ServiceTask getCheckEntityAttributesServiceTask(String subProcessId, String rules) { FieldExtension rulesExpr = new FieldExtensionBuilder().fieldName("rulesExpr").fieldValue(rules).build(); @@ -63,5 +71,6 @@ private ServiceTask getCheckEntityAttributesServiceTask(String subProcessId, Str public void addToWorkflow(BpmnModel model, Process process) { process.addFlowElement(subProcess); + process.addFlowElement(runtimeExceptionBoundaryEvent); } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/automatedTask/SetEntityCertificationTask.java b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/automatedTask/SetEntityCertificationTask.java index 4c8c0d1538e3..c9f0c972a5c4 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/automatedTask/SetEntityCertificationTask.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/automatedTask/SetEntityCertificationTask.java @@ -3,6 +3,7 @@ import static org.openmetadata.service.governance.workflows.Workflow.getFlowableElementId; import java.util.Optional; +import org.flowable.bpmn.model.BoundaryEvent; import org.flowable.bpmn.model.BpmnModel; import org.flowable.bpmn.model.EndEvent; import org.flowable.bpmn.model.FieldExtension; @@ -23,6 +24,7 @@ public class SetEntityCertificationTask implements NodeInterface { private final SubProcess subProcess; + private final BoundaryEvent runtimeExceptionBoundaryEvent; public SetEntityCertificationTask(SetEntityCertificationTaskDefinition nodeDefinition) { String subProcessId = nodeDefinition.getName(); @@ -50,9 +52,15 @@ public SetEntityCertificationTask(SetEntityCertificationTaskDefinition nodeDefin attachWorkflowInstanceStageListeners(subProcess); + this.runtimeExceptionBoundaryEvent = getRuntimeExceptionBoundaryEvent(subProcess); this.subProcess = subProcess; } + @Override + public BoundaryEvent getRuntimeExceptionBoundaryEvent() { + return runtimeExceptionBoundaryEvent; + } + private ServiceTask getSetEntityCertificationServiceTask( String subProcessId, CertificationConfiguration.CertificationEnum certification) { FieldExtension certificationExpr = @@ -75,5 +83,6 @@ private ServiceTask getSetEntityCertificationServiceTask( public void addToWorkflow(BpmnModel model, Process process) { process.addFlowElement(subProcess); + process.addFlowElement(runtimeExceptionBoundaryEvent); } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/automatedTask/SetGlossaryTermStatusTask.java b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/automatedTask/SetGlossaryTermStatusTask.java index f1d1362b103c..55872a21546d 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/automatedTask/SetGlossaryTermStatusTask.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/automatedTask/SetGlossaryTermStatusTask.java @@ -2,6 +2,7 @@ import static org.openmetadata.service.governance.workflows.Workflow.getFlowableElementId; +import org.flowable.bpmn.model.BoundaryEvent; import org.flowable.bpmn.model.BpmnModel; import org.flowable.bpmn.model.EndEvent; import org.flowable.bpmn.model.FieldExtension; @@ -21,6 +22,7 @@ public class SetGlossaryTermStatusTask implements NodeInterface { private final SubProcess subProcess; + private final BoundaryEvent runtimeExceptionBoundaryEvent; public SetGlossaryTermStatusTask(SetGlossaryTermStatusTaskDefinition nodeDefinition) { String subProcessId = nodeDefinition.getName(); @@ -46,9 +48,15 @@ public SetGlossaryTermStatusTask(SetGlossaryTermStatusTaskDefinition nodeDefinit attachWorkflowInstanceStageListeners(subProcess); + this.runtimeExceptionBoundaryEvent = getRuntimeExceptionBoundaryEvent(subProcess); this.subProcess = subProcess; } + @Override + public BoundaryEvent getRuntimeExceptionBoundaryEvent() { + return runtimeExceptionBoundaryEvent; + } + private ServiceTask getSetGlossaryTermStatusServiceTask(String subProcessId, String status) { FieldExtension statusExpr = new FieldExtensionBuilder().fieldName("statusExpr").fieldValue(status).build(); @@ -64,5 +72,6 @@ private ServiceTask getSetGlossaryTermStatusServiceTask(String subProcessId, Str public void addToWorkflow(BpmnModel model, Process process) { process.addFlowElement(subProcess); + process.addFlowElement(runtimeExceptionBoundaryEvent); } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/automatedTask/impl/CheckEntityAttributesImpl.java b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/automatedTask/impl/CheckEntityAttributesImpl.java index 404b6cdf9cad..7723107f45fd 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/automatedTask/impl/CheckEntityAttributesImpl.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/automatedTask/impl/CheckEntityAttributesImpl.java @@ -1,11 +1,16 @@ package org.openmetadata.service.governance.workflows.elements.nodes.automatedTask.impl; +import static org.openmetadata.service.governance.workflows.Workflow.EXCEPTION_VARIABLE; import static org.openmetadata.service.governance.workflows.Workflow.RELATED_ENTITY_VARIABLE; import static org.openmetadata.service.governance.workflows.Workflow.RESULT_VARIABLE; +import static org.openmetadata.service.governance.workflows.Workflow.WORKFLOW_RUNTIME_EXCEPTION; +import static org.openmetadata.service.governance.workflows.WorkflowHandler.getProcessDefinitionKeyFromId; import io.github.jamsesso.jsonlogic.JsonLogic; import io.github.jamsesso.jsonlogic.JsonLogicException; +import lombok.extern.slf4j.Slf4j; import org.flowable.common.engine.api.delegate.Expression; +import org.flowable.engine.delegate.BpmnError; import org.flowable.engine.delegate.DelegateExecution; import org.flowable.engine.delegate.JavaDelegate; import org.openmetadata.schema.EntityInterface; @@ -14,15 +19,25 @@ import org.openmetadata.service.resources.feeds.MessageParser; import org.openmetadata.service.util.JsonUtils; +@Slf4j public class CheckEntityAttributesImpl implements JavaDelegate { private Expression rulesExpr; @Override public void execute(DelegateExecution execution) { - String rules = (String) rulesExpr.getValue(execution); - MessageParser.EntityLink entityLink = - MessageParser.EntityLink.parse((String) execution.getVariable(RELATED_ENTITY_VARIABLE)); - execution.setVariable(RESULT_VARIABLE, checkAttributes(entityLink, rules)); + try { + String rules = (String) rulesExpr.getValue(execution); + MessageParser.EntityLink entityLink = + MessageParser.EntityLink.parse((String) execution.getVariable(RELATED_ENTITY_VARIABLE)); + execution.setVariable(RESULT_VARIABLE, checkAttributes(entityLink, rules)); + } catch (Exception exc) { + LOG.error( + String.format( + "[%s] Failure: ", getProcessDefinitionKeyFromId(execution.getProcessDefinitionId())), + exc); + execution.setVariable(EXCEPTION_VARIABLE, exc.toString()); + throw new BpmnError(WORKFLOW_RUNTIME_EXCEPTION, exc.getMessage()); + } } private Boolean checkAttributes(MessageParser.EntityLink entityLink, String rules) { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/automatedTask/impl/SetEntityCertificationImpl.java b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/automatedTask/impl/SetEntityCertificationImpl.java index 62a6e0509b89..857605f3beee 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/automatedTask/impl/SetEntityCertificationImpl.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/automatedTask/impl/SetEntityCertificationImpl.java @@ -1,11 +1,16 @@ package org.openmetadata.service.governance.workflows.elements.nodes.automatedTask.impl; +import static org.openmetadata.service.governance.workflows.Workflow.EXCEPTION_VARIABLE; import static org.openmetadata.service.governance.workflows.Workflow.RELATED_ENTITY_VARIABLE; import static org.openmetadata.service.governance.workflows.Workflow.RESOLVED_BY_VARIABLE; +import static org.openmetadata.service.governance.workflows.Workflow.WORKFLOW_RUNTIME_EXCEPTION; +import static org.openmetadata.service.governance.workflows.WorkflowHandler.getProcessDefinitionKeyFromId; import java.util.Optional; import javax.json.JsonPatch; +import lombok.extern.slf4j.Slf4j; import org.flowable.common.engine.api.delegate.Expression; +import org.flowable.engine.delegate.BpmnError; import org.flowable.engine.delegate.DelegateExecution; import org.flowable.engine.delegate.JavaDelegate; import org.openmetadata.schema.EntityInterface; @@ -18,25 +23,35 @@ import org.openmetadata.service.util.EntityUtil; import org.openmetadata.service.util.JsonUtils; +@Slf4j public class SetEntityCertificationImpl implements JavaDelegate { private Expression certificationExpr; @Override public void execute(DelegateExecution execution) { - MessageParser.EntityLink entityLink = - MessageParser.EntityLink.parse((String) execution.getVariable(RELATED_ENTITY_VARIABLE)); - String entityType = entityLink.getEntityType(); - EntityInterface entity = Entity.getEntity(entityLink, "*", Include.ALL); + try { + MessageParser.EntityLink entityLink = + MessageParser.EntityLink.parse((String) execution.getVariable(RELATED_ENTITY_VARIABLE)); + String entityType = entityLink.getEntityType(); + EntityInterface entity = Entity.getEntity(entityLink, "*", Include.ALL); - String certification = - Optional.ofNullable(certificationExpr) - .map(certificationExpr -> (String) certificationExpr.getValue(execution)) - .orElse(null); - String user = - Optional.ofNullable((String) execution.getVariable(RESOLVED_BY_VARIABLE)) - .orElse(entity.getUpdatedBy()); + String certification = + Optional.ofNullable(certificationExpr) + .map(certificationExpr -> (String) certificationExpr.getValue(execution)) + .orElse(null); + String user = + Optional.ofNullable((String) execution.getVariable(RESOLVED_BY_VARIABLE)) + .orElse(entity.getUpdatedBy()); - setStatus(entity, entityType, user, certification); + setStatus(entity, entityType, user, certification); + } catch (Exception exc) { + LOG.error( + String.format( + "[%s] Failure: ", getProcessDefinitionKeyFromId(execution.getProcessDefinitionId())), + exc); + execution.setVariable(EXCEPTION_VARIABLE, exc.toString()); + throw new BpmnError(WORKFLOW_RUNTIME_EXCEPTION, exc.getMessage()); + } } private void setStatus( diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/automatedTask/impl/SetGlossaryTermStatusImpl.java b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/automatedTask/impl/SetGlossaryTermStatusImpl.java index 70e126d7e9af..6d2d4060cfd4 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/automatedTask/impl/SetGlossaryTermStatusImpl.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/automatedTask/impl/SetGlossaryTermStatusImpl.java @@ -1,12 +1,17 @@ package org.openmetadata.service.governance.workflows.elements.nodes.automatedTask.impl; +import static org.openmetadata.service.governance.workflows.Workflow.EXCEPTION_VARIABLE; import static org.openmetadata.service.governance.workflows.Workflow.RELATED_ENTITY_VARIABLE; import static org.openmetadata.service.governance.workflows.Workflow.RESOLVED_BY_VARIABLE; +import static org.openmetadata.service.governance.workflows.Workflow.WORKFLOW_RUNTIME_EXCEPTION; +import static org.openmetadata.service.governance.workflows.WorkflowHandler.getProcessDefinitionKeyFromId; import java.util.Objects; import java.util.Optional; import javax.json.JsonPatch; +import lombok.extern.slf4j.Slf4j; import org.flowable.common.engine.api.delegate.Expression; +import org.flowable.engine.delegate.BpmnError; import org.flowable.engine.delegate.DelegateExecution; import org.flowable.engine.delegate.JavaDelegate; import org.openmetadata.schema.entity.data.GlossaryTerm; @@ -16,21 +21,31 @@ import org.openmetadata.service.resources.feeds.MessageParser; import org.openmetadata.service.util.JsonUtils; +@Slf4j public class SetGlossaryTermStatusImpl implements JavaDelegate { private Expression statusExpr; @Override public void execute(DelegateExecution execution) { - MessageParser.EntityLink entityLink = - MessageParser.EntityLink.parse((String) execution.getVariable(RELATED_ENTITY_VARIABLE)); - GlossaryTerm glossaryTerm = Entity.getEntity(entityLink, "*", Include.ALL); + try { + MessageParser.EntityLink entityLink = + MessageParser.EntityLink.parse((String) execution.getVariable(RELATED_ENTITY_VARIABLE)); + GlossaryTerm glossaryTerm = Entity.getEntity(entityLink, "*", Include.ALL); - String status = (String) statusExpr.getValue(execution); - String user = - Optional.ofNullable((String) execution.getVariable(RESOLVED_BY_VARIABLE)) - .orElse(glossaryTerm.getUpdatedBy()); + String status = (String) statusExpr.getValue(execution); + String user = + Optional.ofNullable((String) execution.getVariable(RESOLVED_BY_VARIABLE)) + .orElse(glossaryTerm.getUpdatedBy()); - setStatus(glossaryTerm, user, status); + setStatus(glossaryTerm, user, status); + } catch (Exception exc) { + LOG.error( + String.format( + "[%s] Failure: ", getProcessDefinitionKeyFromId(execution.getProcessDefinitionId())), + exc); + execution.setVariable(EXCEPTION_VARIABLE, exc.toString()); + throw new BpmnError(WORKFLOW_RUNTIME_EXCEPTION, exc.getMessage()); + } } private void setStatus(GlossaryTerm glossaryTerm, String user, String status) { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/endEvent/EndEvent.java b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/endEvent/EndEvent.java index 29389e3d745b..377142d31c91 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/endEvent/EndEvent.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/endEvent/EndEvent.java @@ -1,14 +1,21 @@ package org.openmetadata.service.governance.workflows.elements.nodes.endEvent; +import lombok.Getter; import org.flowable.bpmn.model.BpmnModel; import org.flowable.bpmn.model.Process; import org.openmetadata.schema.governance.workflows.elements.nodes.endEvent.EndEventDefinition; import org.openmetadata.service.governance.workflows.elements.NodeInterface; import org.openmetadata.service.governance.workflows.flowable.builders.EndEventBuilder; +@Getter public class EndEvent implements NodeInterface { private final org.flowable.bpmn.model.EndEvent endEvent; + public EndEvent(String id) { + this.endEvent = new EndEventBuilder().id(id).build(); + attachWorkflowInstanceStageListeners(endEvent); + } + public EndEvent(EndEventDefinition nodeDefinition) { this.endEvent = new EndEventBuilder().id(nodeDefinition.getName()).build(); attachWorkflowInstanceStageListeners(endEvent); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/userTask/UserApprovalTask.java b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/userTask/UserApprovalTask.java index 3a653d6800bb..464cf57c96aa 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/userTask/UserApprovalTask.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/userTask/UserApprovalTask.java @@ -34,6 +34,7 @@ public class UserApprovalTask implements NodeInterface { private final SubProcess subProcess; + private final BoundaryEvent runtimeExceptionBoundaryEvent; private final List messages = new ArrayList<>(); public UserApprovalTask(UserApprovalTaskDefinition nodeDefinition) { @@ -92,9 +93,15 @@ public UserApprovalTask(UserApprovalTaskDefinition nodeDefinition) { attachWorkflowInstanceStageListeners(subProcess); + this.runtimeExceptionBoundaryEvent = getRuntimeExceptionBoundaryEvent(subProcess); this.subProcess = subProcess; } + @Override + public BoundaryEvent getRuntimeExceptionBoundaryEvent() { + return runtimeExceptionBoundaryEvent; + } + private ServiceTask getSetAssigneesVariableServiceTask( String subProcessId, FieldExtension assigneesExpr, FieldExtension assigneesVarNameExpr) { ServiceTask serviceTask = @@ -146,6 +153,7 @@ private BoundaryEvent getTerminationEvent() { public void addToWorkflow(BpmnModel model, Process process) { process.addFlowElement(subProcess); + process.addFlowElement(runtimeExceptionBoundaryEvent); for (Message message : messages) { model.addMessage(message); } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/userTask/impl/CreateApprovalTaskImpl.java b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/userTask/impl/CreateApprovalTaskImpl.java index bd8df6612fc1..58b3c922d679 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/userTask/impl/CreateApprovalTaskImpl.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/userTask/impl/CreateApprovalTaskImpl.java @@ -1,12 +1,17 @@ package org.openmetadata.service.governance.workflows.elements.nodes.userTask.impl; +import static org.openmetadata.service.governance.workflows.Workflow.EXCEPTION_VARIABLE; import static org.openmetadata.service.governance.workflows.Workflow.RELATED_ENTITY_VARIABLE; import static org.openmetadata.service.governance.workflows.Workflow.STAGE_INSTANCE_STATE_ID_VARIABLE; +import static org.openmetadata.service.governance.workflows.Workflow.WORKFLOW_RUNTIME_EXCEPTION; +import static org.openmetadata.service.governance.workflows.WorkflowHandler.getProcessDefinitionKeyFromId; import java.util.ArrayList; import java.util.List; import java.util.Set; import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.flowable.engine.delegate.BpmnError; import org.flowable.engine.delegate.TaskListener; import org.flowable.identitylink.api.IdentityLink; import org.flowable.task.service.delegate.DelegateTask; @@ -27,23 +32,35 @@ import org.openmetadata.service.resources.feeds.MessageParser; import org.openmetadata.service.util.WebsocketNotificationHandler; +@Slf4j public class CreateApprovalTaskImpl implements TaskListener { @Override public void notify(DelegateTask delegateTask) { - List assignees = getAssignees(delegateTask); - MessageParser.EntityLink entityLink = - MessageParser.EntityLink.parse((String) delegateTask.getVariable(RELATED_ENTITY_VARIABLE)); - GlossaryTerm entity = Entity.getEntity(entityLink, "*", Include.ALL); + try { + List assignees = getAssignees(delegateTask); + MessageParser.EntityLink entityLink = + MessageParser.EntityLink.parse( + (String) delegateTask.getVariable(RELATED_ENTITY_VARIABLE)); + GlossaryTerm entity = Entity.getEntity(entityLink, "*", Include.ALL); - Thread task = createApprovalTask(entity, assignees); - WorkflowHandler.getInstance().setCustomTaskId(delegateTask.getId(), task.getId()); + Thread task = createApprovalTask(entity, assignees); + WorkflowHandler.getInstance().setCustomTaskId(delegateTask.getId(), task.getId()); - UUID workflowInstanceStateId = - (UUID) delegateTask.getVariable(STAGE_INSTANCE_STATE_ID_VARIABLE); - WorkflowInstanceStateRepository workflowInstanceStateRepository = - (WorkflowInstanceStateRepository) - Entity.getEntityTimeSeriesRepository(Entity.WORKFLOW_INSTANCE_STATE); - workflowInstanceStateRepository.updateStageWithTask(task.getId(), workflowInstanceStateId); + UUID workflowInstanceStateId = + (UUID) delegateTask.getVariable(STAGE_INSTANCE_STATE_ID_VARIABLE); + WorkflowInstanceStateRepository workflowInstanceStateRepository = + (WorkflowInstanceStateRepository) + Entity.getEntityTimeSeriesRepository(Entity.WORKFLOW_INSTANCE_STATE); + workflowInstanceStateRepository.updateStageWithTask(task.getId(), workflowInstanceStateId); + } catch (Exception exc) { + LOG.error( + String.format( + "[%s] Failure: ", + getProcessDefinitionKeyFromId(delegateTask.getProcessDefinitionId())), + exc); + delegateTask.setVariable(EXCEPTION_VARIABLE, exc.toString()); + throw new BpmnError(WORKFLOW_RUNTIME_EXCEPTION, exc.getMessage()); + } } private List getAssignees(DelegateTask delegateTask) { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/userTask/impl/SetApprovalAssigneesImpl.java b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/userTask/impl/SetApprovalAssigneesImpl.java index c6fadbcb4f09..f3a2ed0585d2 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/userTask/impl/SetApprovalAssigneesImpl.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/userTask/impl/SetApprovalAssigneesImpl.java @@ -1,12 +1,17 @@ package org.openmetadata.service.governance.workflows.elements.nodes.userTask.impl; +import static org.openmetadata.service.governance.workflows.Workflow.EXCEPTION_VARIABLE; import static org.openmetadata.service.governance.workflows.Workflow.RELATED_ENTITY_VARIABLE; +import static org.openmetadata.service.governance.workflows.Workflow.WORKFLOW_RUNTIME_EXCEPTION; +import static org.openmetadata.service.governance.workflows.WorkflowHandler.getProcessDefinitionKeyFromId; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; +import lombok.extern.slf4j.Slf4j; import org.flowable.common.engine.api.delegate.Expression; +import org.flowable.engine.delegate.BpmnError; import org.flowable.engine.delegate.DelegateExecution; import org.flowable.engine.delegate.JavaDelegate; import org.openmetadata.schema.EntityInterface; @@ -16,33 +21,44 @@ import org.openmetadata.service.resources.feeds.MessageParser; import org.openmetadata.service.util.JsonUtils; +@Slf4j public class SetApprovalAssigneesImpl implements JavaDelegate { private Expression assigneesExpr; private Expression assigneesVarNameExpr; @Override public void execute(DelegateExecution execution) { - Map assigneesConfig = - JsonUtils.readOrConvertValue(assigneesExpr.getValue(execution), Map.class); - Boolean addReviewers = (Boolean) assigneesConfig.get("addReviewers"); - Optional> oExtraAssignees = - Optional.ofNullable( - JsonUtils.readOrConvertValue(assigneesConfig.get("extraAssignees"), List.class)); - - List assignees = new ArrayList<>(); - - if (addReviewers) { - MessageParser.EntityLink entityLink = - MessageParser.EntityLink.parse((String) execution.getVariable(RELATED_ENTITY_VARIABLE)); - EntityInterface entity = Entity.getEntity(entityLink, "*", Include.ALL); - assignees.addAll(getEntityLinkStringFromEntityReference(entity.getReviewers())); - } + try { + Map assigneesConfig = + JsonUtils.readOrConvertValue(assigneesExpr.getValue(execution), Map.class); + Boolean addReviewers = (Boolean) assigneesConfig.get("addReviewers"); + Optional> oExtraAssignees = + Optional.ofNullable( + JsonUtils.readOrConvertValue(assigneesConfig.get("extraAssignees"), List.class)); + + List assignees = new ArrayList<>(); - oExtraAssignees.ifPresent( - extraAssignees -> assignees.addAll(getEntityLinkStringFromEntityReference(extraAssignees))); + if (addReviewers) { + MessageParser.EntityLink entityLink = + MessageParser.EntityLink.parse((String) execution.getVariable(RELATED_ENTITY_VARIABLE)); + EntityInterface entity = Entity.getEntity(entityLink, "*", Include.ALL); + assignees.addAll(getEntityLinkStringFromEntityReference(entity.getReviewers())); + } - execution.setVariableLocal( - assigneesVarNameExpr.getValue(execution).toString(), JsonUtils.pojoToJson(assignees)); + oExtraAssignees.ifPresent( + extraAssignees -> + assignees.addAll(getEntityLinkStringFromEntityReference(extraAssignees))); + + execution.setVariableLocal( + assigneesVarNameExpr.getValue(execution).toString(), JsonUtils.pojoToJson(assignees)); + } catch (Exception exc) { + LOG.error( + String.format( + "[%s] Failure: ", getProcessDefinitionKeyFromId(execution.getProcessDefinitionId())), + exc); + execution.setVariable(EXCEPTION_VARIABLE, exc.toString()); + throw new BpmnError(WORKFLOW_RUNTIME_EXCEPTION, exc.getMessage()); + } } private List getEntityLinkStringFromEntityReference(List assignees) { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/userTask/impl/SetCandidateUsersImpl.java b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/userTask/impl/SetCandidateUsersImpl.java index cdd423c38517..ee3234593f38 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/userTask/impl/SetCandidateUsersImpl.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/userTask/impl/SetCandidateUsersImpl.java @@ -1,20 +1,37 @@ package org.openmetadata.service.governance.workflows.elements.nodes.userTask.impl; +import static org.openmetadata.service.governance.workflows.Workflow.EXCEPTION_VARIABLE; +import static org.openmetadata.service.governance.workflows.Workflow.WORKFLOW_RUNTIME_EXCEPTION; +import static org.openmetadata.service.governance.workflows.WorkflowHandler.getProcessDefinitionKeyFromId; + import java.util.List; +import lombok.extern.slf4j.Slf4j; import org.flowable.common.engine.api.delegate.Expression; +import org.flowable.engine.delegate.BpmnError; import org.flowable.engine.delegate.TaskListener; import org.flowable.task.service.delegate.DelegateTask; import org.openmetadata.service.util.JsonUtils; +@Slf4j public class SetCandidateUsersImpl implements TaskListener { private Expression assigneesVarNameExpr; @Override public void notify(DelegateTask delegateTask) { - List assignees = - JsonUtils.readOrConvertValue( - delegateTask.getVariable(assigneesVarNameExpr.getValue(delegateTask).toString()), - List.class); - delegateTask.addCandidateUsers(assignees); + try { + List assignees = + JsonUtils.readOrConvertValue( + delegateTask.getVariable(assigneesVarNameExpr.getValue(delegateTask).toString()), + List.class); + delegateTask.addCandidateUsers(assignees); + } catch (Exception exc) { + LOG.error( + String.format( + "[%s] Failure: ", + getProcessDefinitionKeyFromId(delegateTask.getProcessDefinitionId())), + exc); + delegateTask.setVariable(EXCEPTION_VARIABLE, exc.toString()); + throw new BpmnError(WORKFLOW_RUNTIME_EXCEPTION, exc.getMessage()); + } } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/triggers/EventBasedEntityTrigger.java b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/triggers/EventBasedEntityTrigger.java index 9f4a8ce47f83..f8761bf96656 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/triggers/EventBasedEntityTrigger.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/triggers/EventBasedEntityTrigger.java @@ -1,15 +1,19 @@ package org.openmetadata.service.governance.workflows.elements.triggers; +import static org.openmetadata.service.governance.workflows.Workflow.EXCEPTION_VARIABLE; import static org.openmetadata.service.governance.workflows.Workflow.RELATED_ENTITY_VARIABLE; +import static org.openmetadata.service.governance.workflows.Workflow.WORKFLOW_RUNTIME_EXCEPTION; import static org.openmetadata.service.governance.workflows.Workflow.getFlowableElementId; import java.util.ArrayList; import java.util.List; import java.util.ListIterator; import lombok.Getter; +import org.flowable.bpmn.model.BoundaryEvent; import org.flowable.bpmn.model.BpmnModel; import org.flowable.bpmn.model.CallActivity; import org.flowable.bpmn.model.EndEvent; +import org.flowable.bpmn.model.ErrorEventDefinition; import org.flowable.bpmn.model.FieldExtension; import org.flowable.bpmn.model.IOParameter; import org.flowable.bpmn.model.Process; @@ -55,6 +59,21 @@ public EventBasedEntityTrigger( CallActivity workflowTrigger = getWorkflowTrigger(triggerWorkflowId, mainWorkflowName); process.addFlowElement(workflowTrigger); + ErrorEventDefinition runtimeExceptionDefinition = new ErrorEventDefinition(); + runtimeExceptionDefinition.setErrorCode(WORKFLOW_RUNTIME_EXCEPTION); + + BoundaryEvent runtimeExceptionBoundaryEvent = new BoundaryEvent(); + runtimeExceptionBoundaryEvent.setId( + getFlowableElementId(workflowTrigger.getId(), "runtimeExceptionBoundaryEvent")); + runtimeExceptionBoundaryEvent.addEventDefinition(runtimeExceptionDefinition); + + runtimeExceptionBoundaryEvent.setAttachedToRef(workflowTrigger); + process.addFlowElement(runtimeExceptionBoundaryEvent); + + EndEvent errorEndEvent = + new EndEventBuilder().id(getFlowableElementId(triggerWorkflowId, "errorEndEvent")).build(); + process.addFlowElement(errorEndEvent); + EndEvent endEvent = new EndEventBuilder().id(getFlowableElementId(triggerWorkflowId, "endEvent")).build(); process.addFlowElement(endEvent); @@ -77,6 +96,8 @@ public EventBasedEntityTrigger( process.addFlowElement(filterNotPassed); // WorkflowTrigger -> End process.addFlowElement(new SequenceFlow(workflowTrigger.getId(), endEvent.getId())); + process.addFlowElement( + new SequenceFlow(runtimeExceptionBoundaryEvent.getId(), errorEndEvent.getId())); this.process = process; this.triggerWorkflowId = triggerWorkflowId; @@ -126,7 +147,12 @@ private CallActivity getWorkflowTrigger(String triggerWorkflowId, String mainWor inputParameter.setSource(RELATED_ENTITY_VARIABLE); inputParameter.setTarget(RELATED_ENTITY_VARIABLE); + IOParameter outputParameter = new IOParameter(); + outputParameter.setSource(EXCEPTION_VARIABLE); + outputParameter.setTarget(EXCEPTION_VARIABLE); + workflowTrigger.setInParameters(List.of(inputParameter)); + workflowTrigger.setOutParameters(List.of(outputParameter)); return workflowTrigger; } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/triggers/PeriodicBatchEntityTrigger.java b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/triggers/PeriodicBatchEntityTrigger.java index 849357510356..3a9eb500e804 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/triggers/PeriodicBatchEntityTrigger.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/triggers/PeriodicBatchEntityTrigger.java @@ -1,5 +1,6 @@ package org.openmetadata.service.governance.workflows.elements.triggers; +import static org.openmetadata.service.governance.workflows.Workflow.EXCEPTION_VARIABLE; import static org.openmetadata.service.governance.workflows.Workflow.RELATED_ENTITY_VARIABLE; import static org.openmetadata.service.governance.workflows.Workflow.getFlowableElementId; @@ -121,7 +122,12 @@ private CallActivity getWorkflowTriggerCallActivity( inputParameter.setSource(RELATED_ENTITY_VARIABLE); inputParameter.setTarget(RELATED_ENTITY_VARIABLE); + IOParameter outputParameter = new IOParameter(); + outputParameter.setSource(EXCEPTION_VARIABLE); + outputParameter.setTarget(EXCEPTION_VARIABLE); + workflowTrigger.setInParameters(List.of(inputParameter)); + workflowTrigger.setOutParameters(List.of(outputParameter)); workflowTrigger.setLoopCharacteristics(multiInstance); return workflowTrigger; diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/flowable/MainWorkflow.java b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/flowable/MainWorkflow.java index e6d44cc29e15..4fc00c29b660 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/flowable/MainWorkflow.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/flowable/MainWorkflow.java @@ -1,22 +1,28 @@ package org.openmetadata.service.governance.workflows.flowable; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; import lombok.Getter; +import org.flowable.bpmn.model.BoundaryEvent; import org.flowable.bpmn.model.BpmnModel; import org.flowable.bpmn.model.Process; +import org.flowable.bpmn.model.SequenceFlow; import org.openmetadata.schema.governance.workflows.WorkflowDefinition; import org.openmetadata.schema.governance.workflows.elements.EdgeDefinition; import org.openmetadata.schema.governance.workflows.elements.WorkflowNodeDefinitionInterface; import org.openmetadata.service.governance.workflows.elements.Edge; import org.openmetadata.service.governance.workflows.elements.NodeFactory; +import org.openmetadata.service.governance.workflows.elements.NodeInterface; +import org.openmetadata.service.governance.workflows.elements.nodes.endEvent.EndEvent; import org.openmetadata.service.util.JsonUtils; @Getter public class MainWorkflow { private final BpmnModel model; private final String workflowName; + private final List runtimeExceptionBoundaryEvents = new ArrayList<>(); public MainWorkflow(WorkflowDefinition workflowDefinition) { BpmnModel model = new BpmnModel(); @@ -33,8 +39,12 @@ public MainWorkflow(WorkflowDefinition workflowDefinition) { // Add Nodes for (Object nodeDefinitionObj : (List) workflowDefinition.getNodes()) { - NodeFactory.createNode(JsonUtils.readOrConvertValue(nodeDefinitionObj, Map.class)) - .addToWorkflow(model, process); + NodeInterface node = + NodeFactory.createNode(JsonUtils.readOrConvertValue(nodeDefinitionObj, Map.class)); + node.addToWorkflow(model, process); + + Optional.ofNullable(node.getRuntimeExceptionBoundaryEvent()) + .ifPresent(runtimeExceptionBoundaryEvents::add); } // Add Edges @@ -43,7 +53,18 @@ public MainWorkflow(WorkflowDefinition workflowDefinition) { edge.addToWorkflow(model, process); } + // Configure Exception Flow + configureRuntimeExceptionFlow(process); + this.model = model; this.workflowName = workflowName; } + + private void configureRuntimeExceptionFlow(Process process) { + EndEvent errorEndEvent = new EndEvent("Error"); + process.addFlowElement(errorEndEvent.getEndEvent()); + for (BoundaryEvent event : runtimeExceptionBoundaryEvents) { + process.addFlowElement(new SequenceFlow(event.getId(), errorEndEvent.getEndEvent().getId())); + } + } } 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/FeedRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/FeedRepository.java index acf8de971d99..17103ba77a0c 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/FeedRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/FeedRepository.java @@ -197,6 +197,9 @@ private String getFields() { if (repository.supportsTags) { fieldList.add("tags"); } + if (repository.supportsReviewers) { + fieldList.add("reviewers"); + } return String.join(",", fieldList.toArray(new String[0])); } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/GlossaryTermRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/GlossaryTermRepository.java index 000dcda39d94..4e01a8cb9448 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/GlossaryTermRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/GlossaryTermRepository.java @@ -598,6 +598,9 @@ public static class ApprovalTaskWorkflow extends TaskWorkflow { @Override public EntityInterface performTask(String user, ResolveTask resolveTask) { // TODO: Resolve this outside + GlossaryTerm glossaryTerm = (GlossaryTerm) threadContext.getAboutEntity(); + checkUpdatedByReviewer(glossaryTerm, user); + UUID taskId = threadContext.getThread().getId(); Map variables = new HashMap<>(); variables.put(RESULT_VARIABLE, resolveTask.getNewValue().equalsIgnoreCase("approved")); @@ -607,7 +610,6 @@ public EntityInterface performTask(String user, ResolveTask resolveTask) { // TODO: performTask returns the updated Entity and the flow applies the new value. // This should be changed with the new Governance Workflows. - GlossaryTerm glossaryTerm = (GlossaryTerm) threadContext.getAboutEntity(); // glossaryTerm.setStatus(Status.APPROVED); return glossaryTerm; } @@ -652,7 +654,7 @@ private void validateHierarchy(GlossaryTerm term) { } } - private void checkUpdatedByReviewer(GlossaryTerm term, String updatedBy) { + public static void checkUpdatedByReviewer(GlossaryTerm term, String updatedBy) { // Only list of allowed reviewers can change the status from DRAFT to APPROVED List reviewers = term.getReviewers(); if (!nullOrEmpty(reviewers)) { 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/StoredProcedureRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/StoredProcedureRepository.java index 61156802f654..9a879a27dcea 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/StoredProcedureRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/StoredProcedureRepository.java @@ -138,6 +138,12 @@ public void entitySpecificUpdate() { original.getStoredProcedureCode(), updated.getStoredProcedureCode()); } + if (updated.getStoredProcedureType() != null) { + recordChange( + "storedProcedureType", + original.getStoredProcedureType(), + updated.getStoredProcedureType()); + } recordChange("sourceUrl", original.getSourceUrl(), updated.getSourceUrl()); recordChange("sourceHash", original.getSourceHash(), updated.getSourceHash()); } 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/jdbi3/WorkflowInstanceRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/WorkflowInstanceRepository.java index 07d314eca3a6..33c71f7c643a 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/WorkflowInstanceRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/WorkflowInstanceRepository.java @@ -1,5 +1,7 @@ package org.openmetadata.service.jdbi3; +import static org.openmetadata.service.governance.workflows.Workflow.EXCEPTION_VARIABLE; + import java.util.Map; import java.util.UUID; import org.openmetadata.schema.governance.workflows.WorkflowInstance; @@ -41,12 +43,17 @@ public void addNewWorkflowInstance( workflowDefinitionName); } - public void updateWorkflowInstance(UUID workflowInstanceId, Long endedAt) { + public void updateWorkflowInstance( + UUID workflowInstanceId, Long endedAt, Map variables) { WorkflowInstance workflowInstance = JsonUtils.readValue(timeSeriesDao.getById(workflowInstanceId), WorkflowInstance.class); workflowInstance.setEndedAt(endedAt); + if (variables.containsKey(EXCEPTION_VARIABLE)) { + workflowInstance.setException(true); + } + getTimeSeriesDao().update(JsonUtils.pojoToJson(workflowInstance), workflowInstanceId); } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/WorkflowInstanceStateRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/WorkflowInstanceStateRepository.java index c578a7db9020..bcea6ac0b621 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/WorkflowInstanceStateRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/WorkflowInstanceStateRepository.java @@ -1,5 +1,7 @@ package org.openmetadata.service.jdbi3; +import static org.openmetadata.service.governance.workflows.Workflow.EXCEPTION_VARIABLE; + import java.util.Map; import java.util.UUID; import org.openmetadata.schema.governance.workflows.Stage; @@ -61,6 +63,10 @@ public void updateStage( stage.setEndedAt(endedAt); stage.setVariables(variables); + if (variables.containsKey(EXCEPTION_VARIABLE)) { + workflowInstanceState.setException(true); + } + workflowInstanceState.setStage(stage); getTimeSeriesDao().update(JsonUtils.pojoToJson(workflowInstanceState), workflowInstanceStateId); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/EntityResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/EntityResource.java index d4443fb4efce..5606685d22c2 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/EntityResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/EntityResource.java @@ -484,7 +484,7 @@ public Response importCsvInternalAsync( jobId, securityContext, e.getMessage()); } }); - CSVImportResponse response = new CSVImportResponse(jobId, "Import initiated successfully."); + CSVImportResponse response = new CSVImportResponse(jobId, "Import is in progress."); return Response.ok().entity(response).type(MediaType.APPLICATION_JSON).build(); } 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/databases/StoredProcedureResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/databases/StoredProcedureResource.java index e5b1b8e7ef19..84626a53062b 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/databases/StoredProcedureResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/databases/StoredProcedureResource.java @@ -533,6 +533,7 @@ private StoredProcedure getStoredProcedure(CreateStoredProcedure create, String .copy(new StoredProcedure(), create, user) .withDatabaseSchema(getEntityReference(Entity.DATABASE_SCHEMA, create.getDatabaseSchema())) .withStoredProcedureCode(create.getStoredProcedureCode()) + .withStoredProcedureType(create.getStoredProcedureType()) .withSourceUrl(create.getSourceUrl()) .withSourceHash(create.getSourceHash()); } 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/api/data/createStoredProcedure.json b/openmetadata-spec/src/main/resources/json/schema/api/data/createStoredProcedure.json index 6caf3a3ae8b6..75cd4ba5f26c 100644 --- a/openmetadata-spec/src/main/resources/json/schema/api/data/createStoredProcedure.json +++ b/openmetadata-spec/src/main/resources/json/schema/api/data/createStoredProcedure.json @@ -38,6 +38,10 @@ "description": "SQL Query definition.", "$ref": "../../entity/data/storedProcedure.json#/definitions/storedProcedureCode" }, + "storedProcedureType": { + "description": "Type of the Stored Procedure.", + "$ref": "../../entity/data/storedProcedure.json#/definitions/storedProcedureType" + }, "databaseSchema": { "description": "Link to the database schema fully qualified name where this stored procedure is hosted in", "$ref": "../../type/basic.json#/definitions/fullyQualifiedEntityName" 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/entity/data/storedProcedure.json b/openmetadata-spec/src/main/resources/json/schema/entity/data/storedProcedure.json index 0fb8aa1bae12..25133d8bc052 100644 --- a/openmetadata-spec/src/main/resources/json/schema/entity/data/storedProcedure.json +++ b/openmetadata-spec/src/main/resources/json/schema/entity/data/storedProcedure.json @@ -8,6 +8,24 @@ "javaType": "org.openmetadata.schema.entity.data.StoredProcedure", "javaInterfaces": ["org.openmetadata.schema.EntityInterface"], "definitions": { + "storedProcedureType": { + "javaType": "org.openmetadata.schema.type.StoredProcedureType", + "description": "This schema defines the type of the type of Procedures", + "type": "string", + "default": "StoredProcedure", + "enum": [ + "StoredProcedure", + "UDF" + ], + "javaEnums": [ + { + "name": "StoredProcedure" + }, + { + "name": "UDF" + } + ] + }, "storedProcedureCode": { "properties": { "language": { @@ -76,14 +94,19 @@ "description": "Metadata version of the Stored Procedure.", "$ref": "../../type/entityHistory.json#/definitions/entityVersion" }, - "dataProducts" : { + "dataProducts": { "description": "List of data products this entity is part of.", - "$ref" : "../../type/entityReferenceList.json" + "$ref": "../../type/entityReferenceList.json" }, "updatedAt": { "description": "Last update time corresponding to the new version of the entity in Unix epoch time milliseconds.", "$ref": "../../type/basic.json#/definitions/timestamp" }, + "storedProcedureType": { + "description": "Type of the Stored Procedure.", + "$ref": "#/definitions/storedProcedureType", + "default": "StoredProcedure" + }, "updatedBy": { "description": "User who made the query.", "type": "string" @@ -104,14 +127,14 @@ "description": "Reference to Database that contains this stored procedure.", "$ref": "../../type/entityReference.json" }, - "service": { - "description": "Link to Database service this table is hosted in.", - "$ref": "../../type/entityReference.json" - }, - "serviceType": { - "description": "Service type this table is hosted in.", - "$ref": "../services/databaseService.json#/definitions/databaseServiceType" - }, + "service": { + "description": "Link to Database service this table is hosted in.", + "$ref": "../../type/entityReference.json" + }, + "serviceType": { + "description": "Service type this table is hosted in.", + "$ref": "../services/databaseService.json#/definitions/databaseServiceType" + }, "deleted": { "description": "When `true` indicates the entity has been soft deleted.", "type": "boolean", @@ -126,7 +149,7 @@ "description": "Followers of this Stored Procedure.", "$ref": "../../type/entityReferenceList.json" }, - "votes" : { + "votes": { "description": "Votes on the entity.", "$ref": "../../type/votes.json" }, @@ -150,7 +173,7 @@ "description": "Source URL of database schema.", "$ref": "../../type/basic.json#/definitions/sourceUrl" }, - "domain" : { + "domain": { "description": "Domain the Stored Procedure belongs to. When not set, the Stored Procedure inherits the domain from the database schemna it belongs to.", "$ref": "../../type/entityReference.json" }, @@ -168,6 +191,10 @@ "maxLength": 32 } }, - "required": ["id","name","storedProcedureCode"], + "required": [ + "id", + "name", + "storedProcedureCode" + ], "additionalProperties": false -} +} \ No newline at end of file diff --git a/openmetadata-spec/src/main/resources/json/schema/governance/workflows/workflowInstance.json b/openmetadata-spec/src/main/resources/json/schema/governance/workflows/workflowInstance.json index 81beb132d155..c63feb258fa2 100644 --- a/openmetadata-spec/src/main/resources/json/schema/governance/workflows/workflowInstance.json +++ b/openmetadata-spec/src/main/resources/json/schema/governance/workflows/workflowInstance.json @@ -30,6 +30,10 @@ "timestamp": { "description": "Timestamp on which the workflow instance state was created.", "$ref": "../../type/basic.json#/definitions/timestamp" + }, + "exception": { + "description": "If the Workflow Instance has errors, 'True'. Else, 'False'.", + "type": "boolean" } }, "required": [], diff --git a/openmetadata-spec/src/main/resources/json/schema/governance/workflows/workflowInstanceState.json b/openmetadata-spec/src/main/resources/json/schema/governance/workflows/workflowInstanceState.json index 01ed9dc19a19..fb11362f3b72 100644 --- a/openmetadata-spec/src/main/resources/json/schema/governance/workflows/workflowInstanceState.json +++ b/openmetadata-spec/src/main/resources/json/schema/governance/workflows/workflowInstanceState.json @@ -54,6 +54,10 @@ "timestamp": { "description": "Timestamp on which the workflow instance state was created.", "$ref": "../../type/basic.json#/definitions/timestamp" + }, + "exception": { + "description": "If the Workflow Instance has errors, 'True'. Else, 'False'.", + "type": "boolean" } }, "required": [], 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/package.json b/openmetadata-ui/src/main/resources/ui/package.json index 0fedb0dfe368..2e83bc6e9c79 100644 --- a/openmetadata-ui/src/main/resources/ui/package.json +++ b/openmetadata-ui/src/main/resources/ui/package.json @@ -120,7 +120,6 @@ "react-quill-new": "^3.3.2", "react-reflex": "^4.0.12", "react-router-dom": "^5.2.0", - "react-toastify": "^8.2.0", "reactflow": "^11.10.2", "reactjs-localstorage": "^1.0.1", "recharts": "2.10.3", 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/Features/ActivityFeed.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ActivityFeed.spec.ts index 57578ef22eaa..467bf511d006 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ActivityFeed.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ActivityFeed.spec.ts @@ -363,13 +363,15 @@ test.describe('Activity feed', () => { await page.getByText('OK').click(); await resolveTask; - await toastNotification(page, /Task resolved successfully/); + await toastNotification(page, /Task resolved successfully/, 'success'); // Task 1 - Resolved the task + const resolveTask2 = page.waitForResponse('/api/v1/feed/tasks/*/resolve'); await page.getByText('Accept Suggestion').click(); + await resolveTask2; - await toastNotification(page, /Task resolved successfully/); + await toastNotification(page, /Task resolved successfully/, 'success'); await checkTaskCount(page, 0, 2); }); @@ -412,7 +414,11 @@ test.describe('Activity feed', () => { await page.getByRole('menuitem', { name: 'close' }).click(); - await toastNotification(page, 'Task cannot be closed without a comment.'); + await toastNotification( + page, + 'Task cannot be closed without a comment.', + 'error' + ); // Close the task from the Button.Group, with comment is added. await page.fill( @@ -671,7 +677,8 @@ base.describe('Activity feed with Data Consumer User', () => { // await toastNotification(page1, 'Task closed successfully.'); await toastNotification( page1, - 'An exception with message [Cannot invoke "java.util.List.stream()" because "owners" is null] was thrown while processing request.' + 'An exception with message [Cannot invoke "java.util.List.stream()" because "owners" is null] was thrown while processing request.', + 'error' ); // TODO: Ashish - Enable them once issue is resolved from Backend https://github.com/open-metadata/OpenMetadata/issues/17059 @@ -984,7 +991,8 @@ base.describe('Activity feed with Data Consumer User', () => { await toastNotification( page2, // eslint-disable-next-line max-len - `Principal: CatalogPrincipal{name='${viewAllUser.responseData.name}'} operation EditDescription denied by role ${viewAllRoles.responseData.name}, policy ${viewAllPolicy.responseData.name}, rule editNotAllowed` + `Principal: CatalogPrincipal{name='${viewAllUser.responseData.name}'} operation EditDescription denied by role ${viewAllRoles.responseData.name}, policy ${viewAllPolicy.responseData.name}, rule editNotAllowed`, + 'error' ); await afterActionUser2(); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/TeamsDragAndDrop.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/TeamsDragAndDrop.spec.ts index a91ddc482b01..eaa13dbc0f3b 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/TeamsDragAndDrop.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/TeamsDragAndDrop.spec.ts @@ -118,7 +118,8 @@ test.describe('Teams drag and drop should work properly', () => { await dragAndDropElement(page, team, teamNameGroup); await toastNotification( page, - `You cannot move to this team as Team Type ${TEAM_TYPE_BY_NAME[team]} can't be Group children` + `You cannot move to this team as Team Type ${TEAM_TYPE_BY_NAME[team]} can't be Group children`, + 'error' ); } }); @@ -132,7 +133,8 @@ test.describe('Teams drag and drop should work properly', () => { await dragAndDropElement(page, team, teamNameDepartment); await toastNotification( page, - `You cannot move to this team as Team Type ${TEAM_TYPE_BY_NAME[team]} can't be Department children` + `You cannot move to this team as Team Type ${TEAM_TYPE_BY_NAME[team]} can't be Department children`, + 'error' ); } }); @@ -143,7 +145,8 @@ test.describe('Teams drag and drop should work properly', () => { await dragAndDropElement(page, teamNameBusiness, teamNameDivision); await toastNotification( page, - "You cannot move to this team as Team Type BusinessUnit can't be Division children" + "You cannot move to this team as Team Type BusinessUnit can't be Division children", + 'error' ); }); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ApiServiceRest.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ApiServiceRest.spec.ts index d9b46f3d5148..22c9cd195f5b 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ApiServiceRest.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ApiServiceRest.spec.ts @@ -12,7 +12,12 @@ */ import { expect, test } from '@playwright/test'; import { GlobalSettingOptions } from '../../constant/settings'; -import { descriptionBox, redirectToHomePage, uuid } from '../../utils/common'; +import { + descriptionBox, + redirectToHomePage, + toastNotification, + uuid, +} from '../../utils/common'; import { settingClick } from '../../utils/sidebar'; const apiServiceConfig = { @@ -89,10 +94,6 @@ test.describe('API service', () => { await deleteResponse; - await expect(page.locator('.Toastify__toast-body')).toHaveText( - /deleted successfully!/ - ); - - await page.click('.Toastify__close-button'); + await toastNotification(page, /deleted successfully!/); }); }); 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/Domains.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Domains.spec.ts index ef6d563b7d0a..622514a95520 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Domains.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Domains.spec.ts @@ -84,7 +84,11 @@ test.describe('Domains', () => { await deleteRes; await expect( - page.getByText(`"${domain.data.displayName}" deleted`) + page.getByText( + `"${ + domain.data.displayName ?? domain.data.name + }" deleted successfully!` + ) ).toBeVisible(); }); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts index f5c5fe15000f..84c13afb08cf 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts @@ -378,7 +378,8 @@ test.describe('Glossary tests', () => { await toastNotification( page, - /mutually exclusive and can't be assigned together/ + /mutually exclusive and can't be assigned together/, + 'error' ); // Add non mutually exclusive tags diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Policies.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Policies.spec.ts index 5ae25ec71fef..038c637ee8ef 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Policies.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Policies.spec.ts @@ -25,7 +25,11 @@ import { UPDATED_RULE_NAME, } from '../../constant/permission'; import { GlobalSettingOptions } from '../../constant/settings'; -import { descriptionBox, redirectToHomePage } from '../../utils/common'; +import { + descriptionBox, + redirectToHomePage, + toastNotification, +} from '../../utils/common'; import { validateFormNameFieldInput } from '../../utils/form'; import { settingClick } from '../../utils/sidebar'; @@ -258,8 +262,10 @@ test.describe('Policy page should work properly', () => { await page.locator('[data-testid="delete-rule"]').click(); // Validate the error message - await expect(page.locator('.Toastify__toast-body')).toContainText( - ERROR_MESSAGE_VALIDATION.lastRuleCannotBeRemoved + await toastNotification( + page, + ERROR_MESSAGE_VALIDATION.lastRuleCannotBeRemoved, + 'error' ); }); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Roles.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Roles.spec.ts index ce8df8423644..911747d396ad 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Roles.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Roles.spec.ts @@ -12,7 +12,12 @@ */ import { expect, test } from '@playwright/test'; import { GlobalSettingOptions } from '../../constant/settings'; -import { descriptionBox, redirectToHomePage, uuid } from '../../utils/common'; +import { + descriptionBox, + redirectToHomePage, + toastNotification, + uuid, +} from '../../utils/common'; import { removePolicyFromRole } from '../../utils/roles'; import { settingClick } from '../../utils/sidebar'; @@ -201,9 +206,12 @@ test('Roles page should work properly', async ({ page }) => { // Removing the last policy and validating the error message await removePolicyFromRole(page, policies.dataConsumerPolicy, roleName); - await expect(page.locator('.Toastify__toast-body')).toContainText( - errorMessageValidation.lastPolicyCannotBeRemoved + await toastNotification( + page, + errorMessageValidation.lastPolicyCannotBeRemoved, + 'error' ); + await expect(page.locator('.ant-table-row')).toContainText( policies.dataConsumerPolicy ); 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/e2e/Pages/TestCases.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/TestCases.spec.ts index 56bef624f085..76887b2056b9 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/TestCases.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/TestCases.spec.ts @@ -16,6 +16,7 @@ import { descriptionBox, getApiContext, redirectToHomePage, + toastNotification, } from '../../utils/common'; import { deleteTestCase, visitDataQualityTab } from '../../utils/testCases'; @@ -133,11 +134,7 @@ test('Table difference test case', async ({ page }) => { await page.getByTitle('name', { exact: true }).click(); await page.getByRole('button', { name: 'Submit' }).click(); - await expect(page.getByRole('alert')).toContainText( - 'Test case updated successfully.' - ); - - await page.getByLabel('close', { exact: true }).click(); + await toastNotification(page, 'Test case updated successfully.'); }); await test.step('Delete', async () => { @@ -233,11 +230,7 @@ test('Custom SQL Query', async ({ page }) => { await page.getByPlaceholder('Enter a Threshold').fill('244'); await page.getByRole('button', { name: 'Submit' }).click(); - await expect(page.getByRole('alert')).toContainText( - 'Test case updated successfully.' - ); - - await page.getByLabel('close', { exact: true }).click(); + await toastNotification(page, 'Test case updated successfully.'); }); await test.step('Delete', async () => { @@ -334,11 +327,7 @@ test('Column Values To Be Not Null', async ({ page }) => { await page.keyboard.type(' update'); await page.getByRole('button', { name: 'Submit' }).click(); - await expect(page.getByRole('alert')).toContainText( - 'Test case updated successfully.' - ); - - await page.getByLabel('close', { exact: true }).click(); + await toastNotification(page, 'Test case updated successfully.'); }); await test.step('Delete', async () => { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/VersionPages/EntityVersionPages.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/VersionPages/EntityVersionPages.spec.ts index 6b8ae01172a2..0f820128d263 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/VersionPages/EntityVersionPages.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/VersionPages/EntityVersionPages.spec.ts @@ -22,7 +22,11 @@ import { SearchIndexClass } from '../../support/entity/SearchIndexClass'; import { StoredProcedureClass } from '../../support/entity/StoredProcedureClass'; import { TableClass } from '../../support/entity/TableClass'; import { TopicClass } from '../../support/entity/TopicClass'; -import { createNewPage, redirectToHomePage } from '../../utils/common'; +import { + createNewPage, + redirectToHomePage, + toastNotification, +} from '../../utils/common'; import { addMultiOwner, assignTier } from '../../utils/entity'; const entities = [ @@ -233,11 +237,7 @@ entities.forEach((EntityClass) => { await deleteResponse; - await expect(page.locator('.Toastify__toast-body')).toHaveText( - /deleted successfully!/ - ); - - await page.click('.Toastify__close-button'); + await toastNotification(page, /deleted successfully!/); await page.reload(); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/VersionPages/ServiceEntityVersionPage.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/VersionPages/ServiceEntityVersionPage.spec.ts index d083c18c1ecc..f79a1a683071 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/VersionPages/ServiceEntityVersionPage.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/VersionPages/ServiceEntityVersionPage.spec.ts @@ -23,7 +23,11 @@ import { MlmodelServiceClass } from '../../support/entity/service/MlmodelService import { PipelineServiceClass } from '../../support/entity/service/PipelineServiceClass'; import { SearchIndexServiceClass } from '../../support/entity/service/SearchIndexServiceClass'; import { StorageServiceClass } from '../../support/entity/service/StorageServiceClass'; -import { createNewPage, redirectToHomePage } from '../../utils/common'; +import { + createNewPage, + redirectToHomePage, + toastNotification, +} from '../../utils/common'; import { addMultiOwner, assignTier } from '../../utils/entity'; const entities = [ @@ -198,11 +202,7 @@ entities.forEach((EntityClass) => { await deleteResponse; - await expect(page.locator('.Toastify__toast-body')).toHaveText( - /deleted successfully!/ - ); - - await page.click('.Toastify__close-button'); + await toastNotification(page, /deleted successfully!/); await page.reload(); 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/common.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts index f2b7ffc39d9c..c38ea012a884 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts @@ -113,15 +113,14 @@ export const getEntityTypeSearchIndexMapping = (entityType: string) => { export const toastNotification = async ( page: Page, - message: string | RegExp + message: string | RegExp, + type: 'info' | 'success' | 'warning' | 'error' = 'success' ) => { - await expect(page.getByRole('alert').first()).toHaveText(message); + await expect(page.locator(`.alert-container.${type}`)).toHaveText(message); - await page - .locator('.Toastify__toast') - .getByLabel('close', { exact: true }) - .first() - .click(); + await expect(page.locator('.ant-alert-icon')).toBeVisible(); + + await expect(page.locator('.alert-container button')).toBeVisible(); }; export const clickOutside = async (page: Page) => { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/customMetric.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/customMetric.ts index aec19a4749a9..e8cda5d48f75 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/customMetric.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/customMetric.ts @@ -16,6 +16,7 @@ import { NAME_MAX_LENGTH_VALIDATION_ERROR, NAME_VALIDATION_ERROR, } from '../constant/common'; +import { toastNotification } from './common'; type CustomMetricDetails = { page: Page; @@ -118,12 +119,11 @@ export const createCustomMetric = async ({ await page.click('[data-testid="submit-button"]'); await createMetricResponse; - await expect(page.locator('.Toastify__toast-body')).toHaveText( + await toastNotification( + page, new RegExp(`${metric.name} created successfully.`) ); - await page.locator('.Toastify__close-button').click(); - // verify the created custom metric await expect(page).toHaveURL(/profiler/); await expect( 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..93358e01247c 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts @@ -24,7 +24,7 @@ import { } from '../constant/delete'; import { ES_RESERVED_CHARACTERS } from '../constant/entity'; import { EntityTypeEndpoint } from '../support/entity/Entity.interface'; -import { clickOutside, redirectToHomePage } from './common'; +import { clickOutside, redirectToHomePage, toastNotification } from './common'; export const visitEntityPage = async (data: { page: Page; @@ -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') @@ -779,7 +789,6 @@ const announcementForm = async ( ); await page.click('#announcement-submit'); await announcementSubmit; - await page.click('.Toastify__close-button'); }; export const createAnnouncement = async ( @@ -1169,11 +1178,7 @@ export const restoreEntity = async (page: Page) => { await page.click('[data-testid="restore-button"]'); await page.click('button:has-text("Restore")'); - await expect(page.locator('.Toastify__toast-body')).toHaveText( - /restored successfully/ - ); - - await page.click('.Toastify__close-button'); + await toastNotification(page, /restored successfully/); const exists = await page .locator('[data-testid="deleted-badge"]') @@ -1212,11 +1217,7 @@ export const softDeleteEntity = async ( await deleteResponse; - await expect(page.locator('.Toastify__toast-body')).toHaveText( - /deleted successfully!/ - ); - - await page.click('.Toastify__close-button'); + await toastNotification(page, /deleted successfully!/); await page.reload(); @@ -1281,11 +1282,7 @@ export const hardDeleteEntity = async ( await page.click('[data-testid="confirm-button"]'); await deleteResponse; - await expect(page.locator('.Toastify__toast-body')).toHaveText( - /deleted successfully!/ - ); - - await page.click('.Toastify__close-button'); + await toastNotification(page, /deleted successfully!/); }; export const checkDataAssetWidget = async ( diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/lineage.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/lineage.ts index a41c7a16793a..9d09e344818d 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/lineage.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/lineage.ts @@ -63,9 +63,9 @@ export const LINEAGE_CSV_HEADERS = [ export const verifyColumnLayerInactive = async (page: Page) => { await page.click('[data-testid="lineage-layer-btn"]'); // Open Layer popover - await page.waitForSelector( - '[data-testid="lineage-layer-column-btn"]:not(.active)' - ); + + await expect(page.getByTestId('lineage-layer-column-btn')).not.toBeFocused; + await page.click('[data-testid="lineage-layer-btn"]'); // Close Layer popover }; 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/playwright/utils/user.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/user.ts index cc119a639cfe..ab96c291514a 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/user.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/user.ts @@ -138,11 +138,7 @@ export const softDeleteUserProfilePage = async ( await deleteResponse; - await expect(page.locator('.Toastify__toast-body')).toHaveText( - /deleted successfully!/ - ); - - await page.click('.Toastify__close-button'); + await toastNotification(page, /deleted successfully!/); await deletedUserChecks(page); }; @@ -719,7 +715,8 @@ const resetPasswordModal = async ( page, isOldPasswordCorrect ? 'Password updated successfully.' - : 'Old Password is not correct' + : 'Old Password is not correct', + isOldPasswordCorrect ? 'success' : 'error' ); }; 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/App.tsx b/openmetadata-ui/src/main/resources/ui/src/App.tsx index cbcc1eaef496..a8edf6289a81 100644 --- a/openmetadata-ui/src/main/resources/ui/src/App.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/App.tsx @@ -16,15 +16,12 @@ import React, { FC, useEffect } from 'react'; import { HelmetProvider } from 'react-helmet-async'; import { I18nextProvider } from 'react-i18next'; import { Router } from 'react-router-dom'; -import { ToastContainer } from 'react-toastify'; -import 'react-toastify/dist/ReactToastify.min.css'; import AppRouter from './components/AppRouter/AppRouter'; import { AuthProvider } from './components/Auth/AuthProviders/AuthProvider'; import ErrorBoundary from './components/common/ErrorBoundary/ErrorBoundary'; import { EntityExportModalProvider } from './components/Entity/EntityExportModalProvider/EntityExportModalProvider.component'; import ApplicationsProvider from './components/Settings/Applications/ApplicationsProvider/ApplicationsProvider'; import WebAnalyticsProvider from './components/WebAnalytics/WebAnalyticsProvider'; -import { TOAST_OPTIONS } from './constants/Toasts.constants'; import AntDConfigProvider from './context/AntDConfigProvider/AntDConfigProvider'; import PermissionProvider from './context/PermissionProvider/PermissionProvider'; import TourProvider from './context/TourProvider/TourProvider'; @@ -98,7 +95,6 @@ const App: FC = () => { - ); diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-cross.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-cross.svg new file mode 100644 index 000000000000..4edb4b8312eb --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-cross.svg @@ -0,0 +1,3 @@ + + + diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-error.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-error.svg new file mode 100644 index 000000000000..b321359d7d38 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-error.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-info-tag.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-info-tag.svg new file mode 100644 index 000000000000..0874efa5b7d6 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-info-tag.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-success.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-success.svg new file mode 100644 index 000000000000..53c6a0b63ecd --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-success.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-warning-tag.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-warning-tag.svg new file mode 100644 index 000000000000..00f5761b5e5b --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-warning-tag.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/FeedEditor/FeedEditor.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/FeedEditor/FeedEditor.tsx index 3f0b50625d5c..81e91ad53930 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/FeedEditor/FeedEditor.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/FeedEditor/FeedEditor.tsx @@ -12,6 +12,7 @@ */ /* eslint-disable @typescript-eslint/ban-ts-comment */ +import { TextAreaEmoji } from '@windmillcode/quill-emoji'; import classNames from 'classnames'; import { debounce, isNil } from 'lodash'; import { Parchment } from 'quill'; @@ -52,9 +53,11 @@ import searchClassBase from '../../../utils/SearchClassBase'; import { editorRef } from '../../common/RichTextEditor/RichTextEditor.interface'; import './feed-editor.less'; import { FeedEditorProp, MentionSuggestionsItem } from './FeedEditor.interface'; +import './quill-emoji.css'; Quill.register('modules/markdownOptions', QuillMarkdown); Quill.register(LinkBlot as unknown as Parchment.RegistryDefinition); +Quill.register('modules/emoji-textarea', TextAreaEmoji, true); const Delta = Quill.import('delta'); // eslint-disable-next-line @typescript-eslint/no-explicit-any const strikethrough = (_node: any, delta: typeof Delta) => { @@ -172,7 +175,6 @@ export const FeedEditor = forwardRef( }, [userProfilePics] ); - /** * Prepare modules for editor */ @@ -185,7 +187,7 @@ export const FeedEditor = forwardRef( insertRef: insertRef, }, }, - 'emoji-toolbar': false, + 'emoji-textarea': true, mention: { allowedChars: MENTION_ALLOWED_CHARS, mentionDenotationChars: MENTION_DENOTATION_CHARS, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/FeedEditor/feed-editor.less b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/FeedEditor/feed-editor.less index 35dc95cb02e3..10f0835a4112 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/FeedEditor/feed-editor.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/FeedEditor/feed-editor.less @@ -1001,6 +1001,9 @@ border-color: rgb(221, 227, 234); border-top-left-radius: 6px; border-top-right-radius: 6px; + display: flex; + padding: 16px 8px; + align-items: center; } .ql-container.ql-snow { border-color: rgb(221, 227, 234); @@ -1111,3 +1114,17 @@ button.ql-emoji { text-align: right; } } + +#om-quill-editor { + #tab-panel { + padding-top: 8px; + gap: 6px; + } + .emoji-tab.filter-flags { + display: none; + } + #tab-toolbar ul { + display: flex; + gap: 4px; + } +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/FeedEditor/quill-emoji.css b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/FeedEditor/quill-emoji.css new file mode 100644 index 000000000000..427a7b429ed6 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/FeedEditor/quill-emoji.css @@ -0,0 +1,4189 @@ +/* + * 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. + */ +#quill-editor { + position: relative; +} +.mention { + color: #0366d6; +} +.completions { + background: #fff; + border-radius: 2px; + box-shadow: 2px 2px 2px rgba(0, 0, 0, 0.25); + list-style: none; +} +.completions, +.completions > li { + margin: 0; + padding: 0; +} +.completions > li > button { + background: none; + border: none; + box-sizing: border-box; + display: block; + height: 2em; + margin: 0; + padding: 0.25em 0.5em; + text-align: left; + width: 100%; +} +.completions > li > button:hover { + background: #ddd; +} +.completions > li > button:focus { + background: #ddd; + outline: none; +} +.completions > li > button > .matched { + color: #000; + font-weight: 700; +} +.completions > li > button > * { + vertical-align: middle; +} +.emoji_completions { + background: #fff; + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 3px; + box-shadow: 0 5px 10px rgba(0, 0, 0, 0.12); + list-style: none; + margin: 0; + padding: 6px; +} +.emoji_completions li { + display: inline-block; + margin: 2px 0; + padding: 0; +} +.emoji_completions li:not(:last-of-type) { + margin-right: 3px; +} +.emoji_completions > li > button { + background: #efefef; + border: none; + border-radius: 3px; + box-sizing: border-box; + display: block; + margin: 0; + padding: 3px 2px 6px; + text-align: left; + width: 100%; +} +.emoji_completions > li > button:hover { + background: #2d9ee0; + color: #fff; +} +.emoji_completions > li > button:focus { + background: #2d9ee0; + color: #fff; + outline: none; +} +.emoji_completions > li > button.emoji-active { + background: red; + background: #2d9ee0; + color: #fff; + outline: none; +} +.emoji_completions > li > button > .matched { + font-weight: 700; +} +.emoji_completions > li > button > *, +.ico { + vertical-align: middle; +} +.ico { + font-size: 18px; + line-height: 0; + margin-right: 5px; +} +#emoji-palette { + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 3px; + box-shadow: 0 5px 10px rgba(0, 0, 0, 0.12); + max-width: 250px; + position: absolute; + z-index: 999; +} +.bem { + cursor: pointer; + display: inline-block; + font-size: 24px; + margin: 2px; + text-align: center; + width: 34px; +} +#tab-filters { + margin: 20px auto 0; + width: 210px; +} +.emoji-tab { + cursor: pointer; + display: inline-table; + height: 100%; + min-height: 30px; + text-align: center; + width: 30px; +} +#tab-toolbar { + background-color: #f7f7f7; + border-bottom: 1px solid rgba(0, 0, 0, 0.15); + padding: 4px 4px 0; +} +#tab-toolbar ul { + margin: 0; + padding: 0; +} +#tab-toolbar .active { + border-bottom: 3px solid #2ab27b; +} +#tab-panel { + background: #fff; + display: flex; + flex-wrap: wrap; + justify-content: center; + max-height: 220px; + overflow-y: scroll; + padding: 2px; +} +#quill-editor x-contain, +contain { + background: #fb8; + display: block; +} +#quill-editor table { + border-collapse: collapse; + width: 100%; +} +#quill-editor table td { + border: 1px solid #000; + height: 25px; + padding: 5px; +} +.ql-picker.ql-table .ql-picker-label:before, +button.ql-table:after { + content: 'TABLE'; +} +button.ql-contain:after { + content: 'WRAP'; +} +button.ql-table[value='append-row']:after { + content: 'ROWS+'; +} +button.ql-table[value='append-col']:after { + content: 'COLS+'; +} +.ql-contain, +.ql-table { + margin-right: -15px; + width: auto !important; +} +#emoji-close-div { + height: 100%; + left: 0; + position: fixed; + top: 0; + width: 100%; +} +.textarea-emoji-control { + height: 25px; + right: 4px; + top: 10px; + width: 25px; +} +#textarea-emoji { + border: 1px solid #66afe9; + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 3px; + box-shadow: 0 5px 10px rgba(0, 0, 0, 0.12); + max-width: 250px; + position: absolute; + right: 0; + z-index: 999; +} +.ql-editor { + padding-right: 26px; +} +.i-activity { + background: url('data:image/svg+xml;utf8,'); +} +.i-activity, +.i-flags { + content: ''; + height: 25px; + margin: auto; + width: 25px; +} +.i-flags { + background: url('data:image/svg+xml;utf8,'); +} +.i-food { + background: url('data:image/svg+xml;utf8,'); +} +.i-food, +.i-nature { + content: ''; + height: 25px; + margin: auto; + width: 25px; +} +.i-nature { + background: url('data:image/svg+xml;utf8,'); +} +.i-objects { + background: url('data:image/svg+xml;utf8,'); +} +.i-objects, +.i-people { + content: ''; + height: 25px; + margin: auto; + width: 25px; +} +.i-people { + background: url('data:image/svg+xml;utf8,'); +} +.i-symbols { + background: url('data:image/svg+xml;utf8,'); +} +.i-symbols, +.i-travel { + content: ''; + height: 25px; + margin: auto; + width: 25px; +} +.i-travel { + background: url('data:image/svg+xml;utf8,'); +} +.button-emoji { + margin-bottom: -5px; +} +.ql-emojiblot { + display: inline-block; + vertical-align: text-top; +} +/* .ap { + background-image: url(1e7b63404cd2fb8e6525b2fd4ee4d286.png); + background-repeat: no-repeat; + background-size: 820px; + box-sizing: border-box; + display: inline-flex; + font-size: 20px; + height: 20px; + line-height: 1; + margin-top: -3px; + overflow: hidden; + text-indent: -999px; + width: 20px; +} */ +.ap-copyright { + background-position: 0 0; +} +.ap-registered { + background-position: 0 -20px; +} +.ap-bangbang { + background-position: 0 -40px; +} +.ap-interrobang { + background-position: 0 -60px; +} +.ap-tm { + background-position: 0 -80px; +} +.ap-information_source { + background-position: 0 -100px; +} +.ap-left_right_arrow { + background-position: 0 -120px; +} +.ap-arrow_up_down { + background-position: 0 -140px; +} +.ap-arrow_upper_left { + background-position: 0 -160px; +} +.ap-arrow_upper_right { + background-position: 0 -180px; +} +.ap-arrow_lower_right { + background-position: 0 -200px; +} +.ap-arrow_lower_left { + background-position: 0 -220px; +} +.ap-leftwards_arrow_with_hook { + background-position: 0 -240px; +} +.ap-arrow_right_hook { + background-position: 0 -260px; +} +.ap-watch { + background-position: 0 -280px; +} +.ap-hourglass { + background-position: 0 -300px; +} +.ap-keyboard { + background-position: 0 -320px; +} +.ap-fast_forward { + background-position: 0 -360px; +} +.ap-rewind { + background-position: 0 -380px; +} +.ap-arrow_double_up { + background-position: 0 -400px; +} +.ap-arrow_double_down { + background-position: 0 -420px; +} +.ap-black_right_pointing_double_triangle_with_vertical_bar { + background-position: 0 -440px; +} +.ap-black_left_pointing_double_triangle_with_vertical_bar { + background-position: 0 -460px; +} +.ap-black_right_pointing_triangle_with_double_vertical_bar { + background-position: 0 -480px; +} +.ap-alarm_clock { + background-position: 0 -500px; +} +.ap-stopwatch { + background-position: 0 -520px; +} +.ap-timer_clock { + background-position: 0 -540px; +} +.ap-hourglass_flowing_sand { + background-position: 0 -560px; +} +.ap-double_vertical_bar { + background-position: 0 -580px; +} +.ap-black_square_for_stop { + background-position: 0 -600px; +} +.ap-black_circle_for_record { + background-position: 0 -620px; +} +.ap-m { + background-position: 0 -640px; +} +.ap-black_small_square { + background-position: 0 -660px; +} +.ap-white_small_square { + background-position: 0 -680px; +} +.ap-arrow_forward { + background-position: 0 -700px; +} +.ap-arrow_backward { + background-position: 0 -720px; +} +.ap-white_medium_square { + background-position: 0 -740px; +} +.ap-black_medium_square { + background-position: 0 -760px; +} +.ap-white_medium_small_square { + background-position: 0 -780px; +} +.ap-black_medium_small_square { + background-position: 0 -800px; +} +.ap-sunny { + background-position: -20px 0; +} +.ap-cloud { + background-position: -20px -20px; +} +.ap-umbrella { + background-position: -20px -40px; +} +.ap-snowman { + background-position: -20px -60px; +} +.ap-comet { + background-position: -20px -80px; +} +.ap-phone, +.ap-telephone { + background-position: -20px -100px; +} +.ap-ballot_box_with_check { + background-position: -20px -120px; +} +.ap-umbrella_with_rain_drops { + background-position: -20px -140px; +} +.ap-coffee { + background-position: -20px -160px; +} +.ap-shamrock { + background-position: -20px -180px; +} +.ap-point_up { + background-position: -20px -200px; +} +.ap-skull_and_crossbones { + background-position: -20px -320px; +} +.ap-radioactive_sign { + background-position: -20px -340px; +} +.ap-biohazard_sign { + background-position: -20px -360px; +} +.ap-orthodox_cross { + background-position: -20px -380px; +} +.ap-star_and_crescent { + background-position: -20px -400px; +} +.ap-peace_symbol { + background-position: -20px -420px; +} +.ap-yin_yang { + background-position: -20px -440px; +} +.ap-wheel_of_dharma { + background-position: -20px -460px; +} +.ap-white_frowning_face { + background-position: -20px -480px; +} +.ap-relaxed { + background-position: -20px -500px; +} +.ap-aries { + background-position: -20px -520px; +} +.ap-taurus { + background-position: -20px -540px; +} +.ap-gemini { + background-position: -20px -560px; +} +.ap-cancer { + background-position: -20px -580px; +} +.ap-leo { + background-position: -20px -600px; +} +.ap-virgo { + background-position: -20px -620px; +} +.ap-libra { + background-position: -20px -640px; +} +.ap-scorpius { + background-position: -20px -660px; +} +.ap-sagittarius { + background-position: -20px -680px; +} +.ap-capricorn { + background-position: -20px -700px; +} +.ap-aquarius { + background-position: -20px -720px; +} +.ap-pisces { + background-position: -20px -740px; +} +.ap-spades { + background-position: -20px -760px; +} +.ap-clubs { + background-position: -20px -780px; +} +.ap-hearts { + background-position: -20px -800px; +} +.ap-diamonds { + background-position: -40px 0; +} +.ap-hotsprings { + background-position: -40px -20px; +} +.ap-recycle { + background-position: -40px -40px; +} +.ap-wheelchair { + background-position: -40px -60px; +} +.ap-hammer_and_pick { + background-position: -40px -80px; +} +.ap-anchor { + background-position: -40px -100px; +} +.ap-crossed_swords { + background-position: -40px -120px; +} +.ap-scales { + background-position: -40px -140px; +} +.ap-alembic { + background-position: -40px -160px; +} +.ap-gear { + background-position: -40px -180px; +} +.ap-atom_symbol { + background-position: -40px -200px; +} +.ap-fleur_de_lis { + background-position: -40px -220px; +} +.ap-warning { + background-position: -40px -240px; +} +.ap-zap { + background-position: -40px -260px; +} +.ap-white_circle { + background-position: -40px -280px; +} +.ap-black_circle { + background-position: -40px -300px; +} +.ap-coffin { + background-position: -40px -320px; +} +.ap-funeral_urn { + background-position: -40px -340px; +} +.ap-soccer { + background-position: -40px -360px; +} +.ap-baseball { + background-position: -40px -380px; +} +.ap-snowman_without_snow { + background-position: -40px -400px; +} +.ap-partly_sunny { + background-position: -40px -420px; +} +.ap-thunder_cloud_and_rain { + background-position: -40px -440px; +} +.ap-ophiuchus { + background-position: -40px -460px; +} +.ap-pick { + background-position: -40px -480px; +} +.ap-helmet_with_white_cross { + background-position: -40px -500px; +} +.ap-chains { + background-position: -40px -520px; +} +.ap-no_entry { + background-position: -40px -540px; +} +.ap-shinto_shrine { + background-position: -40px -560px; +} +.ap-church { + background-position: -40px -580px; +} +.ap-mountain { + background-position: -40px -600px; +} +.ap-beach_umbrella, +.ap-umbrella_on_ground { + background-position: -40px -620px; +} +.ap-fountain { + background-position: -40px -640px; +} +.ap-golf { + background-position: -40px -660px; +} +.ap-ferry { + background-position: -40px -680px; +} +.ap-boat { + background-position: -40px -700px; +} +.ap-skier { + background-position: -40px -720px; +} +.ap-ice_skate { + background-position: -40px -740px; +} +.ap-person_with_ball { + background-position: -40px -760px; +} +.ap-tent { + background-position: -60px -60px; +} +.ap-fuelpump { + background-position: -60px -80px; +} +.ap-scissors { + background-position: -60px -100px; +} +.ap-white_check_mark { + background-position: -60px -120px; +} +.ap-airplane { + background-position: -60px -140px; +} +.ap-email { + background-position: -60px -160px; +} +.ap-fist { + background-position: -60px -180px; +} +.ap-hand { + background-position: -60px -300px; +} +.ap-v { + background-position: -60px -420px; +} +.ap-writing_hand { + background-position: -60px -540px; +} +.ap-pencil2 { + background-position: -60px -660px; +} +.ap-black_nib { + background-position: -60px -680px; +} +.ap-heavy_check_mark { + background-position: -60px -700px; +} +.ap-heavy_multiplication_x { + background-position: -60px -720px; +} +.ap-latin_cross { + background-position: -60px -740px; +} +.ap-star_of_david { + background-position: -60px -760px; +} +.ap-sparkles { + background-position: -60px -780px; +} +.ap-eight_spoked_asterisk { + background-position: -60px -800px; +} +.ap-eight_pointed_black_star { + background-position: -80px 0; +} +.ap-snowflake { + background-position: -80px -20px; +} +.ap-sparkle { + background-position: -80px -40px; +} +.ap-x { + background-position: -80px -60px; +} +.ap-negative_squared_cross_mark { + background-position: -80px -80px; +} +.ap-question { + background-position: -80px -100px; +} +.ap-grey_question { + background-position: -80px -120px; +} +.ap-grey_exclamation { + background-position: -80px -140px; +} +.ap-exclamation { + background-position: -80px -160px; +} +.ap-heavy_heart_exclamation_mark_ornament { + background-position: -80px -180px; +} +.ap-heart { + background-position: -80px -200px; +} +.ap-heavy_plus_sign { + background-position: -80px -220px; +} +.ap-heavy_minus_sign { + background-position: -80px -240px; +} +.ap-heavy_division_sign { + background-position: -80px -260px; +} +.ap-arrow_right { + background-position: -80px -280px; +} +.ap-curly_loop { + background-position: -80px -300px; +} +.ap-loop { + background-position: -80px -320px; +} +.ap-arrow_heading_up { + background-position: -80px -340px; +} +.ap-arrow_heading_down { + background-position: -80px -360px; +} +.ap-arrow_left { + background-position: -80px -380px; +} +.ap-arrow_up { + background-position: -80px -400px; +} +.ap-arrow_down { + background-position: -80px -420px; +} +.ap-black_large_square { + background-position: -80px -440px; +} +.ap-white_large_square { + background-position: -80px -460px; +} +.ap-star { + background-position: -80px -480px; +} +.ap-o { + background-position: -80px -500px; +} +.ap-wavy_dash { + background-position: -80px -520px; +} +.ap-part_alternation_mark { + background-position: -80px -540px; +} +.ap-congratulations { + background-position: -80px -560px; +} +.ap-secret { + background-position: -80px -580px; +} +.ap-mahjong { + background-position: -80px -600px; +} +.ap-black_joker { + background-position: -80px -620px; +} +.ap-a { + background-position: -80px -640px; +} +.ap-b { + background-position: -80px -660px; +} +.ap-o2 { + background-position: -80px -680px; +} +.ap-parking { + background-position: -80px -700px; +} +.ap-ab { + background-position: -80px -720px; +} +.ap-cl { + background-position: -80px -740px; +} +.ap-cool { + background-position: -80px -760px; +} +.ap-free { + background-position: -80px -780px; +} +.ap-id { + background-position: -80px -800px; +} +.ap-new { + background-position: -100px 0; +} +.ap-ng { + background-position: -100px -20px; +} +.ap-ok { + background-position: -100px -40px; +} +.ap-sos { + background-position: -100px -60px; +} +.ap-up { + background-position: -100px -80px; +} +.ap-vs { + background-position: -100px -100px; +} +.ap-koko { + background-position: -100px -120px; +} +.ap-sa { + background-position: -100px -140px; +} +.ap-u7121 { + background-position: -100px -160px; +} +.ap-u6307 { + background-position: -100px -180px; +} +.ap-u7981 { + background-position: -100px -200px; +} +.ap-u7a7a { + background-position: -100px -220px; +} +.ap-u5408 { + background-position: -100px -240px; +} +.ap-u6e80 { + background-position: -100px -260px; +} +.ap-u6709 { + background-position: -100px -280px; +} +.ap-u6708 { + background-position: -100px -300px; +} +.ap-u7533 { + background-position: -100px -320px; +} +.ap-u5272 { + background-position: -100px -340px; +} +.ap-u55b6 { + background-position: -100px -360px; +} +.ap-ideograph_advantage { + background-position: -100px -380px; +} +.ap-accept { + background-position: -100px -400px; +} +.ap-cyclone { + background-position: -100px -420px; +} +.ap-foggy { + background-position: -100px -440px; +} +.ap-closed_umbrella { + background-position: -100px -460px; +} +.ap-night_with_stars { + background-position: -100px -480px; +} +.ap-sunrise_over_mountains { + background-position: -100px -500px; +} +.ap-sunrise { + background-position: -100px -520px; +} +.ap-city_sunset { + background-position: -100px -540px; +} +.ap-city_sunrise { + background-position: -100px -560px; +} +.ap-rainbow { + background-position: -100px -580px; +} +.ap-bridge_at_night { + background-position: -100px -600px; +} +.ap-ocean { + background-position: -100px -620px; +} +.ap-volcano { + background-position: -100px -640px; +} +.ap-milky_way { + background-position: -100px -660px; +} +.ap-earth_africa { + background-position: -100px -680px; +} +.ap-earth_americas { + background-position: -100px -700px; +} +.ap-earth_asia { + background-position: -100px -720px; +} +.ap-globe_with_meridians { + background-position: -100px -740px; +} +.ap-new_moon { + background-position: -100px -760px; +} +.ap-waxing_crescent_moon { + background-position: -100px -780px; +} +.ap-first_quarter_moon { + background-position: -100px -800px; +} +.ap-moon { + background-position: -120px 0; +} +.ap-full_moon { + background-position: -120px -20px; +} +.ap-waning_gibbous_moon { + background-position: -120px -40px; +} +.ap-last_quarter_moon { + background-position: -120px -60px; +} +.ap-waning_crescent_moon { + background-position: -120px -80px; +} +.ap-crescent_moon { + background-position: -120px -100px; +} +.ap-new_moon_with_face { + background-position: -120px -120px; +} +.ap-first_quarter_moon_with_face { + background-position: -120px -140px; +} +.ap-last_quarter_moon_with_face { + background-position: -120px -160px; +} +.ap-full_moon_with_face { + background-position: -120px -180px; +} +.ap-sun_with_face { + background-position: -120px -200px; +} +.ap-star2 { + background-position: -120px -220px; +} +.ap-stars { + background-position: -120px -240px; +} +.ap-thermometer { + background-position: -120px -260px; +} +.ap-mostly_sunny { + background-position: -120px -280px; +} +.ap-barely_sunny { + background-position: -120px -300px; +} +.ap-partly_sunny_rain { + background-position: -120px -320px; +} +.ap-rain_cloud { + background-position: -120px -340px; +} +.ap-snow_cloud { + background-position: -120px -360px; +} +.ap-lightning { + background-position: -120px -380px; +} +.ap-tornado { + background-position: -120px -400px; +} +.ap-fog { + background-position: -120px -420px; +} +.ap-wind_blowing_face { + background-position: -120px -440px; +} +.ap-hotdog { + background-position: -120px -460px; +} +.ap-taco { + background-position: -120px -480px; +} +.ap-burrito { + background-position: -120px -500px; +} +.ap-chestnut { + background-position: -120px -520px; +} +.ap-seedling { + background-position: -120px -540px; +} +.ap-evergreen_tree { + background-position: -120px -560px; +} +.ap-deciduous_tree { + background-position: -120px -580px; +} +.ap-palm_tree { + background-position: -120px -600px; +} +.ap-cactus { + background-position: -120px -620px; +} +.ap-hot_pepper { + background-position: -120px -640px; +} +.ap-tulip { + background-position: -120px -660px; +} +.ap-cherry_blossom { + background-position: -120px -680px; +} +.ap-rose { + background-position: -120px -700px; +} +.ap-hibiscus { + background-position: -120px -720px; +} +.ap-sunflower { + background-position: -120px -740px; +} +.ap-blossom { + background-position: -120px -760px; +} +.ap-corn { + background-position: -120px -780px; +} +.ap-ear_of_rice { + background-position: -120px -800px; +} +.ap-herb { + background-position: -140px 0; +} +.ap-four_leaf_clover { + background-position: -140px -20px; +} +.ap-maple_leaf { + background-position: -140px -40px; +} +.ap-fallen_leaf { + background-position: -140px -60px; +} +.ap-leaves { + background-position: -140px -80px; +} +.ap-mushroom { + background-position: -140px -100px; +} +.ap-tomato { + background-position: -140px -120px; +} +.ap-eggplant { + background-position: -140px -140px; +} +.ap-grapes { + background-position: -140px -160px; +} +.ap-melon { + background-position: -140px -180px; +} +.ap-watermelon { + background-position: -140px -200px; +} +.ap-tangerine { + background-position: -140px -220px; +} +.ap-lemon { + background-position: -140px -240px; +} +.ap-banana { + background-position: -140px -260px; +} +.ap-pineapple { + background-position: -140px -280px; +} +.ap-apple { + background-position: -140px -300px; +} +.ap-green_apple { + background-position: -140px -320px; +} +.ap-pear { + background-position: -140px -340px; +} +.ap-peach { + background-position: -140px -360px; +} +.ap-cherries { + background-position: -140px -380px; +} +.ap-strawberry { + background-position: -140px -400px; +} +.ap-hamburger { + background-position: -140px -420px; +} +.ap-pizza { + background-position: -140px -440px; +} +.ap-meat_on_bone { + background-position: -140px -460px; +} +.ap-poultry_leg { + background-position: -140px -480px; +} +.ap-rice_cracker { + background-position: -140px -500px; +} +.ap-rice_ball { + background-position: -140px -520px; +} +.ap-rice { + background-position: -140px -540px; +} +.ap-curry { + background-position: -140px -560px; +} +.ap-ramen { + background-position: -140px -580px; +} +.ap-spaghetti { + background-position: -140px -600px; +} +.ap-bread { + background-position: -140px -620px; +} +.ap-fries { + background-position: -140px -640px; +} +.ap-sweet_potato { + background-position: -140px -660px; +} +.ap-dango { + background-position: -140px -680px; +} +.ap-oden { + background-position: -140px -700px; +} +.ap-sushi { + background-position: -140px -720px; +} +.ap-fried_shrimp { + background-position: -140px -740px; +} +.ap-fish_cake { + background-position: -140px -760px; +} +.ap-icecream { + background-position: -140px -780px; +} +.ap-shaved_ice { + background-position: -140px -800px; +} +.ap-ice_cream { + background-position: -160px 0; +} +.ap-doughnut { + background-position: -160px -20px; +} +.ap-cookie { + background-position: -160px -40px; +} +.ap-chocolate_bar { + background-position: -160px -60px; +} +.ap-candy { + background-position: -160px -80px; +} +.ap-lollipop { + background-position: -160px -100px; +} +.ap-custard { + background-position: -160px -120px; +} +.ap-honey_pot { + background-position: -160px -140px; +} +.ap-cake { + background-position: -160px -160px; +} +.ap-bento { + background-position: -160px -180px; +} +.ap-stew { + background-position: -160px -200px; +} +.ap-egg { + background-position: -160px -220px; +} +.ap-fork_and_knife { + background-position: -160px -240px; +} +.ap-tea { + background-position: -160px -260px; +} +.ap-sake { + background-position: -160px -280px; +} +.ap-wine_glass { + background-position: -160px -300px; +} +.ap-cocktail { + background-position: -160px -320px; +} +.ap-tropical_drink { + background-position: -160px -340px; +} +.ap-beer { + background-position: -160px -360px; +} +.ap-beers { + background-position: -160px -380px; +} +.ap-baby_bottle { + background-position: -160px -400px; +} +.ap-knife_fork_plate { + background-position: -160px -420px; +} +.ap-champagne { + background-position: -160px -440px; +} +.ap-popcorn { + background-position: -160px -460px; +} +.ap-ribbon { + background-position: -160px -480px; +} +.ap-gift { + background-position: -160px -500px; +} +.ap-birthday { + background-position: -160px -520px; +} +.ap-jack_o_lantern { + background-position: -160px -540px; +} +.ap-christmas_tree { + background-position: -160px -560px; +} +.ap-santa { + background-position: -160px -580px; +} +.ap-fireworks { + background-position: -160px -700px; +} +.ap-sparkler { + background-position: -160px -720px; +} +.ap-balloon { + background-position: -160px -740px; +} +.ap-tada { + background-position: -160px -760px; +} +.ap-confetti_ball { + background-position: -160px -780px; +} +.ap-tanabata_tree { + background-position: -160px -800px; +} +.ap-crossed_flags { + background-position: -180px 0; +} +.ap-bamboo { + background-position: -180px -20px; +} +.ap-dolls { + background-position: -180px -40px; +} +.ap-flags { + background-position: -180px -60px; +} +.ap-wind_chime { + background-position: -180px -80px; +} +.ap-rice_scene { + background-position: -180px -100px; +} +.ap-school_satchel { + background-position: -180px -120px; +} +.ap-mortar_board { + background-position: -180px -140px; +} +.ap-medal { + background-position: -180px -160px; +} +.ap-reminder_ribbon { + background-position: -180px -180px; +} +.ap-studio_microphone { + background-position: -180px -200px; +} +.ap-level_slider { + background-position: -180px -220px; +} +.ap-control_knobs { + background-position: -180px -240px; +} +.ap-film_frames { + background-position: -180px -260px; +} +.ap-admission_tickets { + background-position: -180px -280px; +} +.ap-carousel_horse { + background-position: -180px -300px; +} +.ap-ferris_wheel { + background-position: -180px -320px; +} +.ap-roller_coaster { + background-position: -180px -340px; +} +.ap-fishing_pole_and_fish { + background-position: -180px -360px; +} +.ap-microphone { + background-position: -180px -380px; +} +.ap-movie_camera { + background-position: -180px -400px; +} +.ap-cinema { + background-position: -180px -420px; +} +.ap-headphones { + background-position: -180px -440px; +} +.ap-art { + background-position: -180px -460px; +} +.ap-tophat { + background-position: -180px -480px; +} +.ap-circus_tent { + background-position: -180px -500px; +} +.ap-ticket { + background-position: -180px -520px; +} +.ap-clapper { + background-position: -180px -540px; +} +.ap-performing_arts { + background-position: -180px -560px; +} +.ap-video_game { + background-position: -180px -580px; +} +.ap-dart { + background-position: -180px -600px; +} +.ap-slot_machine { + background-position: -180px -620px; +} +.ap-8ball { + background-position: -180px -640px; +} +.ap-game_die { + background-position: -180px -660px; +} +.ap-bowling { + background-position: -180px -680px; +} +.ap-flower_playing_cards { + background-position: -180px -700px; +} +.ap-musical_note { + background-position: -180px -720px; +} +.ap-notes { + background-position: -180px -740px; +} +.ap-saxophone { + background-position: -180px -760px; +} +.ap-guitar { + background-position: -180px -780px; +} +.ap-musical_keyboard { + background-position: -180px -800px; +} +.ap-trumpet { + background-position: -200px 0; +} +.ap-violin { + background-position: -200px -20px; +} +.ap-musical_score { + background-position: -200px -40px; +} +.ap-running_shirt_with_sash { + background-position: -200px -60px; +} +.ap-tennis { + background-position: -200px -80px; +} +.ap-ski { + background-position: -200px -100px; +} +.ap-basketball { + background-position: -200px -120px; +} +.ap-checkered_flag { + background-position: -200px -140px; +} +.ap-snowboarder { + background-position: -200px -160px; +} +.ap-runner { + background-position: -200px -180px; +} +.ap-surfer { + background-position: -200px -300px; +} +.ap-sports_medal { + background-position: -200px -420px; +} +.ap-trophy { + background-position: -200px -440px; +} +.ap-horse_racing { + background-position: -200px -460px; +} +.ap-football { + background-position: -200px -480px; +} +.ap-rugby_football { + background-position: -200px -500px; +} +.ap-swimmer { + background-position: -200px -520px; +} +.ap-weight_lifter { + background-position: -200px -640px; +} +.ap-golfer { + background-position: -200px -760px; +} +.ap-racing_motorcycle { + background-position: -200px -780px; +} +.ap-racing_car { + background-position: -200px -800px; +} +.ap-cricket_bat_and_ball { + background-position: -220px 0; +} +.ap-volleyball { + background-position: -220px -20px; +} +.ap-field_hockey_stick_and_ball { + background-position: -220px -40px; +} +.ap-ice_hockey_stick_and_puck { + background-position: -220px -60px; +} +.ap-table_tennis_paddle_and_ball { + background-position: -220px -80px; +} +.ap-snow_capped_mountain { + background-position: -220px -100px; +} +.ap-camping { + background-position: -220px -120px; +} +.ap-beach_with_umbrella { + background-position: -220px -140px; +} +.ap-building_construction { + background-position: -220px -160px; +} +.ap-house_buildings { + background-position: -220px -180px; +} +.ap-cityscape { + background-position: -220px -200px; +} +.ap-derelict_house_building { + background-position: -220px -220px; +} +.ap-classical_building { + background-position: -220px -240px; +} +.ap-desert { + background-position: -220px -260px; +} +.ap-desert_island { + background-position: -220px -280px; +} +.ap-national_park { + background-position: -220px -300px; +} +.ap-stadium { + background-position: -220px -320px; +} +.ap-house { + background-position: -220px -340px; +} +.ap-house_with_garden { + background-position: -220px -360px; +} +.ap-office { + background-position: -220px -380px; +} +.ap-post_office { + background-position: -220px -400px; +} +.ap-european_post_office { + background-position: -220px -420px; +} +.ap-hospital { + background-position: -220px -440px; +} +.ap-bank { + background-position: -220px -460px; +} +.ap-atm { + background-position: -220px -480px; +} +.ap-hotel { + background-position: -220px -500px; +} +.ap-love_hotel { + background-position: -220px -520px; +} +.ap-convenience_store { + background-position: -220px -540px; +} +.ap-school { + background-position: -220px -560px; +} +.ap-department_store { + background-position: -220px -580px; +} +.ap-factory { + background-position: -220px -600px; +} +.ap-izakaya_lantern { + background-position: -220px -620px; +} +.ap-japanese_castle { + background-position: -220px -640px; +} +.ap-european_castle { + background-position: -220px -660px; +} +.ap-waving_white_flag { + background-position: -220px -680px; +} +.ap-waving_black_flag { + background-position: -220px -700px; +} +.ap-rosette { + background-position: -220px -720px; +} +.ap-label { + background-position: -220px -740px; +} +.ap-badminton_racquet_and_shuttlecock { + background-position: -220px -760px; +} +.ap-bow_and_arrow { + background-position: -220px -780px; +} +.ap-amphora { + background-position: -220px -800px; +} +.ap-skin-tone-2 { + background-position: -240px 0; +} +.ap-skin-tone-3 { + background-position: -240px -20px; +} +.ap-skin-tone-4 { + background-position: -240px -40px; +} +.ap-skin-tone-5 { + background-position: -240px -60px; +} +.ap-skin-tone-6 { + background-position: -240px -80px; +} +.ap-rat { + background-position: -240px -100px; +} +.ap-mouse2 { + background-position: -240px -120px; +} +.ap-ox { + background-position: -240px -140px; +} +.ap-water_buffalo { + background-position: -240px -160px; +} +.ap-cow2 { + background-position: -240px -180px; +} +.ap-tiger2 { + background-position: -240px -200px; +} +.ap-leopard { + background-position: -240px -220px; +} +.ap-rabbit2 { + background-position: -240px -240px; +} +.ap-cat2 { + background-position: -240px -260px; +} +.ap-dragon { + background-position: -240px -280px; +} +.ap-crocodile { + background-position: -240px -300px; +} +.ap-whale2 { + background-position: -240px -320px; +} +.ap-snail { + background-position: -240px -340px; +} +.ap-snake { + background-position: -240px -360px; +} +.ap-racehorse { + background-position: -240px -380px; +} +.ap-ram { + background-position: -240px -400px; +} +.ap-goat { + background-position: -240px -420px; +} +.ap-sheep { + background-position: -240px -440px; +} +.ap-monkey { + background-position: -240px -460px; +} +.ap-rooster { + background-position: -240px -480px; +} +.ap-chicken { + background-position: -240px -500px; +} +.ap-dog2 { + background-position: -240px -520px; +} +.ap-pig2 { + background-position: -240px -540px; +} +.ap-boar { + background-position: -240px -560px; +} +.ap-elephant { + background-position: -240px -580px; +} +.ap-octopus { + background-position: -240px -600px; +} +.ap-shell { + background-position: -240px -620px; +} +.ap-bug { + background-position: -240px -640px; +} +.ap-ant { + background-position: -240px -660px; +} +.ap-bee { + background-position: -240px -680px; +} +.ap-beetle { + background-position: -240px -700px; +} +.ap-fish { + background-position: -240px -720px; +} +.ap-tropical_fish { + background-position: -240px -740px; +} +.ap-blowfish { + background-position: -240px -760px; +} +.ap-turtle { + background-position: -240px -780px; +} +.ap-hatching_chick { + background-position: -240px -800px; +} +.ap-baby_chick { + background-position: -260px 0; +} +.ap-hatched_chick { + background-position: -260px -20px; +} +.ap-bird { + background-position: -260px -40px; +} +.ap-penguin { + background-position: -260px -60px; +} +.ap-koala { + background-position: -260px -80px; +} +.ap-poodle { + background-position: -260px -100px; +} +.ap-dromedary_camel { + background-position: -260px -120px; +} +.ap-camel { + background-position: -260px -140px; +} +.ap-dolphin { + background-position: -260px -160px; +} +.ap-mouse { + background-position: -260px -180px; +} +.ap-cow { + background-position: -260px -200px; +} +.ap-tiger { + background-position: -260px -220px; +} +.ap-rabbit { + background-position: -260px -240px; +} +.ap-cat { + background-position: -260px -260px; +} +.ap-dragon_face { + background-position: -260px -280px; +} +.ap-whale { + background-position: -260px -300px; +} +.ap-horse { + background-position: -260px -320px; +} +.ap-monkey_face { + background-position: -260px -340px; +} +.ap-dog { + background-position: -260px -360px; +} +.ap-pig { + background-position: -260px -380px; +} +.ap-frog { + background-position: -260px -400px; +} +.ap-hamster { + background-position: -260px -420px; +} +.ap-wolf { + background-position: -260px -440px; +} +.ap-bear { + background-position: -260px -460px; +} +.ap-panda_face { + background-position: -260px -480px; +} +.ap-pig_nose { + background-position: -260px -500px; +} +.ap-feet { + background-position: -260px -520px; +} +.ap-chipmunk { + background-position: -260px -540px; +} +.ap-eyes { + background-position: -260px -560px; +} +.ap-eye { + background-position: -260px -580px; +} +.ap-ear { + background-position: -260px -600px; +} +.ap-nose { + background-position: -260px -720px; +} +.ap-lips { + background-position: -280px -20px; +} +.ap-tongue { + background-position: -280px -40px; +} +.ap-point_up_2 { + background-position: -280px -60px; +} +.ap-point_down { + background-position: -280px -180px; +} +.ap-point_left { + background-position: -280px -300px; +} +.ap-point_right { + background-position: -280px -420px; +} +.ap-facepunch { + background-position: -280px -540px; +} +.ap-wave { + background-position: -280px -660px; +} +.ap-ok_hand { + background-position: -280px -780px; +} +.ap-thumbsup { + background-position: -300px -80px; +} +.ap--1, +.ap-thumbsdown { + background-position: -300px -200px; +} +.ap-clap { + background-position: -300px -320px; +} +.ap-open_hands { + background-position: -300px -440px; +} +.ap-crown { + background-position: -300px -560px; +} +.ap-womans_hat { + background-position: -300px -580px; +} +.ap-eyeglasses { + background-position: -300px -600px; +} +.ap-necktie { + background-position: -300px -620px; +} +.ap-shirt { + background-position: -300px -640px; +} +.ap-jeans { + background-position: -300px -660px; +} +.ap-dress { + background-position: -300px -680px; +} +.ap-kimono { + background-position: -300px -700px; +} +.ap-bikini { + background-position: -300px -720px; +} +.ap-womans_clothes { + background-position: -300px -740px; +} +.ap-purse { + background-position: -300px -760px; +} +.ap-handbag { + background-position: -300px -780px; +} +.ap-pouch { + background-position: -300px -800px; +} +.ap-mans_shoe { + background-position: -320px 0; +} +.ap-athletic_shoe { + background-position: -320px -20px; +} +.ap-high_heel { + background-position: -320px -40px; +} +.ap-sandal { + background-position: -320px -60px; +} +.ap-boot { + background-position: -320px -80px; +} +.ap-footprints { + background-position: -320px -100px; +} +.ap-bust_in_silhouette { + background-position: -320px -120px; +} +.ap-busts_in_silhouette { + background-position: -320px -140px; +} +.ap-boy { + background-position: -320px -160px; +} +.ap-girl { + background-position: -320px -280px; +} +.ap-man { + background-position: -320px -400px; +} +.ap-woman { + background-position: -320px -520px; +} +.ap-family { + background-position: -320px -640px; +} +.ap-couple { + background-position: -320px -660px; +} +.ap-two_men_holding_hands { + background-position: -320px -680px; +} +.ap-two_women_holding_hands { + background-position: -320px -700px; +} +.ap-cop { + background-position: -320px -720px; +} +.ap-dancers { + background-position: -340px -20px; +} +.ap-bride_with_veil { + background-position: -340px -40px; +} +.ap-person_with_blond_hair { + background-position: -340px -160px; +} +.ap-man_with_gua_pi_mao { + background-position: -340px -280px; +} +.ap-man_with_turban { + background-position: -340px -400px; +} +.ap-older_man { + background-position: -340px -520px; +} +.ap-older_woman { + background-position: -340px -640px; +} +.ap-baby { + background-position: -340px -760px; +} +.ap-construction_worker { + background-position: -360px -60px; +} +.ap-princess { + background-position: -360px -180px; +} +.ap-japanese_ogre { + background-position: -360px -300px; +} +.ap-japanese_goblin { + background-position: -360px -320px; +} +.ap-ghost { + background-position: -360px -340px; +} +.ap-angel { + background-position: -360px -360px; +} +.ap-alien { + background-position: -360px -480px; +} +.ap-space_invader { + background-position: -360px -500px; +} +.ap-imp { + background-position: -360px -520px; +} +.ap-skull { + background-position: -360px -540px; +} +.ap-information_desk_person { + background-position: -360px -560px; +} +.ap-guardsman { + background-position: -360px -680px; +} +.ap-dancer { + background-position: -360px -800px; +} +.ap-lipstick { + background-position: -380px -100px; +} +.ap-nail_care { + background-position: -380px -120px; +} +.ap-massage { + background-position: -380px -240px; +} +.ap-haircut { + background-position: -380px -360px; +} +.ap-barber { + background-position: -380px -480px; +} +.ap-syringe { + background-position: -380px -500px; +} +.ap-pill { + background-position: -380px -520px; +} +.ap-kiss { + background-position: -380px -540px; +} +.ap-love_letter { + background-position: -380px -560px; +} +.ap-ring { + background-position: -380px -580px; +} +.ap-gem { + background-position: -380px -600px; +} +.ap-couplekiss { + background-position: -380px -620px; +} +.ap-bouquet { + background-position: -380px -640px; +} +.ap-couple_with_heart { + background-position: -380px -660px; +} +.ap-wedding { + background-position: -380px -680px; +} +.ap-heartbeat { + background-position: -380px -700px; +} +.ap-broken_heart { + background-position: -380px -720px; +} +.ap-two_hearts { + background-position: -380px -740px; +} +.ap-sparkling_heart { + background-position: -380px -760px; +} +.ap-heartpulse { + background-position: -380px -780px; +} +.ap-cupid { + background-position: -380px -800px; +} +.ap-blue_heart { + background-position: -400px 0; +} +.ap-green_heart { + background-position: -400px -20px; +} +.ap-yellow_heart { + background-position: -400px -40px; +} +.ap-purple_heart { + background-position: -400px -60px; +} +.ap-gift_heart { + background-position: -400px -80px; +} +.ap-revolving_hearts { + background-position: -400px -100px; +} +.ap-heart_decoration { + background-position: -400px -120px; +} +.ap-diamond_shape_with_a_dot_inside { + background-position: -400px -140px; +} +.ap-bulb { + background-position: -400px -160px; +} +.ap-anger { + background-position: -400px -180px; +} +.ap-bomb { + background-position: -400px -200px; +} +.ap-zzz { + background-position: -400px -220px; +} +.ap-boom { + background-position: -400px -240px; +} +.ap-sweat_drops { + background-position: -400px -260px; +} +.ap-droplet { + background-position: -400px -280px; +} +.ap-dash { + background-position: -400px -300px; +} +.ap-hankey { + background-position: -400px -320px; +} +.ap-muscle { + background-position: -400px -340px; +} +.ap-dizzy { + background-position: -400px -460px; +} +.ap-speech_balloon { + background-position: -400px -480px; +} +.ap-thought_balloon { + background-position: -400px -500px; +} +.ap-white_flower { + background-position: -400px -520px; +} +.ap-100 { + background-position: -400px -540px; +} +.ap-moneybag { + background-position: -400px -560px; +} +.ap-currency_exchange { + background-position: -400px -580px; +} +.ap-heavy_dollar_sign { + background-position: -400px -600px; +} +.ap-credit_card { + background-position: -400px -620px; +} +.ap-yen { + background-position: -400px -640px; +} +.ap-dollar { + background-position: -400px -660px; +} +.ap-euro { + background-position: -400px -680px; +} +.ap-pound { + background-position: -400px -700px; +} +.ap-money_with_wings { + background-position: -400px -720px; +} +.ap-chart { + background-position: -400px -740px; +} +.ap-seat { + background-position: -400px -760px; +} +.ap-computer { + background-position: -400px -780px; +} +.ap-briefcase { + background-position: -400px -800px; +} +.ap-minidisc { + background-position: -420px 0; +} +.ap-floppy_disk { + background-position: -420px -20px; +} +.ap-cd { + background-position: -420px -40px; +} +.ap-dvd { + background-position: -420px -60px; +} +.ap-file_folder { + background-position: -420px -80px; +} +.ap-open_file_folder { + background-position: -420px -100px; +} +.ap-page_with_curl { + background-position: -420px -120px; +} +.ap-page_facing_up { + background-position: -420px -140px; +} +.ap-date { + background-position: -420px -160px; +} +.ap-calendar { + background-position: -420px -180px; +} +.ap-card_index { + background-position: -420px -200px; +} +.ap-chart_with_upwards_trend { + background-position: -420px -220px; +} +.ap-chart_with_downwards_trend { + background-position: -420px -240px; +} +.ap-bar_chart { + background-position: -420px -260px; +} +.ap-clipboard { + background-position: -420px -280px; +} +.ap-pushpin { + background-position: -420px -300px; +} +.ap-round_pushpin { + background-position: -420px -320px; +} +.ap-paperclip { + background-position: -420px -340px; +} +.ap-straight_ruler { + background-position: -420px -360px; +} +.ap-triangular_ruler { + background-position: -420px -380px; +} +.ap-bookmark_tabs { + background-position: -420px -400px; +} +.ap-ledger { + background-position: -420px -420px; +} +.ap-notebook { + background-position: -420px -440px; +} +.ap-notebook_with_decorative_cover { + background-position: -420px -460px; +} +.ap-closed_book { + background-position: -420px -480px; +} +.ap-book { + background-position: -420px -500px; +} +.ap-green_book { + background-position: -420px -520px; +} +.ap-blue_book { + background-position: -420px -540px; +} +.ap-orange_book { + background-position: -420px -560px; +} +.ap-books { + background-position: -420px -580px; +} +.ap-name_badge { + background-position: -420px -600px; +} +.ap-scroll { + background-position: -420px -620px; +} +.ap-memo { + background-position: -420px -640px; +} +.ap-telephone_receiver { + background-position: -420px -660px; +} +.ap-pager { + background-position: -420px -680px; +} +.ap-fax { + background-position: -420px -700px; +} +.ap-satellite_antenna { + background-position: -420px -720px; +} +.ap-loudspeaker { + background-position: -420px -740px; +} +.ap-mega { + background-position: -420px -760px; +} +.ap-outbox_tray { + background-position: -420px -780px; +} +.ap-inbox_tray { + background-position: -420px -800px; +} +.ap-package { + background-position: -440px 0; +} +.ap-e-mail { + background-position: -440px -20px; +} +.ap-incoming_envelope { + background-position: -440px -40px; +} +.ap-envelope_with_arrow { + background-position: -440px -60px; +} +.ap-mailbox_closed { + background-position: -440px -80px; +} +.ap-mailbox { + background-position: -440px -100px; +} +.ap-mailbox_with_mail { + background-position: -440px -120px; +} +.ap-mailbox_with_no_mail { + background-position: -440px -140px; +} +.ap-postbox { + background-position: -440px -160px; +} +.ap-postal_horn { + background-position: -440px -180px; +} +.ap-newspaper { + background-position: -440px -200px; +} +.ap-iphone { + background-position: -440px -220px; +} +.ap-calling { + background-position: -440px -240px; +} +.ap-vibration_mode { + background-position: -440px -260px; +} +.ap-mobile_phone_off { + background-position: -440px -280px; +} +.ap-no_mobile_phones { + background-position: -440px -300px; +} +.ap-signal_strength { + background-position: -440px -320px; +} +.ap-camera { + background-position: -440px -340px; +} +.ap-camera_with_flash { + background-position: -440px -360px; +} +.ap-video_camera { + background-position: -440px -380px; +} +.ap-tv { + background-position: -440px -400px; +} +.ap-radio { + background-position: -440px -420px; +} +.ap-vhs { + background-position: -440px -440px; +} +.ap-film_projector { + background-position: -440px -460px; +} +.ap-prayer_beads { + background-position: -440px -480px; +} +.ap-twisted_rightwards_arrows { + background-position: -440px -500px; +} +.ap-repeat { + background-position: -440px -520px; +} +.ap-repeat_one { + background-position: -440px -540px; +} +.ap-arrows_clockwise { + background-position: -440px -560px; +} +.ap-arrows_counterclockwise { + background-position: -440px -580px; +} +.ap-low_brightness { + background-position: -440px -600px; +} +.ap-high_brightness { + background-position: -440px -620px; +} +.ap-mute { + background-position: -440px -640px; +} +.ap-speaker { + background-position: -440px -660px; +} +.ap-sound { + background-position: -440px -680px; +} +.ap-loud_sound { + background-position: -440px -700px; +} +.ap-battery { + background-position: -440px -720px; +} +.ap-electric_plug { + background-position: -440px -740px; +} +.ap-mag { + background-position: -440px -760px; +} +.ap-mag_right { + background-position: -440px -780px; +} +.ap-lock_with_ink_pen { + background-position: -440px -800px; +} +.ap-closed_lock_with_key { + background-position: -460px 0; +} +.ap-key { + background-position: -460px -20px; +} +.ap-lock { + background-position: -460px -40px; +} +.ap-unlock { + background-position: -460px -60px; +} +.ap-bell { + background-position: -460px -80px; +} +.ap-no_bell { + background-position: -460px -100px; +} +.ap-bookmark { + background-position: -460px -120px; +} +.ap-link { + background-position: -460px -140px; +} +.ap-radio_button { + background-position: -460px -160px; +} +.ap-back { + background-position: -460px -180px; +} +.ap-end { + background-position: -460px -200px; +} +.ap-on { + background-position: -460px -220px; +} +.ap-soon { + background-position: -460px -240px; +} +.ap-top { + background-position: -460px -260px; +} +.ap-underage { + background-position: -460px -280px; +} +.ap-keycap_ten { + background-position: -460px -300px; +} +.ap-capital_abcd { + background-position: -460px -320px; +} +.ap-abcd { + background-position: -460px -340px; +} +.ap-1234 { + background-position: -460px -360px; +} +.ap-symbols { + background-position: -460px -380px; +} +.ap-abc { + background-position: -460px -400px; +} +.ap-fire { + background-position: -460px -420px; +} +.ap-flashlight { + background-position: -460px -440px; +} +.ap-wrench { + background-position: -460px -460px; +} +.ap-hammer { + background-position: -460px -480px; +} +.ap-nut_and_bolt { + background-position: -460px -500px; +} +.ap-hocho { + background-position: -460px -520px; +} +.ap-gun { + background-position: -460px -540px; +} +.ap-microscope { + background-position: -460px -560px; +} +.ap-telescope { + background-position: -460px -580px; +} +.ap-crystal_ball { + background-position: -460px -600px; +} +.ap-six_pointed_star { + background-position: -460px -620px; +} +.ap-beginner { + background-position: -460px -640px; +} +.ap-trident { + background-position: -460px -660px; +} +.ap-black_square_button { + background-position: -460px -680px; +} +.ap-white_square_button { + background-position: -460px -700px; +} +.ap-red_circle { + background-position: -460px -720px; +} +.ap-large_blue_circle { + background-position: -460px -740px; +} +.ap-large_orange_diamond { + background-position: -460px -760px; +} +.ap-large_blue_diamond { + background-position: -460px -780px; +} +.ap-small_orange_diamond { + background-position: -460px -800px; +} +.ap-small_blue_diamond { + background-position: -480px 0; +} +.ap-small_red_triangle { + background-position: -480px -20px; +} +.ap-small_red_triangle_down { + background-position: -480px -40px; +} +.ap-arrow_up_small { + background-position: -480px -60px; +} +.ap-arrow_down_small { + background-position: -480px -80px; +} +.ap-om_symbol { + background-position: -480px -100px; +} +.ap-dove_of_peace { + background-position: -480px -120px; +} +.ap-kaaba { + background-position: -480px -140px; +} +.ap-mosque { + background-position: -480px -160px; +} +.ap-synagogue { + background-position: -480px -180px; +} +.ap-menorah_with_nine_branches { + background-position: -480px -200px; +} +.ap-clock1 { + background-position: -480px -220px; +} +.ap-clock2 { + background-position: -480px -240px; +} +.ap-clock3 { + background-position: -480px -260px; +} +.ap-clock4 { + background-position: -480px -280px; +} +.ap-clock5 { + background-position: -480px -300px; +} +.ap-clock6 { + background-position: -480px -320px; +} +.ap-clock7 { + background-position: -480px -340px; +} +.ap-clock8 { + background-position: -480px -360px; +} +.ap-clock9 { + background-position: -480px -380px; +} +.ap-clock10 { + background-position: -480px -400px; +} +.ap-clock11 { + background-position: -480px -420px; +} +.ap-clock12 { + background-position: -480px -440px; +} +.ap-clock130 { + background-position: -480px -460px; +} +.ap-clock230 { + background-position: -480px -480px; +} +.ap-clock330 { + background-position: -480px -500px; +} +.ap-clock430 { + background-position: -480px -520px; +} +.ap-clock530 { + background-position: -480px -540px; +} +.ap-clock630 { + background-position: -480px -560px; +} +.ap-clock730 { + background-position: -480px -580px; +} +.ap-clock830 { + background-position: -480px -600px; +} +.ap-clock930 { + background-position: -480px -620px; +} +.ap-clock1030 { + background-position: -480px -640px; +} +.ap-clock1130 { + background-position: -480px -660px; +} +.ap-clock1230 { + background-position: -480px -680px; +} +.ap-candle { + background-position: -480px -700px; +} +.ap-mantelpiece_clock { + background-position: -480px -720px; +} +.ap-hole { + background-position: -480px -740px; +} +.ap-man_in_business_suit_levitating { + background-position: -480px -760px; +} +.ap-sleuth_or_spy { + background-position: -480px -780px; +} +.ap-dark_sunglasses { + background-position: -500px -80px; +} +.ap-spider { + background-position: -500px -100px; +} +.ap-spider_web { + background-position: -500px -120px; +} +.ap-joystick { + background-position: -500px -140px; +} +.ap-linked_paperclips { + background-position: -500px -160px; +} +.ap-lower_left_ballpoint_pen { + background-position: -500px -180px; +} +.ap-lower_left_fountain_pen { + background-position: -500px -200px; +} +.ap-lower_left_paintbrush { + background-position: -500px -220px; +} +.ap-lower_left_crayon { + background-position: -500px -240px; +} +.ap-raised_hand_with_fingers_splayed { + background-position: -500px -260px; +} +.ap-middle_finger { + background-position: -500px -380px; +} +.ap-spock-hand { + background-position: -500px -500px; +} +.ap-desktop_computer { + background-position: -500px -620px; +} +.ap-printer { + background-position: -500px -640px; +} +.ap-three_button_mouse { + background-position: -500px -660px; +} +.ap-trackball { + background-position: -500px -680px; +} +.ap-frame_with_picture { + background-position: -500px -700px; +} +.ap-card_index_dividers { + background-position: -500px -720px; +} +.ap-card_file_box { + background-position: -500px -740px; +} +.ap-file_cabinet { + background-position: -500px -760px; +} +.ap-wastebasket { + background-position: -500px -780px; +} +.ap-spiral_note_pad { + background-position: -500px -800px; +} +.ap-spiral_calendar_pad { + background-position: -520px 0; +} +.ap-compression { + background-position: -520px -20px; +} +.ap-old_key { + background-position: -520px -40px; +} +.ap-rolled_up_newspaper { + background-position: -520px -60px; +} +.ap-dagger_knife { + background-position: -520px -80px; +} +.ap-speaking_head_in_silhouette { + background-position: -520px -100px; +} +.ap-left_speech_bubble { + background-position: -520px -120px; +} +.ap-right_anger_bubble { + background-position: -520px -140px; +} +.ap-ballot_box_with_ballot { + background-position: -520px -160px; +} +.ap-world_map { + background-position: -520px -180px; +} +.ap-mount_fuji { + background-position: -520px -200px; +} +.ap-tokyo_tower { + background-position: -520px -220px; +} +.ap-statue_of_liberty { + background-position: -520px -240px; +} +.ap-japan { + background-position: -520px -260px; +} +.ap-moyai { + background-position: -520px -280px; +} +.ap-grinning { + background-position: -520px -300px; +} +.ap-grin { + background-position: -520px -320px; +} +.ap-joy { + background-position: -520px -340px; +} +.ap-smiley { + background-position: -520px -360px; +} +.ap-smile { + background-position: -520px -380px; +} +.ap-sweat_smile { + background-position: -520px -400px; +} +.ap-laughing { + background-position: -520px -420px; +} +.ap-innocent { + background-position: -520px -440px; +} +.ap-smiling_imp { + background-position: -520px -460px; +} +.ap-wink { + background-position: -520px -480px; +} +.ap-blush { + background-position: -520px -500px; +} +.ap-yum { + background-position: -520px -520px; +} +.ap-relieved { + background-position: -520px -540px; +} +.ap-heart_eyes { + background-position: -520px -560px; +} +.ap-sunglasses { + background-position: -520px -580px; +} +.ap-smirk { + background-position: -520px -600px; +} +.ap-neutral_face { + background-position: -520px -620px; +} +.ap-expressionless { + background-position: -520px -640px; +} +.ap-unamused { + background-position: -520px -660px; +} +.ap-sweat { + background-position: -520px -680px; +} +.ap-pensive { + background-position: -520px -700px; +} +.ap-confused { + background-position: -520px -720px; +} +.ap-confounded { + background-position: -520px -740px; +} +.ap-kissing { + background-position: -520px -760px; +} +.ap-kissing_heart { + background-position: -520px -780px; +} +.ap-kissing_smiling_eyes { + background-position: -520px -800px; +} +.ap-kissing_closed_eyes { + background-position: -540px 0; +} +.ap-stuck_out_tongue { + background-position: -540px -20px; +} +.ap-stuck_out_tongue_winking_eye { + background-position: -540px -40px; +} +.ap-stuck_out_tongue_closed_eyes { + background-position: -540px -60px; +} +.ap-disappointed { + background-position: -540px -80px; +} +.ap-worried { + background-position: -540px -100px; +} +.ap-angry { + background-position: -540px -120px; +} +.ap-rage { + background-position: -540px -140px; +} +.ap-cry { + background-position: -540px -160px; +} +.ap-persevere { + background-position: -540px -180px; +} +.ap-triumph { + background-position: -540px -200px; +} +.ap-disappointed_relieved { + background-position: -540px -220px; +} +.ap-frowning { + background-position: -540px -240px; +} +.ap-anguished { + background-position: -540px -260px; +} +.ap-fearful { + background-position: -540px -280px; +} +.ap-weary { + background-position: -540px -300px; +} +.ap-sleepy { + background-position: -540px -320px; +} +.ap-tired_face { + background-position: -540px -340px; +} +.ap-grimacing { + background-position: -540px -360px; +} +.ap-sob { + background-position: -540px -380px; +} +.ap-open_mouth { + background-position: -540px -400px; +} +.ap-hushed { + background-position: -540px -420px; +} +.ap-cold_sweat { + background-position: -540px -440px; +} +.ap-scream { + background-position: -540px -460px; +} +.ap-astonished { + background-position: -540px -480px; +} +.ap-flushed { + background-position: -540px -500px; +} +.ap-sleeping { + background-position: -540px -520px; +} +.ap-dizzy_face { + background-position: -540px -540px; +} +.ap-no_mouth { + background-position: -540px -560px; +} +.ap-mask { + background-position: -540px -580px; +} +.ap-smile_cat { + background-position: -540px -600px; +} +.ap-joy_cat { + background-position: -540px -620px; +} +.ap-smiley_cat { + background-position: -540px -640px; +} +.ap-heart_eyes_cat { + background-position: -540px -660px; +} +.ap-smirk_cat { + background-position: -540px -680px; +} +.ap-kissing_cat { + background-position: -540px -700px; +} +.ap-pouting_cat { + background-position: -540px -720px; +} +.ap-crying_cat_face { + background-position: -540px -740px; +} +.ap-scream_cat { + background-position: -540px -760px; +} +.ap-slightly_frowning_face { + background-position: -540px -780px; +} +.ap-slightly_smiling_face { + background-position: -540px -800px; +} +.ap-upside_down_face { + background-position: -560px 0; +} +.ap-face_with_rolling_eyes { + background-position: -560px -20px; +} +.ap-no_good { + background-position: -560px -40px; +} +.ap-ok_woman { + background-position: -560px -160px; +} +.ap-bow { + background-position: -560px -280px; +} +.ap-see_no_evil { + background-position: -560px -400px; +} +.ap-hear_no_evil { + background-position: -560px -420px; +} +.ap-speak_no_evil { + background-position: -560px -440px; +} +.ap-raising_hand { + background-position: -560px -460px; +} +.ap-raised_hands { + background-position: -560px -580px; +} +.ap-person_frowning { + background-position: -560px -700px; +} +.ap-person_with_pouting_face { + background-position: -580px 0; +} +.ap-pray { + background-position: -580px -120px; +} +.ap-rocket { + background-position: -580px -240px; +} +.ap-helicopter { + background-position: -580px -260px; +} +.ap-steam_locomotive { + background-position: -580px -280px; +} +.ap-railway_car { + background-position: -580px -300px; +} +.ap-bullettrain_side { + background-position: -580px -320px; +} +.ap-bullettrain_front { + background-position: -580px -340px; +} +.ap-train2 { + background-position: -580px -360px; +} +.ap-metro { + background-position: -580px -380px; +} +.ap-light_rail { + background-position: -580px -400px; +} +.ap-station { + background-position: -580px -420px; +} +.ap-tram { + background-position: -580px -440px; +} +.ap-train { + background-position: -580px -460px; +} +.ap-bus { + background-position: -580px -480px; +} +.ap-oncoming_bus { + background-position: -580px -500px; +} +.ap-trolleybus { + background-position: -580px -520px; +} +.ap-busstop { + background-position: -580px -540px; +} +.ap-minibus { + background-position: -580px -560px; +} +.ap-ambulance { + background-position: -580px -580px; +} +.ap-fire_engine { + background-position: -580px -600px; +} +.ap-police_car { + background-position: -580px -620px; +} +.ap-oncoming_police_car { + background-position: -580px -640px; +} +.ap-taxi { + background-position: -580px -660px; +} +.ap-oncoming_taxi { + background-position: -580px -680px; +} +.ap-car { + background-position: -580px -700px; +} +.ap-oncoming_automobile { + background-position: -580px -720px; +} +.ap-blue_car { + background-position: -580px -740px; +} +.ap-truck { + background-position: -580px -760px; +} +.ap-articulated_lorry { + background-position: -580px -780px; +} +.ap-tractor { + background-position: -580px -800px; +} +.ap-monorail { + background-position: -600px 0; +} +.ap-mountain_railway { + background-position: -600px -20px; +} +.ap-suspension_railway { + background-position: -600px -40px; +} +.ap-mountain_cableway { + background-position: -600px -60px; +} +.ap-aerial_tramway { + background-position: -600px -80px; +} +.ap-ship { + background-position: -600px -100px; +} +.ap-rowboat { + background-position: -600px -120px; +} +.ap-speedboat { + background-position: -600px -240px; +} +.ap-traffic_light { + background-position: -600px -260px; +} +.ap-vertical_traffic_light { + background-position: -600px -280px; +} +.ap-construction { + background-position: -600px -300px; +} +.ap-rotating_light { + background-position: -600px -320px; +} +.ap-triangular_flag_on_post { + background-position: -600px -340px; +} +.ap-door { + background-position: -600px -360px; +} +.ap-no_entry_sign { + background-position: -600px -380px; +} +.ap-smoking { + background-position: -600px -400px; +} +.ap-no_smoking { + background-position: -600px -420px; +} +.ap-put_litter_in_its_place { + background-position: -600px -440px; +} +.ap-do_not_litter { + background-position: -600px -460px; +} +.ap-potable_water { + background-position: -600px -480px; +} +.ap-non-potable_water { + background-position: -600px -500px; +} +.ap-bike { + background-position: -600px -520px; +} +.ap-no_bicycles { + background-position: -600px -540px; +} +.ap-bicyclist { + background-position: -600px -560px; +} +.ap-mountain_bicyclist { + background-position: -600px -680px; +} +.ap-walking { + background-position: -600px -800px; +} +.ap-no_pedestrians { + background-position: -620px -100px; +} +.ap-children_crossing { + background-position: -620px -120px; +} +.ap-mens { + background-position: -620px -140px; +} +.ap-womens { + background-position: -620px -160px; +} +.ap-restroom { + background-position: -620px -180px; +} +.ap-baby_symbol { + background-position: -620px -200px; +} +.ap-toilet { + background-position: -620px -220px; +} +.ap-wc { + background-position: -620px -240px; +} +.ap-shower { + background-position: -620px -260px; +} +.ap-bath { + background-position: -620px -280px; +} +.ap-bathtub { + background-position: -620px -400px; +} +.ap-passport_control { + background-position: -620px -420px; +} +.ap-customs { + background-position: -620px -440px; +} +.ap-baggage_claim { + background-position: -620px -460px; +} +.ap-left_luggage { + background-position: -620px -480px; +} +.ap-couch_and_lamp { + background-position: -620px -500px; +} +.ap-sleeping_accommodation { + background-position: -620px -520px; +} +.ap-shopping_bags { + background-position: -620px -540px; +} +.ap-bellhop_bell { + background-position: -620px -560px; +} +.ap-bed { + background-position: -620px -580px; +} +.ap-place_of_worship { + background-position: -620px -600px; +} +.ap-hammer_and_wrench { + background-position: -620px -620px; +} +.ap-shield { + background-position: -620px -640px; +} +.ap-oil_drum { + background-position: -620px -660px; +} +.ap-motorway { + background-position: -620px -680px; +} +.ap-railway_track { + background-position: -620px -700px; +} +.ap-motor_boat { + background-position: -620px -720px; +} +.ap-small_airplane { + background-position: -620px -740px; +} +.ap-airplane_departure { + background-position: -620px -760px; +} +.ap-airplane_arriving { + background-position: -620px -780px; +} +.ap-satellite { + background-position: -620px -800px; +} +.ap-passenger_ship { + background-position: -640px 0; +} +.ap-zipper_mouth_face { + background-position: -640px -20px; +} +.ap-money_mouth_face { + background-position: -640px -40px; +} +.ap-face_with_thermometer { + background-position: -640px -60px; +} +.ap-nerd_face { + background-position: -640px -80px; +} +.ap-thinking_face { + background-position: -640px -100px; +} +.ap-face_with_head_bandage { + background-position: -640px -120px; +} +.ap-robot_face { + background-position: -640px -140px; +} +.ap-hugging_face { + background-position: -640px -160px; +} +.ap-the_horns { + background-position: -640px -180px; +} +.ap-crab { + background-position: -640px -300px; +} +.ap-lion_face { + background-position: -640px -320px; +} +.ap-scorpion { + background-position: -640px -340px; +} +.ap-turkey { + background-position: -640px -360px; +} +.ap-unicorn_face { + background-position: -640px -380px; +} +.ap-cheese_wedge { + background-position: -640px -400px; +} +.ap-hash { + background-position: -640px -420px; +} +.ap-keycap_star { + background-position: -640px -440px; +} +.ap-zero { + background-position: -640px -460px; +} +.ap-one { + background-position: -640px -480px; +} +.ap-two { + background-position: -640px -500px; +} +.ap-three { + background-position: -640px -520px; +} +.ap-four { + background-position: -640px -540px; +} +.ap-five { + background-position: -640px -560px; +} +.ap-six { + background-position: -640px -580px; +} +.ap-seven { + background-position: -640px -600px; +} +.ap-eight { + background-position: -640px -620px; +} +.ap-nine { + background-position: -640px -640px; +} +.ap-flag-ac { + background-position: -640px -660px; +} +.ap-flag-ad { + background-position: -640px -680px; +} +.ap-flag-ae { + background-position: -640px -700px; +} +.ap-flag-af { + background-position: -640px -720px; +} +.ap-flag-ag { + background-position: -640px -740px; +} +.ap-flag-ai { + background-position: -640px -760px; +} +.ap-flag-al { + background-position: -640px -780px; +} +.ap-flag-am { + background-position: -640px -800px; +} +.ap-flag-ao { + background-position: -660px 0; +} +.ap-flag-aq { + background-position: -660px -20px; +} +.ap-flag-ar { + background-position: -660px -40px; +} +.ap-flag-as { + background-position: -660px -60px; +} +.ap-flag-at { + background-position: -660px -80px; +} +.ap-flag-au { + background-position: -660px -100px; +} +.ap-flag-aw { + background-position: -660px -120px; +} +.ap-flag-ax { + background-position: -660px -140px; +} +.ap-flag-az { + background-position: -660px -160px; +} +.ap-flag-ba { + background-position: -660px -180px; +} +.ap-flag-bb { + background-position: -660px -200px; +} +.ap-flag-bd { + background-position: -660px -220px; +} +.ap-flag-be { + background-position: -660px -240px; +} +.ap-flag-bf { + background-position: -660px -260px; +} +.ap-flag-bg { + background-position: -660px -280px; +} +.ap-flag-bh { + background-position: -660px -300px; +} +.ap-flag-bi { + background-position: -660px -320px; +} +.ap-flag-bj { + background-position: -660px -340px; +} +.ap-flag-bl { + background-position: -660px -360px; +} +.ap-flag-bm { + background-position: -660px -380px; +} +.ap-flag-bn { + background-position: -660px -400px; +} +.ap-flag-bo { + background-position: -660px -420px; +} +.ap-flag-bq { + background-position: -660px -440px; +} +.ap-flag-br { + background-position: -660px -460px; +} +.ap-flag-bs { + background-position: -660px -480px; +} +.ap-flag-bt { + background-position: -660px -500px; +} +.ap-flag-bv { + background-position: -660px -520px; +} +.ap-flag-bw { + background-position: -660px -540px; +} +.ap-flag-by { + background-position: -660px -560px; +} +.ap-flag-bz { + background-position: -660px -580px; +} +.ap-flag-ca { + background-position: -660px -600px; +} +.ap-flag-cc { + background-position: -660px -620px; +} +.ap-flag-cd { + background-position: -660px -640px; +} +.ap-flag-cf { + background-position: -660px -660px; +} +.ap-flag-cg { + background-position: -660px -680px; +} +.ap-flag-ch { + background-position: -660px -700px; +} +.ap-flag-ci { + background-position: -660px -720px; +} +.ap-flag-ck { + background-position: -660px -740px; +} +.ap-flag-cl { + background-position: -660px -760px; +} +.ap-flag-cm { + background-position: -660px -780px; +} +.ap-flag-cn { + background-position: -660px -800px; +} +.ap-flag-co { + background-position: -680px 0; +} +.ap-flag-cp { + background-position: -680px -20px; +} +.ap-flag-cr { + background-position: -680px -40px; +} +.ap-flag-cu { + background-position: -680px -60px; +} +.ap-flag-cv { + background-position: -680px -80px; +} +.ap-flag-cw { + background-position: -680px -100px; +} +.ap-flag-cx { + background-position: -680px -120px; +} +.ap-flag-cy { + background-position: -680px -140px; +} +.ap-flag-cz { + background-position: -680px -160px; +} +.ap-flag-de { + background-position: -680px -180px; +} +.ap-flag-dg { + background-position: -680px -200px; +} +.ap-flag-dj { + background-position: -680px -220px; +} +.ap-flag-dk { + background-position: -680px -240px; +} +.ap-flag-dm { + background-position: -680px -260px; +} +.ap-flag-do { + background-position: -680px -280px; +} +.ap-flag-dz { + background-position: -680px -300px; +} +.ap-flag-ea { + background-position: -680px -320px; +} +.ap-flag-ec { + background-position: -680px -340px; +} +.ap-flag-ee { + background-position: -680px -360px; +} +.ap-flag-eg { + background-position: -680px -380px; +} +.ap-flag-eh { + background-position: -680px -400px; +} +.ap-flag-er { + background-position: -680px -420px; +} +.ap-flag-es { + background-position: -680px -440px; +} +.ap-flag-et { + background-position: -680px -460px; +} +.ap-flag-eu { + background-position: -680px -480px; +} +.ap-flag-fi { + background-position: -680px -500px; +} +.ap-flag-fj { + background-position: -680px -520px; +} +.ap-flag-fk { + background-position: -680px -540px; +} +.ap-flag-fm { + background-position: -680px -560px; +} +.ap-flag-fo { + background-position: -680px -580px; +} +.ap-flag-fr { + background-position: -680px -600px; +} +.ap-flag-ga { + background-position: -680px -620px; +} +.ap-flag-gb { + background-position: -680px -640px; +} +.ap-flag-gd { + background-position: -680px -660px; +} +.ap-flag-ge { + background-position: -680px -680px; +} +.ap-flag-gf { + background-position: -680px -700px; +} +.ap-flag-gg { + background-position: -680px -720px; +} +.ap-flag-gh { + background-position: -680px -740px; +} +.ap-flag-gi { + background-position: -680px -760px; +} +.ap-flag-gl { + background-position: -680px -780px; +} +.ap-flag-gm { + background-position: -680px -800px; +} +.ap-flag-gn { + background-position: -700px 0; +} +.ap-flag-gp { + background-position: -700px -20px; +} +.ap-flag-gq { + background-position: -700px -40px; +} +.ap-flag-gr { + background-position: -700px -60px; +} +.ap-flag-gs { + background-position: -700px -80px; +} +.ap-flag-gt { + background-position: -700px -100px; +} +.ap-flag-gu { + background-position: -700px -120px; +} +.ap-flag-gw { + background-position: -700px -140px; +} +.ap-flag-gy { + background-position: -700px -160px; +} +.ap-flag-hk { + background-position: -700px -180px; +} +.ap-flag-hm { + background-position: -700px -200px; +} +.ap-flag-hn { + background-position: -700px -220px; +} +.ap-flag-hr { + background-position: -700px -240px; +} +.ap-flag-ht { + background-position: -700px -260px; +} +.ap-flag-hu { + background-position: -700px -280px; +} +.ap-flag-ic { + background-position: -700px -300px; +} +.ap-flag-id { + background-position: -700px -320px; +} +.ap-flag-ie { + background-position: -700px -340px; +} +.ap-flag-il { + background-position: -700px -360px; +} +.ap-flag-im { + background-position: -700px -380px; +} +.ap-flag-in { + background-position: -700px -400px; +} +.ap-flag-io { + background-position: -700px -420px; +} +.ap-flag-iq { + background-position: -700px -440px; +} +.ap-flag-ir { + background-position: -700px -460px; +} +.ap-flag-is { + background-position: -700px -480px; +} +.ap-flag-it { + background-position: -700px -500px; +} +.ap-flag-je { + background-position: -700px -520px; +} +.ap-flag-jm { + background-position: -700px -540px; +} +.ap-flag-jo { + background-position: -700px -560px; +} +.ap-flag-jp { + background-position: -700px -580px; +} +.ap-flag-ke { + background-position: -700px -600px; +} +.ap-flag-kg { + background-position: -700px -620px; +} +.ap-flag-kh { + background-position: -700px -640px; +} +.ap-flag-ki { + background-position: -700px -660px; +} +.ap-flag-km { + background-position: -700px -680px; +} +.ap-flag-kn { + background-position: -700px -700px; +} +.ap-flag-kp { + background-position: -700px -720px; +} +.ap-flag-kr { + background-position: -700px -740px; +} +.ap-flag-kw { + background-position: -700px -760px; +} +.ap-flag-ky { + background-position: -700px -780px; +} +.ap-flag-kz { + background-position: -700px -800px; +} +.ap-flag-la { + background-position: -720px 0; +} +.ap-flag-lb { + background-position: -720px -20px; +} +.ap-flag-lc { + background-position: -720px -40px; +} +.ap-flag-li { + background-position: -720px -60px; +} +.ap-flag-lk { + background-position: -720px -80px; +} +.ap-flag-lr { + background-position: -720px -100px; +} +.ap-flag-ls { + background-position: -720px -120px; +} +.ap-flag-lt { + background-position: -720px -140px; +} +.ap-flag-lu { + background-position: -720px -160px; +} +.ap-flag-lv { + background-position: -720px -180px; +} +.ap-flag-ly { + background-position: -720px -200px; +} +.ap-flag-ma { + background-position: -720px -220px; +} +.ap-flag-mc { + background-position: -720px -240px; +} +.ap-flag-md { + background-position: -720px -260px; +} +.ap-flag-me { + background-position: -720px -280px; +} +.ap-flag-mf { + background-position: -720px -300px; +} +.ap-flag-mg { + background-position: -720px -320px; +} +.ap-flag-mh { + background-position: -720px -340px; +} +.ap-flag-mk { + background-position: -720px -360px; +} +.ap-flag-ml { + background-position: -720px -380px; +} +.ap-flag-mm { + background-position: -720px -400px; +} +.ap-flag-mn { + background-position: -720px -420px; +} +.ap-flag-mo { + background-position: -720px -440px; +} +.ap-flag-mp { + background-position: -720px -460px; +} +.ap-flag-mq { + background-position: -720px -480px; +} +.ap-flag-mr { + background-position: -720px -500px; +} +.ap-flag-ms { + background-position: -720px -520px; +} +.ap-flag-mt { + background-position: -720px -540px; +} +.ap-flag-mu { + background-position: -720px -560px; +} +.ap-flag-mv { + background-position: -720px -580px; +} +.ap-flag-mw { + background-position: -720px -600px; +} +.ap-flag-mx { + background-position: -720px -620px; +} +.ap-flag-my { + background-position: -720px -640px; +} +.ap-flag-mz { + background-position: -720px -660px; +} +.ap-flag-na { + background-position: -720px -680px; +} +.ap-flag-nc { + background-position: -720px -700px; +} +.ap-flag-ne { + background-position: -720px -720px; +} +.ap-flag-nf { + background-position: -720px -740px; +} +.ap-flag-ng { + background-position: -720px -760px; +} +.ap-flag-ni { + background-position: -720px -780px; +} +.ap-flag-nl { + background-position: -720px -800px; +} +.ap-flag-no { + background-position: -740px 0; +} +.ap-flag-np { + background-position: -740px -20px; +} +.ap-flag-nr { + background-position: -740px -40px; +} +.ap-flag-nu { + background-position: -740px -60px; +} +.ap-flag-nz { + background-position: -740px -80px; +} +.ap-flag-om { + background-position: -740px -100px; +} +.ap-flag-pa { + background-position: -740px -120px; +} +.ap-flag-pe { + background-position: -740px -140px; +} +.ap-flag-pf { + background-position: -740px -160px; +} +.ap-flag-pg { + background-position: -740px -180px; +} +.ap-flag-ph { + background-position: -740px -200px; +} +.ap-flag-pk { + background-position: -740px -220px; +} +.ap-flag-pl { + background-position: -740px -240px; +} +.ap-flag-pm { + background-position: -740px -260px; +} +.ap-flag-pn { + background-position: -740px -280px; +} +.ap-flag-pr { + background-position: -740px -300px; +} +.ap-flag-ps { + background-position: -740px -320px; +} +.ap-flag-pt { + background-position: -740px -340px; +} +.ap-flag-pw { + background-position: -740px -360px; +} +.ap-flag-py { + background-position: -740px -380px; +} +.ap-flag-qa { + background-position: -740px -400px; +} +.ap-flag-re { + background-position: -740px -420px; +} +.ap-flag-ro { + background-position: -740px -440px; +} +.ap-flag-rs { + background-position: -740px -460px; +} +.ap-flag-ru { + background-position: -740px -480px; +} +.ap-flag-rw { + background-position: -740px -500px; +} +.ap-flag-sa { + background-position: -740px -520px; +} +.ap-flag-sb { + background-position: -740px -540px; +} +.ap-flag-sc { + background-position: -740px -560px; +} +.ap-flag-sd { + background-position: -740px -580px; +} +.ap-flag-se { + background-position: -740px -600px; +} +.ap-flag-sg { + background-position: -740px -620px; +} +.ap-flag-sh { + background-position: -740px -640px; +} +.ap-flag-si { + background-position: -740px -660px; +} +.ap-flag-sj { + background-position: -740px -680px; +} +.ap-flag-sk { + background-position: -740px -700px; +} +.ap-flag-sl { + background-position: -740px -720px; +} +.ap-flag-sm { + background-position: -740px -740px; +} +.ap-flag-sn { + background-position: -740px -760px; +} +.ap-flag-so { + background-position: -740px -780px; +} +.ap-flag-sr { + background-position: -740px -800px; +} +.ap-flag-ss { + background-position: -760px 0; +} +.ap-flag-st { + background-position: -760px -20px; +} +.ap-flag-sv { + background-position: -760px -40px; +} +.ap-flag-sx { + background-position: -760px -60px; +} +.ap-flag-sy { + background-position: -760px -80px; +} +.ap-flag-sz { + background-position: -760px -100px; +} +.ap-flag-ta { + background-position: -760px -120px; +} +.ap-flag-tc { + background-position: -760px -140px; +} +.ap-flag-td { + background-position: -760px -160px; +} +.ap-flag-tf { + background-position: -760px -180px; +} +.ap-flag-tg { + background-position: -760px -200px; +} +.ap-flag-th { + background-position: -760px -220px; +} +.ap-flag-tj { + background-position: -760px -240px; +} +.ap-flag-tk { + background-position: -760px -260px; +} +.ap-flag-tl { + background-position: -760px -280px; +} +.ap-flag-tm { + background-position: -760px -300px; +} +.ap-flag-tn { + background-position: -760px -320px; +} +.ap-flag-to { + background-position: -760px -340px; +} +.ap-flag-tr { + background-position: -760px -360px; +} +.ap-flag-tt { + background-position: -760px -380px; +} +.ap-flag-tv { + background-position: -760px -400px; +} +.ap-flag-tw { + background-position: -760px -420px; +} +.ap-flag-tz { + background-position: -760px -440px; +} +.ap-flag-ua { + background-position: -760px -460px; +} +.ap-flag-ug { + background-position: -760px -480px; +} +.ap-flag-um { + background-position: -760px -500px; +} +.ap-flag-us { + background-position: -760px -520px; +} +.ap-flag-uy { + background-position: -760px -540px; +} +.ap-flag-uz { + background-position: -760px -560px; +} +.ap-flag-va { + background-position: -760px -580px; +} +.ap-flag-vc { + background-position: -760px -600px; +} +.ap-flag-ve { + background-position: -760px -620px; +} +.ap-flag-vg { + background-position: -760px -640px; +} +.ap-flag-vi { + background-position: -760px -660px; +} +.ap-flag-vn { + background-position: -760px -680px; +} +.ap-flag-vu { + background-position: -760px -700px; +} +.ap-flag-wf { + background-position: -760px -720px; +} +.ap-flag-ws { + background-position: -760px -740px; +} +.ap-flag-xk { + background-position: -760px -760px; +} +.ap-flag-ye { + background-position: -760px -780px; +} +.ap-flag-yt { + background-position: -760px -800px; +} +.ap-flag-za { + background-position: -780px 0; +} +.ap-flag-zm { + background-position: -780px -20px; +} +.ap-flag-zw { + background-position: -780px -40px; +} +.ap-man-man-boy { + background-position: -780px -60px; +} +.ap-man-man-boy-boy { + background-position: -780px -80px; +} +.ap-man-man-girl { + background-position: -780px -100px; +} +.ap-man-man-girl-boy { + background-position: -780px -120px; +} +.ap-man-man-girl-girl { + background-position: -780px -140px; +} +.ap-man-woman-boy-boy { + background-position: -780px -160px; +} +.ap-man-woman-girl { + background-position: -780px -180px; +} +.ap-man-woman-girl-boy { + background-position: -780px -200px; +} +.ap-man-woman-girl-girl { + background-position: -780px -220px; +} +.ap-man-heart-man { + background-position: -780px -240px; +} +.ap-man-kiss-man { + background-position: -780px -260px; +} +.ap-woman-woman-boy { + background-position: -780px -280px; +} +.ap-woman-woman-boy-boy { + background-position: -780px -300px; +} +.ap-woman-woman-girl { + background-position: -780px -320px; +} +.ap-woman-woman-girl-boy { + background-position: -780px -340px; +} +.ap-woman-woman-girl-girl { + background-position: -780px -360px; +} +.ap-woman-heart-woman { + background-position: -780px -380px; +} +.ap-woman-kiss-woman { + background-position: -780px -400px; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/AlertBar/AlertBar.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/AlertBar/AlertBar.interface.ts new file mode 100644 index 000000000000..269daa647ba4 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/AlertBar/AlertBar.interface.ts @@ -0,0 +1,18 @@ +import { AlertProps } from 'antd'; + +/* + * 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. + */ +export interface AlertBarProps { + type: AlertProps['type']; + message: string | JSX.Element; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/AlertBar/AlertBar.tsx b/openmetadata-ui/src/main/resources/ui/src/components/AlertBar/AlertBar.tsx new file mode 100644 index 000000000000..6ffb58766394 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/AlertBar/AlertBar.tsx @@ -0,0 +1,43 @@ +/* + * 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 { Alert } from 'antd'; +import classNames from 'classnames'; +import React, { useMemo } from 'react'; +import { ReactComponent as CrossIcon } from '../../assets/svg/ic-cross.svg'; +import { useAlertStore } from '../../hooks/useAlertStore'; +import { getIconAndClassName } from '../../utils/ToastUtils'; +import './alert-bar.style.less'; +import { AlertBarProps } from './AlertBar.interface'; + +const AlertBar = ({ type, message }: AlertBarProps): JSX.Element => { + const { resetAlert, animationClass } = useAlertStore(); + + const { icon: AlertIcon, className } = useMemo(() => { + return getIconAndClassName(type); + }, [type]); + + return ( + } + description={message} + icon={AlertIcon && } + type={type} + /> + ); +}; + +export default AlertBar; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/AlertBar/alert-bar.style.less b/openmetadata-ui/src/main/resources/ui/src/components/AlertBar/alert-bar.style.less new file mode 100644 index 000000000000..c139ba7cbe2d --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/AlertBar/alert-bar.style.less @@ -0,0 +1,85 @@ +/* + * 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 (reference) url('../../styles/variables.less'); + +@keyframes resize-show-animation { + from { + height: 0; + padding: 0 20px; + } + to { + height: 64px; + padding: 20px; + } +} + +@keyframes resize-hide-animation { + from { + height: 64px; + padding: 20px; + } + to { + height: 0; + padding: 0 20px; + } +} + +.alert-container { + overflow-y: scroll; + backdrop-filter: blur(500px); + + &.show-alert { + animation: resize-show-animation 1.2s ease-in-out forwards; + position: fixed; + top: 64px; + z-index: 20; + width: 96%; + } + + &.hide-alert { + animation: resize-hide-animation 1.2s ease-in-out forwards; + } + + &.info { + background-color: @info-bg-color; + color: @info-color; + border: none; + border-radius: 0px; + } + + &.success { + background-color: @success-bg-color; + color: @success-color; + border: none; + border-radius: 0px; + } + + &.warning { + background-color: @warning-bg-color; + color: @warning-color; + border: none; + border-radius: 0px; + } + + &.error { + background-color: @error-bg-color; + color: @error-color; + border: none; + border-radius: 0px; + } + + .ant-alert-description { + font-size: 16px; + } +} 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/Auth/AppAuthenticators/MsalAuthenticator.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/MsalAuthenticator.tsx index dfc9efb7579e..c3687226679e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/MsalAuthenticator.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/MsalAuthenticator.tsx @@ -24,13 +24,13 @@ import React, { useEffect, useImperativeHandle, } from 'react'; -import { toast } from 'react-toastify'; import { msalLoginRequest, parseMSALResponse, } from '../../../utils/AuthProvider.util'; import { getPopupSettingLink } from '../../../utils/BrowserUtils'; import { Transi18next } from '../../../utils/CommonUtils'; +import { showErrorToast } from '../../../utils/ToastUtils'; import Loader from '../../common/Loader/Loader'; import { AuthenticatorRef, @@ -96,7 +96,7 @@ const MsalAuthenticator = forwardRef( // eslint-disable-next-line no-console console.error(e); if (e?.message?.includes('popup_window_error')) { - toast.error( + showErrorToast( ( // Performs silent signIn and returns with IDToken const signInSilently = async () => { - const user = await userManager.signinSilent(); + // For OIDC token will be coming as silent-callback as an IFram hence not returning new token here + await userManager.signinSilent(); + }; + + const handleSilentSignInSuccess = (user: User) => { + // On success update token in store and update axios interceptors setOidcToken(user.id_token); + updateAxiosInterceptors(); + }; + + const handleSilentSignInFailure = (error: unknown) => { + // eslint-disable-next-line no-console + console.error(error); - return user.id_token; + onLogoutSuccess(); + history.push(ROUTES.SIGNIN); }; useImperativeHandle(ref, () => ({ @@ -154,22 +166,11 @@ const OidcAuthenticator = forwardRef( ( - <> - { - // eslint-disable-next-line no-console - console.error(error); - - onLogoutSuccess(); - history.push(ROUTES.SIGNIN); - }} - onSuccess={(user) => { - setOidcToken(user.id_token); - updateAxiosInterceptors(); - }} - /> - + )} /> diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.interface.ts index 29c591b94a03..b49cd8bd99ea 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.interface.ts @@ -39,7 +39,10 @@ export type OidcUser = { export interface AuthenticatorRef { invokeLogin: () => void; invokeLogout: () => void; - renewIdToken: () => Promise | Promise; + renewIdToken: () => + | Promise + | Promise + | Promise; } export enum JWT_PRINCIPAL_CLAIMS { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx index ab99a54361f2..207ac9c71ae2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx @@ -25,7 +25,7 @@ import { InternalAxiosRequestConfig, } from 'axios'; import { CookieStorage } from 'cookie-storage'; -import { isEmpty, isNil, isNumber } from 'lodash'; +import { debounce, isEmpty, isNil, isNumber } from 'lodash'; import Qs from 'qs'; import React, { ComponentType, @@ -72,6 +72,7 @@ import { isProtectedRoute, prepareUserProfileFromClaims, } from '../../../utils/AuthProvider.util'; +import { getOidcToken } from '../../../utils/LocalStorageUtils'; import { getPathNameFromWindowLocation } from '../../../utils/RouterUtils'; import { escapeESReservedCharacters } from '../../../utils/StringsUtils'; import { showErrorToast, showInfoToast } from '../../../utils/ToastUtils'; @@ -131,7 +132,6 @@ export const AuthProvider = ({ setJwtPrincipalClaimsMapping, removeRefreshToken, removeOidcToken, - getOidcToken, getRefreshToken, isApplicationLoading, setApplicationLoading, @@ -179,6 +179,9 @@ export const AuthProvider = ({ removeRefreshToken(); setApplicationLoading(false); + + // Upon logout, redirect to the login page + history.push(ROUTES.SIGNIN); }, [timeoutId]); useEffect(() => { @@ -265,39 +268,6 @@ export const AuthProvider = ({ } }; - /** - * Renew Id Token handler for all the SSOs. - * This method will be called when the id token is about to expire. - */ - const renewIdToken = async () => { - try { - if (!tokenService.current?.isTokenUpdateInProgress()) { - await tokenService.current?.refreshToken(); - } else { - // wait for renewal to complete - const wait = new Promise((resolve) => { - setTimeout(() => { - return resolve(true); - }, 500); - }); - await wait; - - // should have updated token after renewal - return getOidcToken(); - } - } catch (error) { - // eslint-disable-next-line no-console - console.error( - `Error while refreshing token: `, - (error as AxiosError).message - ); - - throw error; - } - - return getOidcToken(); - }; - /** * This method will try to signIn silently when token is about to expire * if it's not succeed then it will proceed for logout @@ -311,10 +281,10 @@ export const AuthProvider = ({ return; } - try { - // Try to renew token - const newToken = await renewIdToken(); - + if (!tokenService.current?.isTokenUpdateInProgress()) { + // For OIDC we won't be getting newToken immediately hence not updating token here + const newToken = await tokenService.current?.refreshToken(); + // Start expiry timer on successful silent signIn if (newToken) { // Start expiry timer on successful silent signIn // eslint-disable-next-line @typescript-eslint/no-use-before-define @@ -325,13 +295,9 @@ export const AuthProvider = ({ await getLoggedInUserDetails(); failedLoggedInUserRequest = null; } - } else { - // reset user details if silent signIn fails - resetUserDetails(forceLogout); + } else if (forceLogout) { + resetUserDetails(true); } - } catch (error) { - // reset user details if silent signIn fails - resetUserDetails(forceLogout); } }; @@ -550,6 +516,7 @@ export const AuthProvider = ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any config: InternalAxiosRequestConfig ) { + // Need to read token from local storage as it might have been updated with refresh const token: string = getOidcToken() || ''; if (token) { if (config.headers) { @@ -580,7 +547,8 @@ export const AuthProvider = ({ failedLoggedInUserRequest = true; } handleStoreProtectedRedirectPath(); - trySilentSignIn(true); + // try silent signIn if token is about to expire + debounce(() => trySilentSignIn(true), 100); } } @@ -611,9 +579,11 @@ export const AuthProvider = ({ } else { // get the user details if token is present and route is not auth callback and saml callback if ( - ![ROUTES.AUTH_CALLBACK, ROUTES.SAML_CALLBACK].includes( - location.pathname - ) + ![ + ROUTES.AUTH_CALLBACK, + ROUTES.SAML_CALLBACK, + ROUTES.SILENT_CALLBACK, + ].includes(location.pathname) ) { getLoggedInUserDetails(); } 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/Database/Profiler/TableProfiler/TableProfilerProvider.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/TableProfilerProvider.tsx index f7b50bf0afac..5611085bd5c3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/TableProfilerProvider.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/TableProfilerProvider.tsx @@ -155,7 +155,7 @@ export const TableProfilerProvider = ({ value: profile?.createDateTime ? DateTime.fromJSDate(new Date(profile?.createDateTime)) .toUTC() - .toFormat('MMM dd, yyyy HH:mm') + .toLocaleString(DateTime.DATE_MED) : '--', }, ]; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainPage.component.tsx index 4a3d3202cd49..c0f6cbb12422 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainPage.component.tsx @@ -37,6 +37,7 @@ import { getDomainPath } from '../../utils/RouterUtils'; import { showErrorToast } from '../../utils/ToastUtils'; import Loader from '../common/Loader/Loader'; import ResizableLeftPanels from '../common/ResizablePanels/ResizableLeftPanels'; +import PageLayoutV1 from '../PageLayoutV1/PageLayoutV1'; import './domain.less'; import DomainDetailsPage from './DomainDetailsPage/DomainDetailsPage.component'; import DomainsLeftPanel from './DomainLeftPanel/DomainLeftPanel.component'; @@ -176,10 +177,6 @@ const DomainPage = () => { } }, [rootDomains, domainFqn]); - if (domainLoading) { - return ; - } - if (!(viewBasicDomainPermission || viewAllDomainPermission)) { return ( { ); } - if (isEmpty(rootDomains)) { - return ( - - {t('message.domains-not-configured')} - - ); - } - - return ( + const pageContent = isEmpty(rootDomains) ? ( + + {t('message.domains-not-configured')} + + ) : ( { }} /> ); + + return ( + + {domainLoading ? : pageContent} + + ); }; export default DomainPage; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Domain/domain.less b/openmetadata-ui/src/main/resources/ui/src/components/Domain/domain.less index 81bcd468dadc..a0fe86d6b5e3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Domain/domain.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/Domain/domain.less @@ -15,6 +15,9 @@ height: @domain-page-height; overflow-y: auto; } +.domain-page { + margin-top: -12px; +} .domain-details-page-tabs { .ant-tabs-nav { 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/Explore/EntitySummaryPanel/CommonEntitySummaryInfo/CommonEntitySummaryInfo.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Explore/EntitySummaryPanel/CommonEntitySummaryInfo/CommonEntitySummaryInfo.tsx index de4466a7e112..3461d72a8bb5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Explore/EntitySummaryPanel/CommonEntitySummaryInfo/CommonEntitySummaryInfo.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/EntitySummaryPanel/CommonEntitySummaryInfo/CommonEntitySummaryInfo.tsx @@ -54,7 +54,11 @@ function CommonEntitySummaryInfo({ component={Typography.Link} data-testid={`${info.name}-value`} target={info.isExternal ? '_blank' : '_self'} - to={{ pathname: info.url }}> + to={ + info.linkProps + ? info.linkProps + : { pathname: info.url } + }> {info.value} {info.isExternal ? ( 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/Modals/WhatsNewModal/whatsNewData.ts b/openmetadata-ui/src/main/resources/ui/src/components/Modals/WhatsNewModal/whatsNewData.ts index 68cc80082ac5..8cf0ab8cddff 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Modals/WhatsNewModal/whatsNewData.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Modals/WhatsNewModal/whatsNewData.ts @@ -17,7 +17,7 @@ import incidentManagerSampleData from '../../../assets/img/incidentManagerSample import profilerConfigPage from '../../../assets/img/profilerConfigPage.png'; import collateIcon from '../../../assets/svg/ic-collate.svg'; -export const COOKIE_VERSION = 'VERSION_1_5_11'; // To be changed with each release. +export const COOKIE_VERSION = 'VERSION_1_5_12'; // To be changed with each release. // for youtube video make isImage = false and path = {video embed id} // embed:- youtube video => share => click on embed and take {url with id} from it @@ -1144,6 +1144,26 @@ To continue pursuing this objective, the application was completely refactored t - **Improvement**: Mask SQL Queries in Usage & Lineage Workflow. - **Fix**: Sample data overlapping issue. - **Fix**: Checkmark saves wrong custom property field +`, + }, + }, + { + id: 54, + version: 'v1.5.12', + description: 'Released on 25th November 2024.', + features: [], + changeLogs: { + Improvements: `- **Improvement**: Added async apis for csv import. +- **Improvement**: Skip domain check for bots and admin +- **Improvement**: MYSQL lineage and usage. +- **Minor**: Added Lineage Field back to SearchLineage. +- **Fix**: Database is missing from the search dropdown +- **Fix**: Bad Representation of owners. +- **Fix**: The Daily Active Users Summary Card in Data Insights. +- **Fix**: The processing of URL Encoded Assets in Data Insights. +- **Fix**: Column Level Lineage export. +- **Fix**: Store procedure yielding by adding Try/Except. +- **Fix**: Lineage export when there is no column / pipeline edge. `, }, }, 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 = ({ mainContainerClassName = '', pageContainerStyle = {}, }: PageLayoutProp) => { + const { alert } = useAlertStore(); + const contentWidth = useMemo(() => { if (leftPanel && rightPanel) { return `calc(100% - ${leftPanelWidth + rightPanelWidth}px)`; @@ -99,27 +103,35 @@ const PageLayoutV1: FC = ({ {leftPanel} )} - - {children} - - {rightPanel && ( + - {rightPanel} + className={classNames( + 'page-layout-v1-center p-t-sm page-layout-v1-vertical-scroll', + { + 'flex justify-center': center, + }, + mainContainerClassName + )} + flex={contentWidth} + offset={center ? 3 : 0} + span={center ? 18 : 24}> +
+ {alert && } +
+ {children} +
+
- )} + {rightPanel && ( + + {rightPanel} + + )} +
); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/AppRunsHistory/AppRunsHistory.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/AppRunsHistory/AppRunsHistory.component.tsx index 8c9735a40714..fe54986735a5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/AppRunsHistory/AppRunsHistory.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/AppRunsHistory/AppRunsHistory.component.tsx @@ -59,6 +59,7 @@ import { PagingHandlerParams } from '../../../common/NextPrevious/NextPrevious.i import StatusBadge from '../../../common/StatusBadge/StatusBadge.component'; import { StatusType } from '../../../common/StatusBadge/StatusBadge.interface'; import Table from '../../../common/Table/Table'; +import StopScheduleModal from '../../../Modals/StopScheduleRun/StopScheduleRunModal'; import AppLogsViewer from '../AppLogsViewer/AppLogsViewer.component'; import { AppRunRecordWithId, @@ -78,6 +79,7 @@ const AppRunsHistory = forwardRef( AppRunRecordWithId[] >([]); 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; @@ -184,11 +192,9 @@ const AppRunsHistory = forwardRef( dataIndex: 'executionTime', key: 'executionTime', render: (_, record: AppRunRecordWithId) => { - if (record.startTime && record.endTime) { - const ms = getIntervalInMilliseconds( - record.startTime, - record.endTime - ); + if (record.startTime) { + const endTime = record.endTime || Date.now(); // Use current time in epoch milliseconds if endTime is not present + const ms = getIntervalInMilliseconds(record.startTime, endTime); return formatDuration(ms); } else { @@ -347,47 +353,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 ? ( - - - - - - - - - - - -
- {ownerLoading ? ( - - ) : ( - - )} - {extraInfo} -
- - - - - - {editPermission && ( - - + + + + + + + + + - - )} - - - - - - {alertDetails?.description && ( - - {`${t( - 'label.description' - )} :`} - + +
+ {ownerLoading ? ( + + ) : ( + + )} + {extraInfo} +
+ + + + + + {editPermission && + alertDetails?.provider !== ProviderType.System && ( + + + - )} - - - - - - - - ), - minWidth: 700, - flex: 0.7, - }} - pageTitle={t('label.entity-detail-plural', { entity: t('label.alert') })} - secondPanel={{ - children: <>, - minWidth: 0, - className: 'content-resizable-panel-container', - }} - /> + + + + + + + + ), + minWidth: 700, + flex: 0.7, + }} + pageTitle={t('label.entity-detail-plural', { + entity: t('label.alert'), + })} + secondPanel={{ + children: <>, + minWidth: 0, + className: 'content-resizable-panel-container', + }} + /> + ); } diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/AlertDetailsPage/alert-details-page.less b/openmetadata-ui/src/main/resources/ui/src/pages/AlertDetailsPage/alert-details-page.less index 3fccd858c598..ad170cd11780 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/AlertDetailsPage/alert-details-page.less +++ b/openmetadata-ui/src/main/resources/ui/src/pages/AlertDetailsPage/alert-details-page.less @@ -11,9 +11,18 @@ * limitations under the License. */ +@import (reference) url('../../styles/variables.less'); + .ant-skeleton-active .ant-skeleton-button.extra-info-skeleton { display: flex; align-items: center; height: 24px; width: 120px; } + +.alert-description { + margin-top: 8px; + .right-panel-label { + color: @text-color; + } +} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DataQuality/DataQualityPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DataQuality/DataQualityPage.test.tsx index ce6f706cb070..318bc94530cc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/DataQuality/DataQualityPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/DataQuality/DataQualityPage.test.tsx @@ -63,6 +63,10 @@ jest.mock('../../components/common/ResizablePanels/ResizableLeftPanels', () => { )); }); +jest.mock('../../components/PageLayoutV1/PageLayoutV1', () => { + return jest.fn().mockImplementation(({ children }) =>
{children}
); +}); + jest.mock('react-router-dom', () => { return { ...jest.requireActual('react-router-dom'), diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DataQuality/DataQualityPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DataQuality/DataQualityPage.tsx index 40e5719231db..811345939365 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/DataQuality/DataQualityPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/DataQuality/DataQualityPage.tsx @@ -24,6 +24,7 @@ import { import LeftPanelCard from '../../components/common/LeftPanelCard/LeftPanelCard'; import ResizableLeftPanels from '../../components/common/ResizablePanels/ResizableLeftPanels'; import TabsLabel from '../../components/common/TabsLabel/TabsLabel.component'; +import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1'; import { ROUTES } from '../../constants/constants'; import { getDataQualityPagePath } from '../../utils/RouterUtils'; import './data-quality-page.less'; @@ -68,76 +69,78 @@ const DataQualityPage = () => { }; return ( - - - - ), - }} - pageTitle="Quality" - secondPanel={{ - children: ( - - -
- - {t('label.data-quality')} - - - {t('message.page-sub-header-for-data-quality')} - - - - - {tabDetailsComponent.map((tab) => ( - - ))} + + + + + ), + }} + pageTitle="Quality" + secondPanel={{ + children: ( + + + + + {t('label.data-quality')} + + + {t('message.page-sub-header-for-data-quality')} + + + + + {tabDetailsComponent.map((tab) => ( + + ))} - - - - - - - - ), - className: 'content-resizable-panel-container p-t-sm', - minWidth: 800, - flex: 0.87, - }} - /> + + + + + + + + ), + className: 'content-resizable-panel-container p-t-sm', + minWidth: 800, + flex: 0.87, + }} + /> + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/Glossary/GlossaryPage/GlossaryPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/Glossary/GlossaryPage/GlossaryPage.component.tsx index 2839a2b47274..a565feae5e9b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/Glossary/GlossaryPage/GlossaryPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/Glossary/GlossaryPage/GlossaryPage.component.tsx @@ -29,6 +29,7 @@ import { ModifiedGlossary, useGlossaryStore, } from '../../../components/Glossary/useGlossary.store'; +import PageLayoutV1 from '../../../components/PageLayoutV1/PageLayoutV1'; import { FQN_SEPARATOR_CHAR } from '../../../constants/char.constants'; import { PAGE_SIZE_LARGE, ROUTES } from '../../../constants/constants'; import { GLOSSARIES_DOCS } from '../../../constants/docs.constants'; @@ -348,24 +349,6 @@ const GlossaryPage = () => { return ; } - if (glossaries.length === 0 && !isLoading) { - return ( - - ); - } - const glossaryElement = (
{isRightPanelLoading ? ( @@ -433,7 +416,27 @@ const GlossaryPage = () => { /> ); - return <>{resizableLayout}; + return ( + + {glossaries.length === 0 && !isLoading ? ( + + ) : ( + resizableLayout + )} + + ); }; export default GlossaryPage; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/Glossary/GlossaryPage/GlossaryPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/Glossary/GlossaryPage/GlossaryPage.test.tsx index 8c1b0f7e52f6..b542c7cd88bf 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/Glossary/GlossaryPage/GlossaryPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/Glossary/GlossaryPage/GlossaryPage.test.tsx @@ -46,6 +46,15 @@ jest.mock('../../../context/PermissionProvider/PermissionProvider', () => { }; }); +jest.mock('../../../components/PageLayoutV1/PageLayoutV1', () => { + return jest.fn(({ children, pageTitle }) => ( +
+

{pageTitle}

+
{children}
+
+ )); +}); + jest.mock('../../../components/Glossary/GlossaryV1.component', () => { return jest.fn().mockImplementation((props) => (
diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/LineageConfigPage/LineageConfigPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/LineageConfigPage/LineageConfigPage.tsx index 9eaca4e7bd7c..8b65c053a9a7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/LineageConfigPage/LineageConfigPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/LineageConfigPage/LineageConfigPage.tsx @@ -26,6 +26,7 @@ import ResizablePanels from '../../components/common/ResizablePanels/ResizablePa import ServiceDocPanel from '../../components/common/ServiceDocPanel/ServiceDocPanel'; import TitleBreadcrumb from '../../components/common/TitleBreadcrumb/TitleBreadcrumb.component'; import { TitleBreadcrumbProps } from '../../components/common/TitleBreadcrumb/TitleBreadcrumb.interface'; +import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1'; import { GlobalSettingsMenuCategory } from '../../constants/GlobalSettings.constants'; import { OPEN_METADATA } from '../../constants/service-guide.constant'; import { @@ -116,128 +117,130 @@ const LineageConfigPage = () => { } return ( - - -
- - + + + + + + - - - {t('label.lineage')} - - - - - - - + + + {t('label.lineage')} + + + + + + + - - - + + + - - - - - - - - - - - - - - ), - minWidth: 700, - flex: 0.7, - }} - pageTitle={t('label.lineage-config')} - secondPanel={{ - className: 'service-doc-panel content-resizable-panel-container', - minWidth: 400, - flex: 0.3, - children: ( - - ), - }} - /> + + + + + + + + + + + + + + ), + minWidth: 700, + flex: 0.7, + }} + pageTitle={t('label.lineage-config')} + secondPanel={{ + className: 'service-doc-panel content-resizable-panel-container', + minWidth: 400, + flex: 0.3, + children: ( + + ), + }} + /> + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/NotificationListPage/NotificationListPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/NotificationListPage/NotificationListPage.test.tsx index 4b1cf0dfd060..c9fbf86a420c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/NotificationListPage/NotificationListPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/NotificationListPage/NotificationListPage.test.tsx @@ -10,10 +10,12 @@ * 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 userEvent from '@testing-library/user-event'; import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import { ROUTES } from '../../constants/constants'; +import { usePermissionProvider } from '../../context/PermissionProvider/PermissionProvider'; import LimitWrapper from '../../hoc/LimitWrapper'; import { getAllAlerts } from '../../rest/alertsAPI'; import NotificationListPage from './NotificationListPage'; @@ -49,6 +51,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(() => @@ -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/Persona/PersonaListPage/PersonaPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/Persona/PersonaListPage/PersonaPage.tsx index f9d90a85db96..62bf50f8208b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/Persona/PersonaListPage/PersonaPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/Persona/PersonaListPage/PersonaPage.tsx @@ -120,75 +120,73 @@ export const PersonaPage = () => { } }; - if (isEmpty(persona) && !isLoading) { - return ( - <> - {errorPlaceHolder} - {Boolean(addEditPersona) && ( - - )} - - ); - } - return ( - - - - - - - - - - - - - - {isLoading - ? [1, 2, 3].map((key) => ( - - - - - - )) - : persona?.map((persona) => ( - - - - ))} - - {showPagination && ( - - + {errorPlaceHolder} + {Boolean(addEditPersona) && ( + + )} + + ) : ( + + + + + + - )} - {Boolean(addEditPersona) && ( - - )} - + + + + + + + {isLoading + ? [1, 2, 3].map((key) => ( + + + + + + )) + : persona?.map((persona) => ( + + + + ))} + + {showPagination && ( + + + + )} + {Boolean(addEditPersona) && ( + + )} + + )} ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableConstraints/TableConstraints.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableConstraints/TableConstraints.tsx index e07400904f8f..1eadeb9573f5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableConstraints/TableConstraints.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableConstraints/TableConstraints.tsx @@ -147,8 +147,10 @@ const TableConstraints: FC = ({ - {columns?.join(', ')} -
+ + {columns?.join(', ')} + +
{map(referredColumns, (referredColumn) => ( { 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/styles/layout/page-layout.less b/openmetadata-ui/src/main/resources/ui/src/styles/layout/page-layout.less index 4d2cea492ece..acd866f1333e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/styles/layout/page-layout.less +++ b/openmetadata-ui/src/main/resources/ui/src/styles/layout/page-layout.less @@ -20,6 +20,20 @@ overflow-x: hidden; } +.alert-page-container { + position: relative; +} + +.page-content { + position: relative; + top: 0; + transition: top 1s ease-in-out; +} + +.page-content-shifted { + top: 64px; +} + .page-layout-v1-left-panel { border: @global-border; border-radius: @border-radius-base; diff --git a/openmetadata-ui/src/main/resources/ui/src/styles/variables.less b/openmetadata-ui/src/main/resources/ui/src/styles/variables.less index e1f685b0f902..211e4b10122d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/styles/variables.less +++ b/openmetadata-ui/src/main/resources/ui/src/styles/variables.less @@ -110,6 +110,10 @@ @team-avatar-bg: #0950c51a; @om-navbar-height: ~'var(--ant-navbar-height)'; @sidebar-width: 60px; +@error-bg-color: rgb(from @error-color r g b / 0.1); +@success-bg-color: rgb(from @success-color r g b / 0.1); +@warning-bg-color: rgb(from @warning-color r g b / 0.1); +@info-bg-color: rgb(from @info-color r g b / 0.1); // Sizing @page-height: calc(100vh - @om-navbar-height); 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/Auth/TokenService/TokenServiceUtil.ts b/openmetadata-ui/src/main/resources/ui/src/utils/Auth/TokenService/TokenServiceUtil.ts index 25c2b850b558..7174f6c9c15f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/Auth/TokenService/TokenServiceUtil.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/Auth/TokenService/TokenServiceUtil.ts @@ -10,19 +10,23 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { AxiosError } from 'axios'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { AccessTokenResponse } from '../../../rest/auth-API'; import { extractDetailsFromToken } from '../../AuthProvider.util'; import { getOidcToken } from '../../LocalStorageUtils'; +type RenewTokenCallback = () => + | Promise + | Promise + | Promise; + class TokenService { channel: BroadcastChannel; - renewToken: () => Promise | Promise; + renewToken: RenewTokenCallback; tokeUpdateInProgress: boolean; - constructor( - renewToken: () => Promise | Promise - ) { + constructor(renewToken: RenewTokenCallback) { this.channel = new BroadcastChannel('auth_channel'); this.renewToken = renewToken; this.channel.onmessage = this.handleTokenUpdate.bind(this); @@ -50,9 +54,10 @@ class TokenService { // Refresh the token if it is expired async refreshToken() { const token = getOidcToken(); - const { isExpired } = extractDetailsFromToken(token); + const { isExpired, timeoutExpiry } = extractDetailsFromToken(token); - if (isExpired) { + // If token is expired or timeoutExpiry is less than 0 then try to silent signIn + if (isExpired || timeoutExpiry <= 0) { // Logic to refresh the token const newToken = await this.fetchNewToken(); // To update all the tabs on updating channel token @@ -66,14 +71,17 @@ class TokenService { // Call renewal method according to the provider async fetchNewToken() { - let response: string | AccessTokenResponse | null = null; + let response: string | AccessTokenResponse | null | void = null; if (typeof this.renewToken === 'function') { try { this.tokeUpdateInProgress = true; response = await this.renewToken(); - this.tokeUpdateInProgress = false; } catch (error) { - useApplicationStore.getState().onLogoutHandler(); + // Silent Frame window timeout error since it doesn't affect refresh token process + if ((error as AxiosError).message !== 'Frame window timed out') { + // Perform logout for any error + useApplicationStore.getState().onLogoutHandler(); + } // Do nothing } finally { this.tokeUpdateInProgress = false; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/AuthProvider.util.ts b/openmetadata-ui/src/main/resources/ui/src/utils/AuthProvider.util.ts index 520d260a20b6..c4c5877febfc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/AuthProvider.util.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/AuthProvider.util.ts @@ -340,6 +340,7 @@ export const extractDetailsFromToken = (token: string) => { return { exp, isExpired: false, + timeoutExpiry: 0, }; } const threshouldMillis = EXPIRY_THRESHOLD_MILLES; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/EntityUtils.interface.ts b/openmetadata-ui/src/main/resources/ui/src/utils/EntityUtils.interface.ts index 43acda7bdf50..9d396f29d04b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/EntityUtils.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/EntityUtils.interface.ts @@ -11,6 +11,8 @@ * limitations under the License. */ +import { LinkProps } from 'react-router-dom'; + export interface BasicEntityOverviewInfo { name: string; value: string | number | React.ReactNode; @@ -18,6 +20,7 @@ export interface BasicEntityOverviewInfo { isExternal?: boolean; isIcon?: boolean; url?: string; + linkProps?: LinkProps['to']; visible?: Array; dataTestId?: string; } diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/EntityUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/EntityUtils.tsx index f9574bc4bcc7..7b868bef14e3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/EntityUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/EntityUtils.tsx @@ -22,12 +22,14 @@ import { startCase, } from 'lodash'; import { Bucket, EntityDetailUnion } from 'Models'; +import QueryString from 'qs'; import React, { Fragment } from 'react'; import { Link } from 'react-router-dom'; import { OwnerLabel } from '../components/common/OwnerLabel/OwnerLabel.component'; import QueryCount from '../components/common/QueryCount/QueryCount.component'; import { TitleLink } from '../components/common/TitleBreadcrumb/TitleBreadcrumb.interface'; import { DataAssetsWithoutServiceField } from '../components/DataAssets/DataAssetsHeader/DataAssetsHeader.interface'; +import { TableProfilerTab } from '../components/Database/Profiler/ProfilerDashboard/profilerDashboard.interface'; import { QueryVoteType } from '../components/Database/TableQueries/TableQueries.interface'; import { LeafNodes, @@ -298,7 +300,7 @@ const getTableOverview = ( domain, } = getTableFieldsFromTableDetails(tableDetails); - const overview = [ + const overview: BasicEntityOverviewInfo[] = [ ...getCommonOverview({ owners, domain }), { name: i18next.t('label.type'), @@ -385,11 +387,16 @@ const getTableOverview = ( name: i18next.t('label.incident-plural'), value: additionalInfo?.incidentCount ?? 0, isLink: true, - url: getEntityDetailsPath( - EntityType.TABLE, - fullyQualifiedName ?? '', - EntityTabs.INCIDENTS - ), + linkProps: { + pathname: getEntityDetailsPath( + EntityType.TABLE, + fullyQualifiedName ?? '', + EntityTabs.PROFILER + ), + search: QueryString.stringify({ + activeTab: TableProfilerTab.INCIDENTS, + }), + }, visible: [ DRAWER_NAVIGATION_OPTIONS.lineage, DRAWER_NAVIGATION_OPTIONS.explore, @@ -411,7 +418,7 @@ const getTopicOverview = (topicDetails: Topic) => { messageSchema, } = topicDetails; - const overview = [ + const overview: BasicEntityOverviewInfo[] = [ ...getCommonOverview({ domain }, false), { name: i18next.t('label.partition-plural'), @@ -478,7 +485,7 @@ const getPipelineOverview = (pipelineDetails: Pipeline) => { const tier = getTierTags(tags ?? []); const serviceDisplayName = getEntityName(service); - const overview = [ + const overview: BasicEntityOverviewInfo[] = [ ...getCommonOverview({ owners, domain }), { name: `${i18next.t('label.pipeline')} ${i18next.t( @@ -522,7 +529,7 @@ const getDashboardOverview = (dashboardDetails: Dashboard) => { const tier = getTierTags(tags ?? []); const serviceDisplayName = getEntityName(service); - const overview = [ + const overview: BasicEntityOverviewInfo[] = [ ...getCommonOverview({ owners, domain }), { name: `${i18next.t('label.dashboard')} ${i18next.t( @@ -575,7 +582,7 @@ export const getSearchIndexOverview = ( const { owners, tags, service, domain } = searchIndexDetails; const tier = getTierTags(tags ?? []); - const overview = [ + const overview: BasicEntityOverviewInfo[] = [ ...getCommonOverview({ owners, domain }), { name: i18next.t('label.tier'), @@ -604,7 +611,7 @@ const getMlModelOverview = (mlModelDetails: Mlmodel) => { const { algorithm, target, server, dashboard, owners, domain } = mlModelDetails; - const overview = [ + const overview: BasicEntityOverviewInfo[] = [ ...getCommonOverview({ owners, domain }), { name: i18next.t('label.algorithm'), @@ -665,7 +672,7 @@ const getContainerOverview = (containerDetails: Container) => { DRAWER_NAVIGATION_OPTIONS.explore, ]; - const overview = [ + const overview: BasicEntityOverviewInfo[] = [ ...getCommonOverview({ owners, domain }), { name: i18next.t('label.object-plural'), @@ -705,7 +712,7 @@ const getChartOverview = (chartDetails: Chart) => { } = chartDetails; const serviceDisplayName = getEntityName(service); - const overview = [ + const overview: BasicEntityOverviewInfo[] = [ ...getCommonOverview({ owners, domain }), { name: `${i18next.t('label.chart')} ${i18next.t('label.url-uppercase')}`, @@ -767,7 +774,7 @@ const getDataModelOverview = (dataModelDetails: DashboardDataModel) => { } = dataModelDetails; const tier = getTierTags(tags ?? []); - const overview = [ + const overview: BasicEntityOverviewInfo[] = [ ...getCommonOverview({ owners, domain }), { name: `${i18next.t('label.data-model')} ${i18next.t( @@ -837,7 +844,7 @@ const getStoredProcedureOverview = ( const tier = getTierTags(tags ?? []); - const overview = [ + const overview: BasicEntityOverviewInfo[] = [ ...getCommonOverview({ owners, domain }), { name: i18next.t('label.service'), @@ -911,7 +918,7 @@ const getDatabaseOverview = (databaseDetails: Database) => { const tier = getTierTags(tags ?? []); - const overview = [ + const overview: BasicEntityOverviewInfo[] = [ { name: i18next.t('label.owner-plural'), value: , @@ -952,7 +959,7 @@ const getDatabaseSchemaOverview = (databaseSchemaDetails: DatabaseSchema) => { const tier = getTierTags(tags ?? []); - const overview = [ + const overview: BasicEntityOverviewInfo[] = [ { name: i18next.t('label.owner-plural'), value: , @@ -1001,7 +1008,7 @@ const getEntityServiceOverview = (serviceDetails: EntityServiceUnion) => { const tier = getTierTags(tags ?? []); - const overview = [ + const overview: BasicEntityOverviewInfo[] = [ { name: i18next.t('label.owner-plural'), value: , @@ -1032,7 +1039,7 @@ const getApiCollectionOverview = (apiCollection: APICollection) => { const { service, domain } = apiCollection; - const overview = [ + const overview: BasicEntityOverviewInfo[] = [ ...getCommonOverview({ domain }, false), { name: i18next.t('label.endpoint-url'), @@ -1062,7 +1069,7 @@ const getApiEndpointOverview = (apiEndpoint: APIEndpoint) => { } const { domain, service, apiCollection } = apiEndpoint; - const overview = [ + const overview: BasicEntityOverviewInfo[] = [ ...getCommonOverview({ domain }, false), { name: i18next.t('label.endpoint-url'), @@ -1119,7 +1126,7 @@ const getMetricOverview = (metric: Metric) => { return []; } - const overview = [ + const overview: BasicEntityOverviewInfo[] = [ ...getCommonOverview({ domain: metric.domain }, false), { name: i18next.t('label.metric-type'), 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/TableProfilerUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/TableProfilerUtils.ts index 0a715f4be344..b02b18c09ee5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/TableProfilerUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/TableProfilerUtils.ts @@ -16,7 +16,10 @@ import { MetricChartType } from '../components/Database/Profiler/ProfilerDashboa import { SystemProfile } from '../generated/api/data/createTableProfile'; import { Table, TableProfile } from '../generated/entity/data/table'; import { CustomMetric } from '../generated/tests/customMetric'; -import { customFormatDateTime } from './date-time/DateTimeUtils'; +import { + customFormatDateTime, + DATE_TIME_12_HOUR_FORMAT, +} from './date-time/DateTimeUtils'; import { isHasKey } from './ObjectUtils'; import { CalculateColumnProfilerMetricsInterface, @@ -31,7 +34,10 @@ export const calculateRowCountMetrics = ( const rowCountMetricData: MetricChartType['data'] = []; updateProfilerData.forEach((data) => { - const timestamp = customFormatDateTime(data.timestamp, 'MMM dd, hh:mm'); + const timestamp = customFormatDateTime( + data.timestamp, + DATE_TIME_12_HOUR_FORMAT + ); rowCountMetricData.push({ name: timestamp, @@ -58,7 +64,10 @@ export const calculateSystemMetrics = ( const operationDateMetrics: MetricChartType['data'] = []; updateProfilerData.forEach((data) => { - const timestamp = customFormatDateTime(data.timestamp, 'MMM dd, hh:mm'); + const timestamp = customFormatDateTime( + data.timestamp, + DATE_TIME_12_HOUR_FORMAT + ); operationMetrics.push({ name: timestamp, @@ -94,7 +103,7 @@ export const calculateSystemMetrics = ( ...item, stackId: stackId, latestValue: operation?.timestamp - ? customFormatDateTime(operation?.timestamp, 'MMM dd, hh:mm') + ? customFormatDateTime(operation?.timestamp, DATE_TIME_12_HOUR_FORMAT) : '--', }; }); @@ -125,7 +134,10 @@ export const calculateCustomMetrics = ( }, {} as Record); updateProfilerData.forEach((data) => { - const timestamp = customFormatDateTime(data.timestamp, 'MMM dd, hh:mm'); + const timestamp = customFormatDateTime( + data.timestamp, + DATE_TIME_12_HOUR_FORMAT + ); data?.customMetrics?.forEach((metric) => { if (!isUndefined(metric.name)) { const updatedMetric = { @@ -167,7 +179,7 @@ export const calculateColumnProfilerMetrics = ({ const quartileMetricData: MetricChartType['data'] = []; updateProfilerData.forEach((col) => { const { timestamp, sum } = col; - const name = customFormatDateTime(timestamp, 'MMM dd, hh:mm'); + const name = customFormatDateTime(timestamp, DATE_TIME_12_HOUR_FORMAT); const defaultData = { name, timestamp }; if ( 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}")`; + } +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/ToastUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/ToastUtils.ts index 38dddaffc89c..697072f5c49a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/ToastUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ToastUtils.ts @@ -11,14 +11,53 @@ * limitations under the License. */ +import { AlertProps } from 'antd'; import { AxiosError } from 'axios'; import { isEmpty, isString } from 'lodash'; import React from 'react'; -import { toast } from 'react-toastify'; +import { ReactComponent as ErrorIcon } from '../assets/svg/ic-error.svg'; +import { ReactComponent as InfoIcon } from '../assets/svg/ic-info-tag.svg'; +import { ReactComponent as SuccessIcon } from '../assets/svg/ic-success.svg'; +import { ReactComponent as WarningIcon } from '../assets/svg/ic-warning-tag.svg'; import { ClientErrors } from '../enums/Axios.enum'; +import { useAlertStore } from '../hooks/useAlertStore'; import i18n from './i18next/LocalUtil'; import { getErrorText } from './StringsUtils'; +export const getIconAndClassName = (type: AlertProps['type']) => { + switch (type) { + case 'info': + return { + icon: InfoIcon, + className: 'info', + }; + + case 'success': + return { + icon: SuccessIcon, + className: 'success', + }; + + case 'warning': + return { + icon: WarningIcon, + className: 'warning', + }; + + case 'error': + return { + icon: ErrorIcon, + className: 'error', + }; + + default: + return { + icon: null, + className: '', + }; + } +}; + export const hashCode = (str: string) => { let hash = 0, i, @@ -42,15 +81,18 @@ export const hashCode = (str: string) => { * @param autoCloseTimer Set the delay in ms to close the toast automatically. */ export const showErrorToast = ( - error: AxiosError | string, + error: AxiosError | string | JSX.Element, fallbackText?: string, autoCloseTimer?: number, - callback?: (value: React.SetStateAction) => void + callback?: (value: React.SetStateAction) => void ) => { - let errorMessage; - if (isString(error)) { + let errorMessage: string | JSX.Element; + + if (React.isValidElement(error)) { + errorMessage = error; + } else if (isString(error)) { errorMessage = error.toString(); - } else { + } else if ('config' in error && 'response' in error) { const method = error.config?.method?.toUpperCase(); const fallback = fallbackText && fallbackText.length > 0 @@ -69,12 +111,15 @@ export const showErrorToast = ( ) { return; } + } else { + errorMessage = fallbackText ?? i18n.t('server.unexpected-error'); } + callback && callback(errorMessage); - toast.error(errorMessage, { - toastId: hashCode(errorMessage), - autoClose: autoCloseTimer, - }); + + useAlertStore + .getState() + .addAlert({ type: 'error', message: errorMessage }, autoCloseTimer); }; /** @@ -83,9 +128,9 @@ export const showErrorToast = ( * @param autoCloseTimer Set the delay in ms to close the toast automatically. `Default: 5000` */ export const showSuccessToast = (message: string, autoCloseTimer = 5000) => { - toast.success(message, { - autoClose: autoCloseTimer, - }); + useAlertStore + .getState() + .addAlert({ type: 'success', message }, autoCloseTimer); }; /** @@ -94,15 +139,5 @@ export const showSuccessToast = (message: string, autoCloseTimer = 5000) => { * @param autoCloseTimer Set the delay in ms to close the toast automatically. `Default: 5000` */ export const showInfoToast = (message: string, autoCloseTimer = 5000) => { - toast.info(message, { - autoClose: autoCloseTimer, - }); -}; - -/** - * Clear all the toast messages. - */ -export const clearAllToasts = () => { - toast.clearWaitingQueue(); - toast.dismiss(); + useAlertStore.getState().addAlert({ type: 'info', message }, autoCloseTimer); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/date-time/DateTimeUtils.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/date-time/DateTimeUtils.test.ts index 917e0cf8a30c..2a694342137d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/date-time/DateTimeUtils.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/date-time/DateTimeUtils.test.ts @@ -15,6 +15,7 @@ import { calculateInterval, convertMillisecondsToHumanReadableFormat, customFormatDateTime, + DATE_TIME_12_HOUR_FORMAT, formatDate, formatDateTime, formatDateTimeLong, @@ -58,6 +59,9 @@ describe('DateTimeUtils tests', () => { it(`customFormatDateTime should formate date and time both`, () => { expect(customFormatDateTime(0, 'yyyy/MM/dd')).toBe(`1970/01/01`); + expect(customFormatDateTime(0, DATE_TIME_12_HOUR_FORMAT)).toBe( + `Jan 01, 1970, 12:00 AM` + ); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/date-time/DateTimeUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/date-time/DateTimeUtils.ts index 2a42ce1fb05a..991badc7c213 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/date-time/DateTimeUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/date-time/DateTimeUtils.ts @@ -13,6 +13,8 @@ import { capitalize, isNil, toInteger, toNumber } from 'lodash'; import { DateTime, Duration } from 'luxon'; +export const DATE_TIME_12_HOUR_FORMAT = 'MMM dd, yyyy, hh:mm a'; // e.g. Jan 01, 12:00 AM + /** * @param date EPOCH millis * @returns Formatted date for valid input. Format: MMM DD, YYYY, HH:MM AM/PM diff --git a/openmetadata-ui/src/main/resources/ui/webpack.config.dev.js b/openmetadata-ui/src/main/resources/ui/webpack.config.dev.js index 178b65174629..8a117de94324 100644 --- a/openmetadata-ui/src/main/resources/ui/webpack.config.dev.js +++ b/openmetadata-ui/src/main/resources/ui/webpack.config.dev.js @@ -116,6 +116,7 @@ module.exports = { }, alias: { process: 'process/browser', + Quill: path.resolve(__dirname, 'node_modules/quill'), // Alias for the 'quill' library in node_modules }, }, diff --git a/openmetadata-ui/src/main/resources/ui/webpack.config.prod.js b/openmetadata-ui/src/main/resources/ui/webpack.config.prod.js index 6f41247a27ae..e7d96fa21110 100644 --- a/openmetadata-ui/src/main/resources/ui/webpack.config.prod.js +++ b/openmetadata-ui/src/main/resources/ui/webpack.config.prod.js @@ -115,6 +115,7 @@ module.exports = { }, alias: { process: 'process/browser', + Quill: path.resolve(__dirname, 'node_modules/quill'), // Alias for the 'quill' library in node_modules }, }, diff --git a/openmetadata-ui/src/main/resources/ui/yarn.lock b/openmetadata-ui/src/main/resources/ui/yarn.lock index a228037516d6..6ff20d4a5c7d 100644 --- a/openmetadata-ui/src/main/resources/ui/yarn.lock +++ b/openmetadata-ui/src/main/resources/ui/yarn.lock @@ -12497,13 +12497,6 @@ react-test-renderer@^16.14.0: react-is "^16.8.6" scheduler "^0.19.1" -react-toastify@^8.2.0: - version "8.2.0" - resolved "https://registry.yarnpkg.com/react-toastify/-/react-toastify-8.2.0.tgz#ef7d56bdfdc6272ca6b228368ab564721c3a3244" - integrity sha512-Pg2Ju7NngAamarFvLwqrFomJ57u/Ay6i6zfLurt/qPynWkAkOthu6vxfqYpJCyNhHRhR4hu7+bySSeWWJu6PAg== - dependencies: - clsx "^1.1.1" - react-transition-group@2.9.0: version "2.9.0" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.9.0.tgz#df9cdb025796211151a436c69a8f3b97b5b07c8d"