From 6c080a3174dd515df8620c3ab55a2ceb3bb5ca20 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 18 Nov 2024 07:11:29 +0000 Subject: [PATCH 001/294] chore(release): Prepare Branch for `1.6.0` --- common/pom.xml | 2 +- .../docker-compose-ingestion/docker-compose-ingestion.yml | 2 +- .../docker-compose-openmetadata.yml | 4 ++-- docker/docker-compose-quickstart/Dockerfile | 4 ++-- .../docker-compose-quickstart/docker-compose-postgres.yml | 8 ++++---- docker/docker-compose-quickstart/docker-compose.yml | 8 ++++---- ingestion/Dockerfile | 2 +- ingestion/operators/docker/Dockerfile | 2 +- ingestion/pyproject.toml | 2 +- openmetadata-airflow-apis/pyproject.toml | 2 +- openmetadata-clients/openmetadata-java-client/pom.xml | 2 +- openmetadata-clients/pom.xml | 2 +- openmetadata-dist/pom.xml | 2 +- openmetadata-service/pom.xml | 2 +- openmetadata-shaded-deps/elasticsearch-dep/pom.xml | 2 +- openmetadata-shaded-deps/opensearch-dep/pom.xml | 2 +- openmetadata-shaded-deps/pom.xml | 2 +- openmetadata-spec/pom.xml | 2 +- openmetadata-ui/pom.xml | 2 +- pom.xml | 2 +- 20 files changed, 28 insertions(+), 28 deletions(-) diff --git a/common/pom.xml b/common/pom.xml index 65d7d15b61dd..8419d12de5b8 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -18,7 +18,7 @@ platform org.open-metadata - 1.6.0-SNAPSHOT + 1.6.0 4.0.0 diff --git a/docker/docker-compose-ingestion/docker-compose-ingestion.yml b/docker/docker-compose-ingestion/docker-compose-ingestion.yml index b6adb2319e31..85d09867d56c 100644 --- a/docker/docker-compose-ingestion/docker-compose-ingestion.yml +++ b/docker/docker-compose-ingestion/docker-compose-ingestion.yml @@ -18,7 +18,7 @@ volumes: services: ingestion: container_name: openmetadata_ingestion - image: docker.getcollate.io/openmetadata/ingestion:1.5.0-SNAPSHOT + image: docker.getcollate.io/openmetadata/ingestion:1.6.0 environment: AIRFLOW__API__AUTH_BACKENDS: "airflow.api.auth.backend.basic_auth,airflow.api.auth.backend.session" AIRFLOW__CORE__EXECUTOR: LocalExecutor diff --git a/docker/docker-compose-openmetadata/docker-compose-openmetadata.yml b/docker/docker-compose-openmetadata/docker-compose-openmetadata.yml index bfc903d62f73..3daae332b5f8 100644 --- a/docker/docker-compose-openmetadata/docker-compose-openmetadata.yml +++ b/docker/docker-compose-openmetadata/docker-compose-openmetadata.yml @@ -14,7 +14,7 @@ services: execute-migrate-all: container_name: execute_migrate_all command: "./bootstrap/openmetadata-ops.sh migrate" - image: docker.getcollate.io/openmetadata/server:1.5.0-SNAPSHOT + image: docker.getcollate.io/openmetadata/server:1.6.0 environment: OPENMETADATA_CLUSTER_NAME: ${OPENMETADATA_CLUSTER_NAME:-openmetadata} SERVER_PORT: ${SERVER_PORT:-8585} @@ -227,7 +227,7 @@ services: openmetadata-server: container_name: openmetadata_server restart: always - image: docker.getcollate.io/openmetadata/server:1.5.0-SNAPSHOT + image: docker.getcollate.io/openmetadata/server:1.6.0 environment: OPENMETADATA_CLUSTER_NAME: ${OPENMETADATA_CLUSTER_NAME:-openmetadata} SERVER_PORT: ${SERVER_PORT:-8585} diff --git a/docker/docker-compose-quickstart/Dockerfile b/docker/docker-compose-quickstart/Dockerfile index 81c9750f2d9f..9f63b12bbf13 100644 --- a/docker/docker-compose-quickstart/Dockerfile +++ b/docker/docker-compose-quickstart/Dockerfile @@ -11,7 +11,7 @@ # Build stage FROM alpine:3 AS build -ARG RI_VERSION="1.6.0-SNAPSHOT" +ARG RI_VERSION="1.6.0" ENV RELEASE_URL="https://github.com/open-metadata/OpenMetadata/releases/download/${RI_VERSION}-release/openmetadata-${RI_VERSION}.tar.gz" RUN mkdir -p /opt/openmetadata && \ @@ -21,7 +21,7 @@ RUN mkdir -p /opt/openmetadata && \ # Final stage FROM alpine:3 -ARG RI_VERSION="1.6.0-SNAPSHOT" +ARG RI_VERSION="1.6.0" ARG BUILD_DATE ARG COMMIT_ID LABEL maintainer="OpenMetadata" diff --git a/docker/docker-compose-quickstart/docker-compose-postgres.yml b/docker/docker-compose-quickstart/docker-compose-postgres.yml index 4b279df6cb28..8faebd441ec9 100644 --- a/docker/docker-compose-quickstart/docker-compose-postgres.yml +++ b/docker/docker-compose-quickstart/docker-compose-postgres.yml @@ -18,7 +18,7 @@ volumes: services: postgresql: container_name: openmetadata_postgresql - image: docker.getcollate.io/openmetadata/postgresql:1.5.0-SNAPSHOT + image: docker.getcollate.io/openmetadata/postgresql:1.6.0 restart: always command: "--work_mem=10MB" environment: @@ -61,7 +61,7 @@ services: execute-migrate-all: container_name: execute_migrate_all - image: docker.getcollate.io/openmetadata/server:1.5.0-SNAPSHOT + image: docker.getcollate.io/openmetadata/server:1.6.0 command: "./bootstrap/openmetadata-ops.sh migrate" environment: OPENMETADATA_CLUSTER_NAME: ${OPENMETADATA_CLUSTER_NAME:-openmetadata} @@ -275,7 +275,7 @@ services: openmetadata-server: container_name: openmetadata_server restart: always - image: docker.getcollate.io/openmetadata/server:1.5.0-SNAPSHOT + image: docker.getcollate.io/openmetadata/server:1.6.0 environment: OPENMETADATA_CLUSTER_NAME: ${OPENMETADATA_CLUSTER_NAME:-openmetadata} SERVER_PORT: ${SERVER_PORT:-8585} @@ -483,7 +483,7 @@ services: ingestion: container_name: openmetadata_ingestion - image: docker.getcollate.io/openmetadata/ingestion:1.5.0-SNAPSHOT + image: docker.getcollate.io/openmetadata/ingestion:1.6.0 depends_on: elasticsearch: condition: service_started diff --git a/docker/docker-compose-quickstart/docker-compose.yml b/docker/docker-compose-quickstart/docker-compose.yml index a66b57ddfb63..42c346921e7b 100644 --- a/docker/docker-compose-quickstart/docker-compose.yml +++ b/docker/docker-compose-quickstart/docker-compose.yml @@ -18,7 +18,7 @@ volumes: services: mysql: container_name: openmetadata_mysql - image: docker.getcollate.io/openmetadata/db:1.5.0-SNAPSHOT + image: docker.getcollate.io/openmetadata/db:1.6.0 command: "--sort_buffer_size=10M" restart: always environment: @@ -59,7 +59,7 @@ services: execute-migrate-all: container_name: execute_migrate_all - image: docker.getcollate.io/openmetadata/server:1.5.0-SNAPSHOT + image: docker.getcollate.io/openmetadata/server:1.6.0 command: "./bootstrap/openmetadata-ops.sh migrate" environment: OPENMETADATA_CLUSTER_NAME: ${OPENMETADATA_CLUSTER_NAME:-openmetadata} @@ -273,7 +273,7 @@ services: openmetadata-server: container_name: openmetadata_server restart: always - image: docker.getcollate.io/openmetadata/server:1.5.0-SNAPSHOT + image: docker.getcollate.io/openmetadata/server:1.6.0 environment: OPENMETADATA_CLUSTER_NAME: ${OPENMETADATA_CLUSTER_NAME:-openmetadata} SERVER_PORT: ${SERVER_PORT:-8585} @@ -482,7 +482,7 @@ services: ingestion: container_name: openmetadata_ingestion - image: docker.getcollate.io/openmetadata/ingestion:1.5.0-SNAPSHOT + image: docker.getcollate.io/openmetadata/ingestion:1.6.0 depends_on: elasticsearch: condition: service_started diff --git a/ingestion/Dockerfile b/ingestion/Dockerfile index cc0df5ad259c..7a726138fefc 100644 --- a/ingestion/Dockerfile +++ b/ingestion/Dockerfile @@ -76,7 +76,7 @@ ARG INGESTION_DEPENDENCY="all" ENV PIP_NO_CACHE_DIR=1 # Make pip silent ENV PIP_QUIET=1 -ARG RI_VERSION="1.6.0.0.dev0" +ARG RI_VERSION="1.6.0.0" RUN pip install --upgrade pip RUN pip install "openmetadata-managed-apis~=${RI_VERSION}" --constraint "https://raw.githubusercontent.com/apache/airflow/constraints-2.9.1/constraints-3.10.txt" RUN pip install "openmetadata-ingestion[${INGESTION_DEPENDENCY}]~=${RI_VERSION}" diff --git a/ingestion/operators/docker/Dockerfile b/ingestion/operators/docker/Dockerfile index d5d57088d256..01826eb2c658 100644 --- a/ingestion/operators/docker/Dockerfile +++ b/ingestion/operators/docker/Dockerfile @@ -81,7 +81,7 @@ ENV PIP_QUIET=1 RUN pip install --upgrade pip ARG INGESTION_DEPENDENCY="all" -ARG RI_VERSION="1.6.0.0.dev0" +ARG RI_VERSION="1.6.0.0" RUN pip install --upgrade pip RUN pip install "openmetadata-ingestion[airflow]~=${RI_VERSION}" RUN pip install "openmetadata-ingestion[${INGESTION_DEPENDENCY}]~=${RI_VERSION}" diff --git a/ingestion/pyproject.toml b/ingestion/pyproject.toml index ec2b46ea578a..1bd5f937d27c 100644 --- a/ingestion/pyproject.toml +++ b/ingestion/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" # since it helps us organize and isolate version management [project] name = "openmetadata-ingestion" -version = "1.6.0.0.dev0" +version = "1.6.0.0" dynamic = ["readme", "dependencies", "optional-dependencies"] authors = [ { name = "OpenMetadata Committers" } diff --git a/openmetadata-airflow-apis/pyproject.toml b/openmetadata-airflow-apis/pyproject.toml index 349b97021ce2..6006f4c3bfa0 100644 --- a/openmetadata-airflow-apis/pyproject.toml +++ b/openmetadata-airflow-apis/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" # since it helps us organize and isolate version management [project] name = "openmetadata_managed_apis" -version = "1.6.0.0.dev0" +version = "1.6.0.0" readme = "README.md" authors = [ {name = "OpenMetadata Committers"} diff --git a/openmetadata-clients/openmetadata-java-client/pom.xml b/openmetadata-clients/openmetadata-java-client/pom.xml index c60dfde3b786..c114c7d49c2b 100644 --- a/openmetadata-clients/openmetadata-java-client/pom.xml +++ b/openmetadata-clients/openmetadata-java-client/pom.xml @@ -5,7 +5,7 @@ openmetadata-clients org.open-metadata - 1.6.0-SNAPSHOT + 1.6.0 4.0.0 diff --git a/openmetadata-clients/pom.xml b/openmetadata-clients/pom.xml index 90b7fa975e8c..28cedd433658 100644 --- a/openmetadata-clients/pom.xml +++ b/openmetadata-clients/pom.xml @@ -5,7 +5,7 @@ platform org.open-metadata - 1.6.0-SNAPSHOT + 1.6.0 4.0.0 diff --git a/openmetadata-dist/pom.xml b/openmetadata-dist/pom.xml index 0a24ef385cd3..ac849ed3857d 100644 --- a/openmetadata-dist/pom.xml +++ b/openmetadata-dist/pom.xml @@ -20,7 +20,7 @@ platform org.open-metadata - 1.6.0-SNAPSHOT + 1.6.0 openmetadata-dist diff --git a/openmetadata-service/pom.xml b/openmetadata-service/pom.xml index 541bb6a41160..a29fca1a969c 100644 --- a/openmetadata-service/pom.xml +++ b/openmetadata-service/pom.xml @@ -5,7 +5,7 @@ platform org.open-metadata - 1.6.0-SNAPSHOT + 1.6.0 4.0.0 openmetadata-service diff --git a/openmetadata-shaded-deps/elasticsearch-dep/pom.xml b/openmetadata-shaded-deps/elasticsearch-dep/pom.xml index 156948df99d0..d0a994a0e1ed 100644 --- a/openmetadata-shaded-deps/elasticsearch-dep/pom.xml +++ b/openmetadata-shaded-deps/elasticsearch-dep/pom.xml @@ -5,7 +5,7 @@ openmetadata-shaded-deps org.open-metadata - 1.6.0-SNAPSHOT + 1.6.0 4.0.0 elasticsearch-deps diff --git a/openmetadata-shaded-deps/opensearch-dep/pom.xml b/openmetadata-shaded-deps/opensearch-dep/pom.xml index 157e02438587..784cf285bc8d 100644 --- a/openmetadata-shaded-deps/opensearch-dep/pom.xml +++ b/openmetadata-shaded-deps/opensearch-dep/pom.xml @@ -5,7 +5,7 @@ openmetadata-shaded-deps org.open-metadata - 1.6.0-SNAPSHOT + 1.6.0 4.0.0 opensearch-deps diff --git a/openmetadata-shaded-deps/pom.xml b/openmetadata-shaded-deps/pom.xml index 4c6131ebdad7..bd309b280cc5 100644 --- a/openmetadata-shaded-deps/pom.xml +++ b/openmetadata-shaded-deps/pom.xml @@ -5,7 +5,7 @@ platform org.open-metadata - 1.6.0-SNAPSHOT + 1.6.0 4.0.0 openmetadata-shaded-deps diff --git a/openmetadata-spec/pom.xml b/openmetadata-spec/pom.xml index cfaea4fe7159..7c2f99c83352 100644 --- a/openmetadata-spec/pom.xml +++ b/openmetadata-spec/pom.xml @@ -5,7 +5,7 @@ platform org.open-metadata - 1.6.0-SNAPSHOT + 1.6.0 4.0.0 diff --git a/openmetadata-ui/pom.xml b/openmetadata-ui/pom.xml index 7dbbb62784df..ff9cd83ff40c 100644 --- a/openmetadata-ui/pom.xml +++ b/openmetadata-ui/pom.xml @@ -5,7 +5,7 @@ platform org.open-metadata - 1.6.0-SNAPSHOT + 1.6.0 4.0.0 diff --git a/pom.xml b/pom.xml index 8608f326e3f4..0c7fe79092b0 100644 --- a/pom.xml +++ b/pom.xml @@ -26,7 +26,7 @@ based on Open Metadata Standards/APIs, supporting connectors to a wide range of data services, OpenMetadata enables end-to-end metadata management, giving you the freedom to unlock the value of your data assets. - 1.6.0-SNAPSHOT + 1.6.0 https://github.com/open-metadata/OpenMetadata openmetadata-spec From 15e519e8b53065909f6944db097eb87930ee167b Mon Sep 17 00:00:00 2001 From: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> Date: Tue, 19 Nov 2024 15:56:15 +0530 Subject: [PATCH 002/294] Revert persona (#18681) * Revert "feat: persona based customisation for glossary and glossary term pages (#18454)" This reverts commit 6fa76ecde915ce027d3ef036206e1a84b360f1f6. * Revert "chore(ui): move persona as top level settings (#18171)" This reverts commit 737c076999ebe76167a7c0307e87770a7d8da9f9. * fix tests * fix playwright --- .../docstore/DocStoreResourceTest.java | 2 +- .../json/schema/entity/teams/persona.json | 4 - .../json/schema/system/ui/navigationItem.json | 34 -- .../resources/json/schema/system/ui/page.json | 32 +- .../resources/json/schema/system/ui/tab.json | 33 -- .../schema/system/ui/uiCustomization.json | 46 -- .../ui/playwright/constant/settings.ts | 9 +- .../e2e/Flow/CustomizeLandingPage.spec.ts | 8 +- .../playwright/e2e/Flow/PersonaFlow.spec.ts | 26 +- .../ui/playwright/e2e/Pages/Glossary.spec.ts | 4 +- .../playwright/utils/customizeLandingPage.ts | 14 +- .../resources/ui/playwright/utils/entity.ts | 7 +- .../ui/src/assets/svg/data-assets.svg | 28 +- .../ui/src/assets/svg/governance.svg | 21 - .../resources/ui/src/assets/svg/homepage.svg | 15 - .../ui/src/assets/svg/navigation.svg | 5 - .../AppRouter/SettingsRouter.test.tsx | 4 +- .../components/AppRouter/SettingsRouter.tsx | 20 +- .../ClassificationDetails.tsx | 2 +- .../DomainLabelV2/DomainLabelV2.tsx | 192 -------- .../DataAssets/OwnerLabelV2/OwnerLabelV2.tsx | 94 ---- .../ReviewerLabelV2/ReviewerLabelV2.tsx | 126 ------ .../DataProductsDetailsPage.component.tsx | 4 +- .../DomainDetailsPage.component.tsx | 4 +- .../GenericProvider/GenericProvider.tsx | 60 --- .../CustomizeTabWidget/CustomizeTabWidget.tsx | 326 -------------- .../SynonymsWidget/GenericWidget.tsx | 411 ----------------- .../GlossaryDetails.component.tsx | 413 +++++++----------- .../GlossaryDetails.interface.ts | 5 + .../GlossaryDetails/GlossaryDetails.test.tsx | 7 + .../GlossaryDetailsRightPanel.component.tsx | 310 +++++++++++++ .../GlossaryDetailsRightPanel.test.tsx | 90 ++++ .../GlossaryHeader.component.tsx | 44 +- .../GlossaryHeader.interface.tsx | 7 + .../GlossaryHeader/GlossaryHeader.test.tsx | 58 ++- .../GlossaryHeader/GlossaryHeaderWidget.tsx | 63 --- .../GlossaryTermsV1.component.tsx | 137 +++--- .../GlossaryTerms/GlossaryTermsV1.test.tsx | 24 +- .../tabs/GlossaryOverviewTab.component.tsx | 343 +++++---------- .../tabs/GlossaryOverviewTab.test.tsx | 22 +- .../tabs/GlossaryTermReferences.test.tsx | 59 ++- .../tabs/GlossaryTermReferences.tsx | 89 +++- .../tabs/GlossaryTermSynonyms.test.tsx | 57 ++- .../tabs/GlossaryTermSynonyms.tsx | 22 +- .../GlossaryTerms/tabs/RelatedTerms.test.tsx | 66 ++- .../GlossaryTerms/tabs/RelatedTerms.tsx | 25 +- .../Glossary/GlossaryV1.component.tsx | 18 +- .../EntityNameModal.component.tsx | 6 +- .../EntityNameModal.interface.ts | 8 +- .../AddDetailsPageWidgetModal.tsx | 147 ------- .../AddWidgetTabContent.test.tsx | 2 +- .../AddWidgetModal/AddWidgetTabContent.tsx | 2 +- .../CustomiseGlossaryTermDetailPage.tsx | 88 ---- .../CustomizablePageHeader.tsx | 132 ------ .../CustomizeMyData.interface.ts | 9 +- .../CustomizeMyData/CustomizeMyData.test.tsx | 34 +- .../CustomizeMyData/CustomizeMyData.tsx | 168 +++++-- .../LeftSidebar/LeftSidebar.component.tsx | 73 +--- .../LeftSidebar/LeftSidebar.interface.ts | 2 +- .../LeftSidebar/LeftSidebarItem.component.tsx | 8 +- .../PersonaDetailsCard.test.tsx | 6 +- .../PersonaDetailsCard/PersonaDetailsCard.tsx | 2 +- .../Persona/CustomizeUI/CustomizeUI.tsx | 52 --- .../CustomPropertyTable/ExtensionTable.tsx | 5 +- .../common/DraggableTabs/DraggableTabs.tsx | 113 ----- .../constants/CustomizeWidgets.constants.ts | 66 --- .../src/constants/GlobalSettings.constants.ts | 1 - .../ui/src/constants/LeftSidebar.constants.ts | 26 +- .../resources/ui/src/constants/constants.ts | 2 +- .../ui/src/enums/CustomizeDetailPage.enum.ts | 49 --- .../resources/ui/src/enums/entity.enum.ts | 4 - .../ui/src/locale/languages/de-de.json | 6 +- .../ui/src/locale/languages/en-us.json | 6 +- .../ui/src/locale/languages/es-es.json | 6 +- .../ui/src/locale/languages/fr-fr.json | 6 +- .../ui/src/locale/languages/gl-es.json | 6 +- .../ui/src/locale/languages/he-he.json | 6 +- .../ui/src/locale/languages/ja-jp.json | 6 +- .../ui/src/locale/languages/nl-nl.json | 6 +- .../ui/src/locale/languages/pr-pr.json | 6 +- .../ui/src/locale/languages/pt-br.json | 6 +- .../ui/src/locale/languages/pt-pt.json | 4 - .../ui/src/locale/languages/ru-ru.json | 6 +- .../ui/src/locale/languages/th-th.json | 4 - .../ui/src/locale/languages/zh-cn.json | 6 +- .../ui/src/mocks/MyDataPage.mock.tsx | 10 +- .../CustomizablePage.test.tsx | 117 +++-- .../CustomizablePage/CustomizablePage.tsx | 248 ++++------- .../pages/CustomizablePage/CustomizeStore.ts | 100 ----- .../CustomizeTableDetailPage.tsx | 85 ---- .../GlossaryPage/GlossaryPage.component.tsx | 6 - .../pages/MyDataPage/MyDataPage.component.tsx | 20 +- .../src/pages/MyDataPage/MyDataPage.test.tsx | 2 +- .../PersonaDetailsPage.test.tsx | 19 +- .../PersonaDetailsPage/PersonaDetailsPage.tsx | 66 ++- .../PersonaListPage/PersonaPage.test.tsx | 47 +- .../Persona/PersonaListPage/PersonaPage.tsx | 32 +- .../SettingsNavigationPage.tsx | 251 ----------- .../TableDetailsPageV1/TableDetailsPageV1.tsx | 46 +- .../main/resources/ui/src/rest/PersonaAPI.ts | 4 +- .../main/resources/ui/src/styles/tree.less | 4 +- .../utils/ApplicationRoutesClassBase.test.ts | 8 +- .../ui/src/utils/ContainerDetailUtils.ts | 70 +-- .../CustomizeGlossaryTermPage.ts | 397 ----------------- .../CustomizeNavigation.ts | 104 ----- .../utils/CustomizableLandingPageUtils.tsx | 10 +- .../CustomizeDetailPage.ts | 198 --------- .../CustomizeGlossaryPage.ts | 333 -------------- .../utils/CustomizePage/CustomizePageUtils.ts | 257 ----------- ...ClassBase.ts => CustomizePageClassBase.ts} | 8 +- .../ui/src/utils/DashboardDetailsUtils.ts | 139 +----- .../ui/src/utils/Database/Database.util.tsx | 74 +--- .../ui/src/utils/DatabaseSchemaClassBase.ts | 76 ---- .../ui/src/utils/EntityVersionUtils.tsx | 10 +- .../ui/src/utils/GlobalSettingsClassBase.ts | 31 +- .../utils/GlossaryTerm/GlossaryTermUtil.tsx | 214 --------- .../resources/ui/src/utils/GlossaryUtils.tsx | 64 +-- .../ui/src/utils/LeftSidebarClassBase.ts | 12 +- .../ui/src/utils/Persona/PersonaUtils.test.ts | 124 ------ .../ui/src/utils/Persona/PersonaUtils.ts | 129 ------ .../ui/src/utils/PipelineDetailsUtils.ts | 70 +-- .../resources/ui/src/utils/RouterUtils.ts | 5 +- .../ui/src/utils/SearchIndexUtils.tsx | 70 +-- .../ui/src/utils/StoredProceduresUtils.tsx | 70 +-- .../resources/ui/src/utils/TableClassBase.ts | 210 +-------- .../resources/ui/src/utils/TagClassBase.ts | 2 +- .../ui/src/utils/TopicDetailsUtils.ts | 80 +--- 127 files changed, 1723 insertions(+), 6574 deletions(-) delete mode 100644 openmetadata-spec/src/main/resources/json/schema/system/ui/navigationItem.json delete mode 100644 openmetadata-spec/src/main/resources/json/schema/system/ui/tab.json delete mode 100644 openmetadata-spec/src/main/resources/json/schema/system/ui/uiCustomization.json delete mode 100644 openmetadata-ui/src/main/resources/ui/src/assets/svg/governance.svg delete mode 100644 openmetadata-ui/src/main/resources/ui/src/assets/svg/homepage.svg delete mode 100644 openmetadata-ui/src/main/resources/ui/src/assets/svg/navigation.svg delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DomainLabelV2/DomainLabelV2.tsx delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/DataAssets/OwnerLabelV2/OwnerLabelV2.tsx delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/DataAssets/ReviewerLabelV2/ReviewerLabelV2.tsx delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/GenericProvider/GenericProvider.tsx delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/Glossary/CustomiseWidgets/CustomizeTabWidget/CustomizeTabWidget.tsx delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/Glossary/CustomiseWidgets/SynonymsWidget/GenericWidget.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetailsRightPanel/GlossaryDetailsRightPanel.component.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetailsRightPanel/GlossaryDetailsRightPanel.test.tsx delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryHeader/GlossaryHeaderWidget.tsx delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/AddDetailsPageWidgetModal/AddDetailsPageWidgetModal.tsx delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomiseGlossaryTermDetailPage/CustomiseGlossaryTermDetailPage.tsx delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomizablePageHeader/CustomizablePageHeader.tsx delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/Settings/Persona/CustomizeUI/CustomizeUI.tsx delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/DraggableTabs/DraggableTabs.tsx delete mode 100644 openmetadata-ui/src/main/resources/ui/src/constants/CustomizeWidgets.constants.ts delete mode 100644 openmetadata-ui/src/main/resources/ui/src/enums/CustomizeDetailPage.enum.ts delete mode 100644 openmetadata-ui/src/main/resources/ui/src/pages/CustomizablePage/CustomizeStore.ts delete mode 100644 openmetadata-ui/src/main/resources/ui/src/pages/CustomizeTableDetailPage/CustomizeTableDetailPage.tsx delete mode 100644 openmetadata-ui/src/main/resources/ui/src/pages/SettingsNavigationPage/SettingsNavigationPage.tsx delete mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/CustomiseGlossaryTermPage/CustomizeGlossaryTermPage.ts delete mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/CustomizaNavigation/CustomizeNavigation.ts delete mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/CustomizeDetailPage/CustomizeDetailPage.ts delete mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/CustomizeGlossaryPage/CustomizeGlossaryPage.ts delete mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/CustomizePage/CustomizePageUtils.ts rename openmetadata-ui/src/main/resources/ui/src/utils/{CustomizeMyDataPageClassBase.ts => CustomizePageClassBase.ts} (97%) delete mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/GlossaryTerm/GlossaryTermUtil.tsx delete mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/Persona/PersonaUtils.test.ts delete mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/Persona/PersonaUtils.ts diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/docstore/DocStoreResourceTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/docstore/DocStoreResourceTest.java index c138bfc1a4a3..6315fc6df753 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/resources/docstore/DocStoreResourceTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/docstore/DocStoreResourceTest.java @@ -291,7 +291,7 @@ void post_validKnowledgePanels_as_admin_200_OK(TestInfo test) throws IOException page = new Page() - .withPageType(PageType.GLOSSARY_TERM) + .withPageType(PageType.GLOSSARY_TERM_LANDING_PAGE) .withKnowledgePanels( List.of(activityFeed.getEntityReference(), myData.getEntityReference())); fqn = diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/teams/persona.json b/openmetadata-spec/src/main/resources/json/schema/entity/teams/persona.json index ac8d15ea4d2a..9247653ad94f 100644 --- a/openmetadata-spec/src/main/resources/json/schema/entity/teams/persona.json +++ b/openmetadata-spec/src/main/resources/json/schema/entity/teams/persona.json @@ -38,10 +38,6 @@ "description": "Link to the resource corresponding to this entity.", "$ref": "../../type/basic.json#/definitions/href" }, - "uiCustomization": { - "description": "Reference to the UI customization configuration.", - "$ref": "../../type/entityReference.json" - }, "users": { "description": "Users that are assigned a persona.", "$ref": "../../type/entityReferenceList.json", diff --git a/openmetadata-spec/src/main/resources/json/schema/system/ui/navigationItem.json b/openmetadata-spec/src/main/resources/json/schema/system/ui/navigationItem.json deleted file mode 100644 index 60bb7a44088a..000000000000 --- a/openmetadata-spec/src/main/resources/json/schema/system/ui/navigationItem.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "$id": "https://open-metadata.org/schema/system/ui/navigationItem.json", - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "NavigationItem", - "description": "Defines a navigation item in the UI navigation menu.", - "type": "object", - "javaType": "org.openmetadata.schema.system.ui.NavigationItem", - "properties": { - "id": { - "description": "Unique identifier for the navigation item.", - "$ref": "../../type/basic.json#/definitions/uuid" - }, - "title": { - "description": "Display title of the navigation item.", - "type": "string" - }, - "pageId": { - "description": "Reference to a Page ID that this navigation item links to.", - "type": "string" - }, - "order": { - "description": "Order of the navigation item in the menu.", - "type": "integer" - }, - "children": { - "description": "Optional sub-navigation items.", - "type": "array", - "items": { "$ref": "#" }, - "default": [] - } - }, - "required": ["id", "title", "pageId", "order"], - "additionalProperties": false -} \ No newline at end of file diff --git a/openmetadata-spec/src/main/resources/json/schema/system/ui/page.json b/openmetadata-spec/src/main/resources/json/schema/system/ui/page.json index c2f2f247fe28..803050d5d88b 100644 --- a/openmetadata-spec/src/main/resources/json/schema/system/ui/page.json +++ b/openmetadata-spec/src/main/resources/json/schema/system/ui/page.json @@ -12,19 +12,19 @@ "type": "string", "enum": [ "LandingPage", - "Table", - "StoredProcedure", - "Database", - "DatabaseSchema", - "Topic", - "Pipeline", - "Dashboard", - "DashboardDataModel", - "Container", - "SearchIndex", - "Glossary", - "GlossaryTerm", - "Domain" + "TableLandingPage", + "StoredProcedureLandingPage", + "DatabaseLandingPage", + "DatabaseSchemaLandingPage", + "TopicLandingPage", + "PipelineLandingPage", + "DashboardLandingPage", + "DashboardDataModelLandingPage", + "ContainerLandingPage", + "SearchIndexLandingPage", + "GlossaryLandingPage", + "GlossaryTermLandingPage", + "DomainLandingPage" ] } }, @@ -41,12 +41,6 @@ "description": "Configuration for the Knowledge Panel.", "type": "object" }, - "tabs": { - "description": "Tabs included in this page.", - "type": "array", - "items": { "$ref": "tab.json" }, - "default": [] - }, "persona": { "description": "Persona this page belongs to.", "$ref": "../../type/entityReference.json" diff --git a/openmetadata-spec/src/main/resources/json/schema/system/ui/tab.json b/openmetadata-spec/src/main/resources/json/schema/system/ui/tab.json deleted file mode 100644 index 93805686b474..000000000000 --- a/openmetadata-spec/src/main/resources/json/schema/system/ui/tab.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "$id": "https://open-metadata.org/schema/system/ui/tab.json", - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Tab", - "description": "This schema defines a Tab within a Page.", - "type": "object", - "javaType": "org.openmetadata.schema.system.ui.Tab", - "properties": { - "id": { "$ref": "../../type/basic.json#/definitions/uuid" }, - "name": { - "description": "Name of the tab.", - "type": "string" - }, - "displayName": { - "description": "DisplayName of the tab.", - "type": "string" - }, - "layout": { - "description": "Layout configuration for this tab.", - "type": "object" - }, - "editable": { - "description": "Weather tab can be edit by the user or not.", - "type": "boolean" - }, - "knowledgePanels": { - "description": "KnowledgePanels that are part of this Tab.", - "$ref": "../../type/entityReferenceList.json" - } - }, - "required": ["id", "name", "layout"], - "additionalProperties": false -} \ No newline at end of file diff --git a/openmetadata-spec/src/main/resources/json/schema/system/ui/uiCustomization.json b/openmetadata-spec/src/main/resources/json/schema/system/ui/uiCustomization.json deleted file mode 100644 index bd32c3b90b4c..000000000000 --- a/openmetadata-spec/src/main/resources/json/schema/system/ui/uiCustomization.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "$id": "https://open-metadata.org/schema/system/ui/uiCustomization.json", - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "UICustomization", - "description": "Contains UI customization details for a Persona.", - "type": "object", - "javaType": "org.openmetadata.schema.system.ui.UICustomization", - "properties": { - "id": { "$ref": "../../type/basic.json#/definitions/uuid" }, - "name": { - "description": "A unique name for the UI customization configuration.", - "$ref": "../../type/basic.json#/definitions/entityName" - }, - "displayName": { - "description": "Name used for display purposes.", - "type": "string" - }, - "description": { - "description": "Description of the UI customization.", - "$ref": "../../type/basic.json#/definitions/markdown" - }, - "pages": { - "description": "List of Pages in the UI customization.", - "type": "array", - "items": { "$ref": "page.json" } - }, - "navigation": { - "description": "Site-wide navigation configuration.", - "type": "array", - "items": { "$ref": "navigationItem.json" } - }, - "updatedAt": { - "$ref": "../../type/basic.json#/definitions/timestamp" - }, - "updatedBy": { "type": "string" }, - "version": { - "$ref": "../../type/entityHistory.json#/definitions/entityVersion" - }, - "changeDescription": { - "$ref": "../../type/entityHistory.json#/definitions/changeDescription" - }, - "href": { "$ref": "../../type/basic.json#/definitions/href" } - }, - "required": ["id", "name", "pages"], - "additionalProperties": false -} \ No newline at end of file diff --git a/openmetadata-ui/src/main/resources/ui/playwright/constant/settings.ts b/openmetadata-ui/src/main/resources/ui/playwright/constant/settings.ts index 703f32e05358..d9dd08c01538 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/constant/settings.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/constant/settings.ts @@ -149,7 +149,10 @@ export const SETTINGS_OPTIONS_PATH = { GlobalSettingsMenuCategory.MEMBERS, `${GlobalSettingsMenuCategory.MEMBERS}.${GlobalSettingOptions.ADMINS}`, ], - [GlobalSettingOptions.PERSONA]: [GlobalSettingOptions.PERSONA], + [GlobalSettingOptions.PERSONA]: [ + GlobalSettingsMenuCategory.MEMBERS, + `${GlobalSettingsMenuCategory.MEMBERS}.${GlobalSettingOptions.PERSONA}`, + ], // Access Control @@ -164,6 +167,10 @@ export const SETTINGS_OPTIONS_PATH = { // Open-metadata + [GlobalSettingOptions.CUSTOMIZE_LANDING_PAGE]: [ + GlobalSettingsMenuCategory.PREFERENCES, + `${GlobalSettingsMenuCategory.PREFERENCES}.${GlobalSettingOptions.CUSTOMIZE_LANDING_PAGE}`, + ], [GlobalSettingOptions.EMAIL]: [ GlobalSettingsMenuCategory.PREFERENCES, `${GlobalSettingsMenuCategory.PREFERENCES}.${GlobalSettingOptions.EMAIL}`, diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/CustomizeLandingPage.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/CustomizeLandingPage.spec.ts index b44f9ff31731..b0cba878881f 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/CustomizeLandingPage.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/CustomizeLandingPage.spec.ts @@ -14,7 +14,7 @@ import { expect, Page, test as base } from '@playwright/test'; import { PersonaClass } from '../../support/persona/PersonaClass'; import { UserClass } from '../../support/user/UserClass'; import { performAdminLogin } from '../../utils/admin'; -import { redirectToHomePage, toastNotification } from '../../utils/common'; +import { redirectToHomePage } from '../../utils/common'; import { checkAllDefaultWidgets, navigateToCustomizeLandingPage, @@ -239,7 +239,11 @@ test.describe('Customize Landing Page Flow', () => { .click(); // Verify the toast notification - await toastNotification(adminPage, 'Page layout updated successfully.'); + const toastNotification = adminPage.locator('.Toastify__toast-body'); + + await expect(toastNotification).toContainText( + 'Page layout updated successfully.' + ); // Check if all widgets are present after resetting the layout await checkAllDefaultWidgets(adminPage, true); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/PersonaFlow.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/PersonaFlow.spec.ts index 7bd48c50c546..692b66700fd2 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/PersonaFlow.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/PersonaFlow.spec.ts @@ -102,7 +102,15 @@ test.describe.serial('Persona operations', () => { // Verify created persona details await expect( - page.getByTestId(`persona-details-card-${PERSONA_DETAILS.name}`) + page + .getByTestId('persona-details-card') + .getByText(PERSONA_DETAILS.displayName) + ).toBeVisible(); + + await expect( + page + .getByTestId('persona-details-card') + .getByText(PERSONA_DETAILS.description) ).toBeVisible(); const personaResponse = page.waitForResponse( @@ -112,7 +120,8 @@ test.describe.serial('Persona operations', () => { ); await page - .getByTestId(`persona-details-card-${PERSONA_DETAILS.name}`) + .locator('[data-testid="persona-details-card"]') + .getByText(PERSONA_DETAILS.displayName) .click(); await personaResponse; @@ -144,7 +153,8 @@ test.describe.serial('Persona operations', () => { page, }) => { await page - .getByTestId(`persona-details-card-${PERSONA_DETAILS.name}`) + .locator('[data-testid="persona-details-card"]') + .getByText(PERSONA_DETAILS.displayName) .click(); await page.getByTestId('edit-description').click(); @@ -170,7 +180,9 @@ test.describe.serial('Persona operations', () => { test('Persona rename flow should work properly', async ({ page }) => { await page - .getByTestId(`persona-details-card-${PERSONA_DETAILS.name}`) + .locator('[data-testid="persona-details-card"]') + .getByText(PERSONA_DETAILS.displayName) + .click(); await updatePersonaDisplayName({ page, displayName: 'Test Persona' }); @@ -191,7 +203,8 @@ test.describe.serial('Persona operations', () => { test('Remove users in persona should work properly', async ({ page }) => { await page - .getByTestId(`persona-details-card-${PERSONA_DETAILS.name}`) + .locator('[data-testid="persona-details-card"]') + .getByText(PERSONA_DETAILS.displayName) .click(); await page @@ -218,7 +231,8 @@ test.describe.serial('Persona operations', () => { test('Delete persona should work properly', async ({ page }) => { await page - .getByTestId(`persona-details-card-${PERSONA_DETAILS.name}`) + .locator('[data-testid="persona-details-card"]') + .getByText(PERSONA_DETAILS.displayName) .click(); await page.click('[data-testid="manage-button"]'); 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 a8c9e8a8a474..2ca09b5c996e 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 @@ -259,7 +259,7 @@ test.describe('Glossary tests', () => { type: 'Users', }); - await assignTag(page, 'PersonalData.Personal', 'Add', 'tabs'); + await assignTag(page, 'PersonalData.Personal'); }); await test.step('Update Glossary Term', async () => { @@ -288,7 +288,7 @@ test.describe('Glossary tests', () => { page, 'PersonalData.Personal', 'Add', - 'glossary-term' + 'panel-container' ); }); } finally { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts index a4c2ddf4d4e9..c1fa70f2f5ac 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts @@ -24,20 +24,20 @@ export const navigateToCustomizeLandingPage = async ( ) => { const getPersonas = page.waitForResponse('/api/v1/personas*'); - await settingClick(page, GlobalSettingOptions.PERSONA); + await settingClick(page, GlobalSettingOptions.CUSTOMIZE_LANDING_PAGE); await getPersonas; const getCustomPageDataResponse = page.waitForResponse( - `/api/v1/docStore/name/persona.${encodeURIComponent(personaName)}` + `/api/v1/docStore/name/persona.${encodeURIComponent( + personaName + )}.Page.LandingPage` ); // Navigate to the customize landing page - await page.getByTestId(`persona-details-card-${personaName}`).click(); - - await page.getByRole('tab', { name: 'Customize UI' }).click(); - - await page.getByTestId('LandingPage').click(); + await page.click( + `[data-testid="persona-details-card-${personaName}"] [data-testid="customize-page-button"]` + ); expect((await getCustomPageDataResponse).status()).toBe( customPageDataResponse 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 b78e65eaa595..36043e4573ca 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts @@ -353,11 +353,10 @@ export const updateDescription = async ( export const assignTag = async ( page: Page, tag: string, - action: 'Add' | 'Edit' = 'Add', - parentId = 'entity-right-panel' + action: 'Add' | 'Edit' = 'Add' ) => { await page - .getByTestId(parentId) + .getByTestId('entity-right-panel') .getByTestId('tags-container') .getByTestId(action === 'Add' ? 'add-tag' : 'edit-button') .click(); @@ -380,7 +379,7 @@ export const assignTag = async ( await expect( page - .getByTestId(parentId) + .getByTestId('entity-right-panel') .getByTestId('tags-container') .getByTestId(`tag-${tag}`) ).toBeVisible(); diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/data-assets.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/data-assets.svg index ded89c071eb3..475eb3222e12 100644 --- a/openmetadata-ui/src/main/resources/ui/src/assets/svg/data-assets.svg +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/data-assets.svg @@ -1,27 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/governance.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/governance.svg deleted file mode 100644 index da1ed7dcc114..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/assets/svg/governance.svg +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/homepage.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/homepage.svg deleted file mode 100644 index 4ceab3740731..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/assets/svg/homepage.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/navigation.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/navigation.svg deleted file mode 100644 index daa0c13adb5f..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/assets/svg/navigation.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/openmetadata-ui/src/main/resources/ui/src/components/AppRouter/SettingsRouter.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/AppRouter/SettingsRouter.test.tsx index cb47cdff5486..50caeeb07e86 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/AppRouter/SettingsRouter.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/AppRouter/SettingsRouter.test.tsx @@ -412,7 +412,7 @@ describe('SettingsRouter', () => { it('should render PersonaPage component for persona list route', async () => { render( - + ); @@ -422,7 +422,7 @@ describe('SettingsRouter', () => { it('should render PersonaDetailsPage component for persona details route', async () => { render( - + ); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/AppRouter/SettingsRouter.tsx b/openmetadata-ui/src/main/resources/ui/src/components/AppRouter/SettingsRouter.tsx index 3a235ac62067..4e16ace5458e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/AppRouter/SettingsRouter.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/AppRouter/SettingsRouter.tsx @@ -172,12 +172,6 @@ const SettingsRouter = () => { {/* Setting Page Routes with categories */} - - { )}> + {/* Roles route start * Do not change the order of these route diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Classifications/ClassificationDetails/ClassificationDetails.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Classifications/ClassificationDetails/ClassificationDetails.tsx index f544520a7546..75c1ad427226 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Classifications/ClassificationDetails/ClassificationDetails.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Classifications/ClassificationDetails/ClassificationDetails.tsx @@ -194,7 +194,7 @@ const ClassificationDetails = forwardRef( const handleUpdateDisplayName = async (data: { name: string; - displayName?: string; + displayName: string; }) => { if ( !isUndefined(currentClassification) && diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DomainLabelV2/DomainLabelV2.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DomainLabelV2/DomainLabelV2.tsx deleted file mode 100644 index 80a9fec26998..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DomainLabelV2/DomainLabelV2.tsx +++ /dev/null @@ -1,192 +0,0 @@ -/* - * 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 { Tooltip, Typography } from 'antd'; -import { AxiosError } from 'axios'; -import classNames from 'classnames'; -import { compare } from 'fast-json-patch'; -import { get, isEmpty, isUndefined } from 'lodash'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { ReactComponent as DomainIcon } from '../../../assets/svg/ic-domain.svg'; -import { ReactComponent as InheritIcon } from '../../../assets/svg/ic-inherit.svg'; -import { DE_ACTIVE_COLOR } from '../../../constants/constants'; -import { EntityReference } from '../../../generated/entity/type'; -import { - getAPIfromSource, - getEntityAPIfromSource, -} from '../../../utils/Assets/AssetsUtils'; -import { - getDomainFieldFromEntityType, - renderDomainLink, -} from '../../../utils/DomainUtils'; -import { getEntityName } from '../../../utils/EntityUtils'; -import { showErrorToast } from '../../../utils/ToastUtils'; -import { DomainLabelProps } from '../../common/DomainLabel/DomainLabel.interface'; -import DomainSelectableList from '../../common/DomainSelectableList/DomainSelectableList.component'; -import { useGenericContext } from '../../GenericProvider/GenericProvider'; -import { AssetsUnion } from '../AssetsSelectionModal/AssetSelectionModal.interface'; -import { DataAssetWithDomains } from '../DataAssetsHeader/DataAssetsHeader.interface'; - -export const DomainLabelV2 = < - T extends { - domain?: EntityReference | EntityReference[]; - id: string; - fullyQualifiedName: string; - } ->( - props: Partial -) => { - const { data, permissions, type: entityType } = useGenericContext(); - const { id: entityId, fullyQualifiedName: entityFqn, domain } = data; - const { t } = useTranslation(); - const [activeDomain, setActiveDomain] = useState([]); - const hasPermission = permissions.EditAll; - - const handleDomainSave = useCallback( - async (selectedDomain: EntityReference | EntityReference[]) => { - const fieldData = getDomainFieldFromEntityType(entityType); - - const entityDetails = getEntityAPIfromSource(entityType as AssetsUnion)( - entityFqn, - { fields: fieldData } - ); - - try { - const entityDetailsResponse = await entityDetails; - if (entityDetailsResponse) { - const jsonPatch = compare(entityDetailsResponse, { - ...entityDetailsResponse, - [fieldData]: selectedDomain, - }); - - const api = getAPIfromSource(entityType as AssetsUnion); - const res = await api(entityId, jsonPatch); - - const entityDomains = get(res, fieldData, {}); - if (Array.isArray(entityDomains)) { - setActiveDomain(entityDomains); - } else { - // update the domain details here - setActiveDomain(isEmpty(entityDomains) ? [] : [entityDomains]); - } - !isUndefined(props.afterDomainUpdateAction) && - props.afterDomainUpdateAction(res as DataAssetWithDomains); - } - } catch (err) { - // Handle errors as needed - showErrorToast(err as AxiosError); - } - }, - [entityType, entityId, entityFqn] - ); - - useEffect(() => { - if (domain) { - if (Array.isArray(domain)) { - setActiveDomain(domain); - } else { - setActiveDomain([domain]); - } - } - }, [domain]); - - const domainLink = useMemo(() => { - if ( - activeDomain && - Array.isArray(activeDomain) && - activeDomain.length > 0 - ) { - return activeDomain.map((domain) => { - const inheritedIcon = domain?.inherited ? ( - - - - ) : null; - - return ( -
- - - - {renderDomainLink(domain, getEntityName(domain), true, '')} - {inheritedIcon &&
{inheritedIcon}
} -
- ); - }); - } else { - return ( - - {t('label.no-entity', { entity: t('label.domain') })} - - ); - } - }, [activeDomain]); - - const selectableList = useMemo(() => { - return ( - hasPermission && ( - - ) - ); - }, [hasPermission, activeDomain, handleDomainSave]); - - const label = useMemo(() => { - if (props.showDomainHeading) { - return ( - <> -
- - {t('label.domain')} - - {selectableList} -
- -
- {domainLink} -
- - ); - } - - return ( -
- {domainLink} - {selectableList} -
- ); - }, [activeDomain, hasPermission, selectableList]); - - return label; -}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/OwnerLabelV2/OwnerLabelV2.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/OwnerLabelV2/OwnerLabelV2.tsx deleted file mode 100644 index 2791ed0bba62..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/OwnerLabelV2/OwnerLabelV2.tsx +++ /dev/null @@ -1,94 +0,0 @@ -/* - * 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 { Button, Space, Tooltip, Typography } from 'antd'; -import { t } from 'i18next'; -import React from 'react'; -import { ReactComponent as EditIcon } from '../../../assets/svg/edit-new.svg'; -import { ReactComponent as PlusIcon } from '../../../assets/svg/plus-primary.svg'; -import { DE_ACTIVE_COLOR } from '../../../constants/constants'; -import { TabSpecificField } from '../../../enums/entity.enum'; -import { EntityReference } from '../../../generated/entity/type'; -import { getOwnerVersionLabel } from '../../../utils/EntityVersionUtils'; -import TagButton from '../../common/TagButton/TagButton.component'; -import { UserTeamSelectableList } from '../../common/UserTeamSelectableList/UserTeamSelectableList.component'; -import { useGenericContext } from '../../GenericProvider/GenericProvider'; - -export const OwnerLabelV2 = < - T extends { owners?: EntityReference[]; id: string } ->() => { - const { data, onUpdate, permissions, isVersionView } = useGenericContext(); - - const handleUpdatedOwner = async (updatedUser?: EntityReference[]) => { - const updatedEntity = { ...data }; - updatedEntity.owners = updatedUser; - await onUpdate(updatedEntity); - }; - - return ( -
-
- - {t('label.owner-plural')} - - {(permissions.EditOwners || permissions.EditAll) && - data.owners && - data.owners.length > 0 && ( - - -
- - {getOwnerVersionLabel( - data, - isVersionView ?? false, - TabSpecificField.OWNERS, - permissions.EditOwners || permissions.EditAll - )} - - {data.owners?.length === 0 && - (permissions.EditOwners || permissions.EditAll) && ( - handleUpdatedOwner(updatedUser)}> - } - label={t('label.add')} - tooltip="" - /> - - )} -
- ); -}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/ReviewerLabelV2/ReviewerLabelV2.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/ReviewerLabelV2/ReviewerLabelV2.tsx deleted file mode 100644 index 8ed7a583083c..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/ReviewerLabelV2/ReviewerLabelV2.tsx +++ /dev/null @@ -1,126 +0,0 @@ -/* - * 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 { Button, Tooltip, Typography } from 'antd'; -import { t } from 'i18next'; -import React, { useMemo } from 'react'; -import { ReactComponent as EditIcon } from '../../../assets/svg/edit-new.svg'; -import { ReactComponent as PlusIcon } from '../../../assets/svg/plus-primary.svg'; -import { DE_ACTIVE_COLOR } from '../../../constants/constants'; -import { TabSpecificField } from '../../../enums/entity.enum'; -import { Glossary } from '../../../generated/entity/data/glossary'; -import { GlossaryTerm } from '../../../generated/entity/data/glossaryTerm'; -import { EntityReference } from '../../../generated/entity/type'; -import { getOwnerVersionLabel } from '../../../utils/EntityVersionUtils'; -import TagButton from '../../common/TagButton/TagButton.component'; -import { UserTeamSelectableList } from '../../common/UserTeamSelectableList/UserTeamSelectableList.component'; -import { useGenericContext } from '../../GenericProvider/GenericProvider'; - -export const ReviewerLabelV2 = () => { - const { data, onUpdate, permissions, isVersionView } = useGenericContext< - GlossaryTerm | Glossary - >(); - - const hasEditReviewerAccess = useMemo(() => { - return permissions.EditAll || permissions.EditReviewers; - }, [permissions]); - - const { assignedReviewers, hasReviewers } = useMemo(() => { - const inheritedReviewers: EntityReference[] = []; - const assignedReviewers: EntityReference[] = []; - - data.reviewers?.forEach((item) => { - if (item.inherited) { - inheritedReviewers.push(item); - } else { - assignedReviewers.push(item); - } - }); - - return { - inheritedReviewers, - assignedReviewers, - hasReviewers: data.reviewers && data.reviewers.length > 0, - }; - }, [data.reviewers]); - - const handleReviewerSave = async (updatedReviewers?: EntityReference[]) => { - const updatedEntity = { ...data }; - updatedEntity.reviewers = updatedReviewers; - await onUpdate(updatedEntity); - }; - - return ( -
-
- - {t('label.reviewer-plural')} - - {hasEditReviewerAccess && hasReviewers && ( - - -
-
-
- {getOwnerVersionLabel( - data, - isVersionView ?? false, - TabSpecificField.REVIEWERS, - hasEditReviewerAccess - )} -
- - {hasEditReviewerAccess && !hasReviewers && ( - - } - label={t('label.add')} - tooltip="" - /> - - )} -
-
- ); -}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx index c8b5013acd78..8f14ea508300 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx @@ -318,7 +318,7 @@ const DataProductsDetailsPage = ({ assetTabRef.current?.refreshAssets(); }; - const onNameSave = (obj: { name: string; displayName?: string }) => { + const onNameSave = (obj: { name: string; displayName: string }) => { if (dataProduct) { const { displayName } = obj; let updatedDetails = cloneDeep(dataProduct); @@ -640,7 +640,7 @@ const DataProductsDetailsPage = ({ - + { + const onNameSave = (obj: { name: string; displayName: string }) => { const { displayName } = obj; let updatedDetails = cloneDeep(domain); @@ -824,7 +824,7 @@ const DomainDetailsPage = ({ }} /> )} - + { - children?: React.ReactNode; - data: T; - type: EntityType; - onUpdate: (updatedData: T) => Promise; - isVersionView?: boolean; - permissions: OperationPermission; -} - -interface GenericContextType { - data: T; - type: EntityType; - onUpdate: (updatedData: T) => Promise; - isVersionView?: boolean; - permissions: OperationPermission; -} - -const createGenericContext = once(() => - React.createContext({} as GenericContextType) -); - -export const GenericProvider = ({ - children, - data, - type, - onUpdate, - isVersionView, - permissions, -}: GenericProviderProps) => { - const GenericContext = createGenericContext(); - - const values = useMemo( - () => ({ data, type, onUpdate, isVersionView, permissions }), - [data, type, onUpdate, isVersionView, permissions] - ); - - return ( - {children} - ); -}; - -export const useGenericContext = () => - useContext(createGenericContext()); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/CustomiseWidgets/CustomizeTabWidget/CustomizeTabWidget.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/CustomiseWidgets/CustomizeTabWidget/CustomizeTabWidget.tsx deleted file mode 100644 index 1047b18c2b5b..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/CustomiseWidgets/CustomizeTabWidget/CustomizeTabWidget.tsx +++ /dev/null @@ -1,326 +0,0 @@ -/* - * 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 Icon from '@ant-design/icons'; -import { Input, Modal, Tooltip } from 'antd'; -import { isEmpty, isNil, toString, uniqueId } from 'lodash'; -import React, { useCallback, useMemo, useState } from 'react'; -import RGL, { Layout, WidthProvider } from 'react-grid-layout'; -import { useTranslation } from 'react-i18next'; -import { ReactComponent as EditIcon } from '../../../../assets/svg/edit-new.svg'; -import { CommonWidgetType } from '../../../../constants/CustomizeWidgets.constants'; -import { EntityTabs } from '../../../../enums/entity.enum'; -import { Document } from '../../../../generated/entity/docStore/document'; -import { Page, Tab } from '../../../../generated/system/ui/page'; -import { PageType } from '../../../../generated/system/ui/uiCustomization'; -import { useGridLayoutDirection } from '../../../../hooks/useGridLayoutDirection'; -import { - WidgetCommonProps, - WidgetConfig, -} from '../../../../pages/CustomizablePage/CustomizablePage.interface'; -import { useCustomizeStore } from '../../../../pages/CustomizablePage/CustomizeStore'; -import customizeGlossaryTermPageClassBase from '../../../../utils/CustomiseGlossaryTermPage/CustomizeGlossaryTermPage'; -import { - getAddWidgetHandler, - getLayoutUpdateHandler, - getLayoutWithEmptyWidgetPlaceholder, - getRemoveWidgetHandler, - getUniqueFilteredLayout, -} from '../../../../utils/CustomizableLandingPageUtils'; -import { - getCustomizableWidgetByPage, - getDefaultTabs, - getDefaultWidgetForTab, -} from '../../../../utils/CustomizePage/CustomizePageUtils'; -import { getEntityName } from '../../../../utils/EntityUtils'; -import { getWidgetFromKey } from '../../../../utils/GlossaryTerm/GlossaryTermUtil'; -import { DraggableTabs } from '../../../common/DraggableTabs/DraggableTabs'; -import AddDetailsPageWidgetModal from '../../../MyData/CustomizableComponents/AddDetailsPageWidgetModal/AddDetailsPageWidgetModal'; - -const ReactGridLayout = WidthProvider(RGL); - -export type CustomizeTabWidgetProps = WidgetCommonProps; - -type TargetKey = React.MouseEvent | React.KeyboardEvent | string; - -export const CustomizeTabWidget = () => { - const { currentPage, currentPageType, updateCurrentPage } = - useCustomizeStore(); - const [items, setItems] = useState( - currentPage?.tabs ?? getDefaultTabs(currentPageType as PageType) - ); - const [activeKey, setActiveKey] = useState( - (items[0]?.id as EntityTabs) ?? null - ); - const { t } = useTranslation(); - - const [editableItem, setEditableItem] = useState(null); - const [tabLayouts, setTabLayouts] = useState( - getLayoutWithEmptyWidgetPlaceholder( - (items.find((item) => item.id === activeKey)?.layout as WidgetConfig[]) ?? - getDefaultWidgetForTab( - currentPageType as PageType, - (activeKey as EntityTabs) ?? EntityTabs.OVERVIEW - ), - 2, - 3 - ) - ); - const [isWidgetModalOpen, setIsWidgetModalOpen] = useState(false); - const [placeholderWidgetKey, setPlaceholderWidgetKey] = useState(''); - - const onChange = (tabKey: string) => { - const key = tabKey as EntityTabs; - setActiveKey(key); - const newTab = items.find((item) => item.id === key); - setTabLayouts( - getLayoutWithEmptyWidgetPlaceholder( - isEmpty(newTab?.layout) - ? getDefaultWidgetForTab(currentPageType as PageType, key) - : (newTab?.layout as WidgetConfig[]), - 2, - 3 - ) - ); - }; - - const add = () => { - const newActiveKey = uniqueId(`newTab`); - setItems((items) => [ - ...items, - { - name: 'New Tab', - layout: [], - id: newActiveKey, - editable: true, - } as Tab, - ]); - onChange(newActiveKey); - }; - - const remove = (targetKey: TargetKey) => { - let newActiveKey = activeKey; - let lastIndex = -1; - items.forEach((item, i) => { - if (item.id === targetKey) { - lastIndex = i - 1; - } - }); - const newPanes = items.filter((item) => item.id !== targetKey); - if (newPanes.length && newActiveKey === targetKey) { - if (lastIndex >= 0) { - newActiveKey = newPanes[lastIndex].id as EntityTabs; - } else { - newActiveKey = newPanes[0].id as EntityTabs; - } - } - setItems(newPanes); - onChange(newActiveKey ?? EntityTabs.OVERVIEW); - }; - - const onEdit = ( - targetKey: React.MouseEvent | React.KeyboardEvent | string, - action: 'add' | 'remove' - ) => { - if (action === 'add') { - add(); - } else { - remove(targetKey); - } - }; - - const handleTabEditClick = (key: string) => { - setEditableItem(items.find((item) => item.id === key) || null); - }; - - const handleRenameSave = () => { - if (editableItem) { - const newItems = items.map((item) => - item.id === editableItem.id ? editableItem : item - ); - setItems(newItems); - updateCurrentPage({ - ...currentPage, - tabs: newItems, - } as Page); - setEditableItem(null); - } - }; - - const handleChange: React.ChangeEventHandler = (event) => { - editableItem && - setEditableItem({ - ...editableItem, - displayName: event.target.value ?? '', - }); - }; - - const handleOpenAddWidgetModal = () => { - setIsWidgetModalOpen(true); - }; - - const handlePlaceholderWidgetKey = (value: string) => { - setPlaceholderWidgetKey(value); - }; - - const handleRemoveWidget = (widgetKey: string) => { - setTabLayouts(getRemoveWidgetHandler(widgetKey, 3, 3.5)); - }; - - const widgets = useMemo( - () => - tabLayouts.map((widget) => ( -
- {getWidgetFromKey({ - widgetConfig: widget, - handleOpenAddWidgetModal: handleOpenAddWidgetModal, - handlePlaceholderWidgetKey: handlePlaceholderWidgetKey, - handleRemoveWidget: handleRemoveWidget, - isEditView: true, - })} -
- )), - [tabLayouts] - ); - - const handleLayoutUpdate = useCallback( - (updatedLayout: Layout[]) => { - if (!isEmpty(tabLayouts) && !isEmpty(updatedLayout)) { - setTabLayouts(getLayoutUpdateHandler(updatedLayout)); - updateCurrentPage({ - ...currentPage, - tabs: items.map((item) => - item.id === activeKey - ? { ...item, layout: getUniqueFilteredLayout(updatedLayout) } - : item - ), - } as Page); - } - }, - [tabLayouts] - ); - - const handleMainPanelAddWidget = useCallback( - ( - newWidgetData: CommonWidgetType, - placeholderWidgetKey: string, - widgetSize: number - ) => { - setTabLayouts( - getAddWidgetHandler( - newWidgetData as unknown as Document, - placeholderWidgetKey, - widgetSize, - customizeGlossaryTermPageClassBase.detailPageMaxGridSize - ) - ); - setIsWidgetModalOpen(false); - }, - [] - ); - - const onTabPositionChange = (newOrder: React.Key[]) => { - const newItems = newOrder.map( - (key) => items.find((item) => item.id === key) as Tab - ); - setItems(newItems); - - updateCurrentPage({ - ...currentPage, - tabs: newItems, - } as Page); - }; - - // eslint-disable-next-line no-console - console.log('widgets', tabLayouts, currentPage); - - // call the hook to set the direction of the grid layout - useGridLayoutDirection(); - - return ( - <> - ({ - key: item.id, - label: ( - { - event.stopPropagation(); - item.editable && onChange(item.id); - }}> - - {getEntityName(item)} - { - event.stopPropagation(); - handleTabEditClick(item.id); - }} - /> - - - ), - closable: true, - }))} - size="small" - tabBarGutter={2} - type="editable-card" - onChange={onChange} - onEdit={onEdit} - onTabChange={onTabPositionChange} - /> - - - {widgets} - - - {currentPageType && ( - setIsWidgetModalOpen(false)} - maxGridSizeSupport={8} - open={isWidgetModalOpen} - placeholderWidgetKey={placeholderWidgetKey} - widgetsList={getCustomizableWidgetByPage(currentPageType)} - /> - )} - {editableItem && ( - setEditableItem(null)} - onOk={handleRenameSave}> - - - )} - - ); -}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/CustomiseWidgets/SynonymsWidget/GenericWidget.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/CustomiseWidgets/SynonymsWidget/GenericWidget.tsx deleted file mode 100644 index 74b73ad9b076..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/CustomiseWidgets/SynonymsWidget/GenericWidget.tsx +++ /dev/null @@ -1,411 +0,0 @@ -/* - * 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 { CloseOutlined, DragOutlined } from '@ant-design/icons'; -import { Card, Space } from 'antd'; -import { noop, startCase } from 'lodash'; -import React, { useMemo } from 'react'; -import { - DetailPageWidgetKeys, - GlossaryTermDetailPageWidgetKeys, -} from '../../../../enums/CustomizeDetailPage.enum'; -import { EntityType } from '../../../../enums/entity.enum'; -import { DataType, Table } from '../../../../generated/entity/data/table'; -import { EntityReference } from '../../../../generated/tests/testCase'; -import { TagSource } from '../../../../generated/type/tagLabel'; -import { WidgetCommonProps } from '../../../../pages/CustomizablePage/CustomizablePage.interface'; -import { FrequentlyJoinedTables } from '../../../../pages/TableDetailsPageV1/FrequentlyJoinedTables/FrequentlyJoinedTables.component'; -import { renderReferenceElement } from '../../../../utils/GlossaryUtils'; -import tableClassBase from '../../../../utils/TableClassBase'; -import { getJoinsFromTableJoins } from '../../../../utils/TableUtils'; -import { ExtensionTable } from '../../../common/CustomPropertyTable/ExtensionTable'; -import { DomainLabel } from '../../../common/DomainLabel/DomainLabel.component'; -import { OwnerLabel } from '../../../common/OwnerLabel/OwnerLabel.component'; -import RichTextEditorPreviewer from '../../../common/RichTextEditor/RichTextEditorPreviewer'; -import TagButton from '../../../common/TagButton/TagButton.component'; -import SchemaTable from '../../../Database/SchemaTable/SchemaTable.component'; -import DataProductsContainer from '../../../DataProducts/DataProductsContainer/DataProductsContainer.component'; -import TagsViewer from '../../../Tag/TagsViewer/TagsViewer'; -import { DisplayType } from '../../../Tag/TagsViewer/TagsViewer.interface'; - -export const GenericWidget = (props: WidgetCommonProps) => { - const handleRemoveClick = () => { - if (props.handleRemoveWidget) { - props.handleRemoveWidget(props.widgetKey); - } - }; - - const widgetName = startCase(props.widgetKey.replace('KnowledgePanel.', '')); - - const cardContent = useMemo(() => { - if ( - props.widgetKey.startsWith(DetailPageWidgetKeys.GLOSSARY_TERMS) || - props.widgetKey.startsWith(GlossaryTermDetailPageWidgetKeys.RELATED_TERMS) - ) { - return ( - - ); - } else if ( - props.widgetKey.startsWith(GlossaryTermDetailPageWidgetKeys.SYNONYMS) - ) { - return ( - - ); - } else if ( - props.widgetKey.startsWith(DetailPageWidgetKeys.DOMAIN) || - props.widgetKey.startsWith(GlossaryTermDetailPageWidgetKeys.DOMAIN) - ) { - return ( - - ); - } else if ( - props.widgetKey.startsWith(GlossaryTermDetailPageWidgetKeys.REFERENCES) - ) { - return [ - { - name: 'Google', - endpoint: 'https://www.google.com', - }, - { - name: 'Collate', - endpoint: 'https://www.getcollate.io', - }, - ].map((term) => renderReferenceElement(term)); - } else if ( - props.widgetKey.startsWith(DetailPageWidgetKeys.TAGS) || - props.widgetKey.startsWith(GlossaryTermDetailPageWidgetKeys.TAGS) - ) { - return ( - - ); - } else if ( - props.widgetKey.startsWith(GlossaryTermDetailPageWidgetKeys.OWNER) - ) { - return ( - - ); - } else if ( - props.widgetKey.startsWith(DetailPageWidgetKeys.CUSTOM_PROPERTIES) || - props.widgetKey.startsWith( - GlossaryTermDetailPageWidgetKeys.CUSTOM_PROPERTIES - ) - ) { - return ( - - ); - } else if ( - props.widgetKey.startsWith(GlossaryTermDetailPageWidgetKeys.REVIEWER) - ) { - return ( - - ); - } else if ( - props.widgetKey.startsWith(DetailPageWidgetKeys.DESCRIPTION) || - props.widgetKey.startsWith(GlossaryTermDetailPageWidgetKeys.DESCRIPTION) - ) { - return ( - // eslint-disable-next-line max-len - - ); - } else if (props.widgetKey.startsWith(DetailPageWidgetKeys.TABLE_SCHEMA)) { - return ( - noop()} - /> - ); - } else if ( - props.widgetKey.startsWith(DetailPageWidgetKeys.FREQUENTLY_JOINED_TABLES) - ) { - return ( - - ); - } else if (props.widgetKey.startsWith(DetailPageWidgetKeys.DATA_PRODUCTS)) { - return ( - - ); - } - - return widgetName; - }, [props.widgetKey]); - - return ( - - {widgetName} - {props.isEditView && ( - - - - - )} - - }> - {cardContent} - - ); -}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.component.tsx index 470709b66484..0ffba4c955a2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.component.tsx @@ -11,58 +11,36 @@ * limitations under the License. */ -import { Col, Row, Tabs } from 'antd'; +import { Col, Row, Space, Tabs } from 'antd'; import classNames from 'classnames'; import { isEmpty, noop } from 'lodash'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import RGL, { WidthProvider } from 'react-grid-layout'; import { useTranslation } from 'react-i18next'; import { useHistory, useParams } from 'react-router-dom'; -import { FQN_SEPARATOR_CHAR } from '../../../constants/char.constants'; import { getGlossaryTermDetailsPath } from '../../../constants/constants'; import { FEED_COUNT_INITIAL_DATA } from '../../../constants/entity.constants'; import { EntityField } from '../../../constants/Feeds.constants'; -import { GlossaryTermDetailPageWidgetKeys } from '../../../enums/CustomizeDetailPage.enum'; -import { EntityTabs, EntityType } from '../../../enums/entity.enum'; +import { COMMON_RESIZABLE_PANEL_CONFIG } from '../../../constants/ResizablePanel.constants'; +import { EntityType } from '../../../enums/entity.enum'; import { Glossary } from '../../../generated/entity/data/glossary'; import { ChangeDescription } from '../../../generated/entity/type'; -import { Page, PageType, Tab } from '../../../generated/system/ui/page'; -import { TagLabel } from '../../../generated/tests/testCase'; -import { TagSource } from '../../../generated/type/tagLabel'; -import { useApplicationStore } from '../../../hooks/useApplicationStore'; -import { useGridLayoutDirection } from '../../../hooks/useGridLayoutDirection'; import { FeedCounts } from '../../../interface/feed.interface'; -import { WidgetConfig } from '../../../pages/CustomizablePage/CustomizablePage.interface'; -import { useCustomizeStore } from '../../../pages/CustomizablePage/CustomizeStore'; -import { getDocumentByFQN } from '../../../rest/DocStoreAPI'; import { getFeedCounts } from '../../../utils/CommonUtils'; -import customizeGlossaryPageClassBase from '../../../utils/CustomizeGlossaryPage/CustomizeGlossaryPage'; import { getEntityName } from '../../../utils/EntityUtils'; -import { - getEntityVersionByField, - getEntityVersionTags, -} from '../../../utils/EntityVersionUtils'; -import { - getGlossaryTermDetailTabs, - getTabLabelMap, - getWidgetFromKey, -} from '../../../utils/GlossaryTerm/GlossaryTermUtil'; +import { getEntityVersionByField } from '../../../utils/EntityVersionUtils'; import { ActivityFeedTab } from '../../ActivityFeed/ActivityFeedTab/ActivityFeedTab.component'; import DescriptionV1 from '../../common/EntityDescription/DescriptionV1'; +import ResizablePanels from '../../common/ResizablePanels/ResizablePanels'; import TabsLabel from '../../common/TabsLabel/TabsLabel.component'; -import { DomainLabelV2 } from '../../DataAssets/DomainLabelV2/DomainLabelV2'; -import { OwnerLabelV2 } from '../../DataAssets/OwnerLabelV2/OwnerLabelV2'; -import { ReviewerLabelV2 } from '../../DataAssets/ReviewerLabelV2/ReviewerLabelV2'; -import { GenericProvider } from '../../GenericProvider/GenericProvider'; -import TagsContainerV2 from '../../Tag/TagsContainerV2/TagsContainerV2'; -import { DisplayType } from '../../Tag/TagsViewer/TagsViewer.interface'; +import GlossaryDetailsRightPanel from '../GlossaryDetailsRightPanel/GlossaryDetailsRightPanel.component'; import GlossaryHeader from '../GlossaryHeader/GlossaryHeader.component'; import GlossaryTermTab from '../GlossaryTermTab/GlossaryTermTab.component'; import { useGlossaryStore } from '../useGlossary.store'; import './glossary-details.less'; -import { GlossaryDetailsProps } from './GlossaryDetails.interface'; - -const ReactGridLayout = WidthProvider(RGL); +import { + GlossaryDetailsProps, + GlossaryTabs, +} from './GlossaryDetails.interface'; const GlossaryDetails = ({ permissions, @@ -79,35 +57,12 @@ const GlossaryDetails = ({ const { t } = useTranslation(); const history = useHistory(); const { activeGlossary: glossary } = useGlossaryStore(); + const { tab: activeTab } = useParams<{ tab: string }>(); const [feedCount, setFeedCount] = useState( FEED_COUNT_INITIAL_DATA ); - const { selectedPersona } = useApplicationStore(); const [isDescriptionEditable, setIsDescriptionEditable] = useState(false); - const { currentPersonaDocStore } = useCustomizeStore(); - // Since we are rendering this component for all customized tabs we need tab ID to get layout form store - const { tab: activeTab = EntityTabs.TERMS } = - useParams<{ tab: EntityTabs }>(); - const [customizedPage, setCustomizedPage] = useState(null); - - useGridLayoutDirection(); - - const layout = useMemo(() => { - if (!currentPersonaDocStore) { - return customizeGlossaryPageClassBase.getDefaultWidgetForTab(activeTab); - } - - const page = currentPersonaDocStore?.data?.pages.find( - (p: Page) => p.pageType === PageType.Glossary - ); - - if (page) { - return page.tabs.find((t: Tab) => t.id === activeTab)?.layout; - } else { - return customizeGlossaryPageClassBase.getDefaultWidgetForTab(activeTab); - } - }, [currentPersonaDocStore, activeTab]); const handleFeedCount = useCallback((data: FeedCounts) => { setFeedCount(data); @@ -139,37 +94,44 @@ const GlossaryDetails = ({ } }; - const updatedGlossary = useMemo(() => { - const updatedDescription = isVersionView - ? getEntityVersionByField( - glossary.changeDescription as ChangeDescription, - EntityField.DESCRIPTION, - glossary.description - ) - : glossary.description; + const description = useMemo( + () => + isVersionView + ? getEntityVersionByField( + glossary.changeDescription as ChangeDescription, + EntityField.DESCRIPTION, + glossary.description + ) + : glossary.description, + + [glossary, isVersionView] + ); + + const name = useMemo( + () => + isVersionView + ? getEntityVersionByField( + glossary.changeDescription as ChangeDescription, + EntityField.NAME, + glossary.name + ) + : glossary.name, + + [glossary, isVersionView] + ); - const updatedName = isVersionView - ? getEntityVersionByField( - glossary.changeDescription as ChangeDescription, - EntityField.NAME, - glossary.name - ) - : glossary.name; - const updatedDisplayName = isVersionView - ? getEntityVersionByField( - glossary.changeDescription as ChangeDescription, - EntityField.DISPLAYNAME, - glossary.displayName - ) - : glossary.displayName; + const displayName = useMemo( + () => + isVersionView + ? getEntityVersionByField( + glossary.changeDescription as ChangeDescription, + EntityField.DISPLAYNAME, + glossary.displayName + ) + : glossary.displayName, - return { - ...glossary, - description: updatedDescription, - name: updatedName, - displayName: updatedDisplayName, - }; - }, [glossary, isVersionView]); + [glossary, isVersionView] + ); const handleTabChange = (activeKey: string) => { if (activeKey !== activeTab) { @@ -179,129 +141,88 @@ const GlossaryDetails = ({ } }; - const tags = useMemo( - () => - isVersionView - ? getEntityVersionTags( - glossary, - glossary.changeDescription as ChangeDescription - ) - : glossary.tags, - [isVersionView, glossary] - ); - - const tagsWidget = useMemo(() => { + const detailsContent = useMemo(() => { return ( - - await handleGlossaryUpdate({ ...glossary, tags: updatedTags }) - } - onThreadLinkSelect={onThreadLinkSelect} - /> - ); - }, [tags, glossary, handleGlossaryUpdate, permissions, onThreadLinkSelect]); + + + + + setIsDescriptionEditable(false)} + onDescriptionEdit={() => setIsDescriptionEditable(true)} + onDescriptionUpdate={onDescriptionUpdate} + onThreadLinkSelect={onThreadLinkSelect} + /> - const widgets = useMemo(() => { - const getWidgetFromKeyInternal = (widget: WidgetConfig) => { - if (widget.i.startsWith(GlossaryTermDetailPageWidgetKeys.DESCRIPTION)) { - return ( - setIsDescriptionEditable(false)} - onDescriptionEdit={() => setIsDescriptionEditable(true)} - onDescriptionUpdate={onDescriptionUpdate} - onThreadLinkSelect={onThreadLinkSelect} - /> - ); - } else if ( - widget.i.startsWith(GlossaryTermDetailPageWidgetKeys.TERMS_TABLE) - ) { - return ( - + + + ), + ...COMMON_RESIZABLE_PANEL_CONFIG.LEFT_PANEL, + }} + secondPanel={{ + children: ( + + ), + ...COMMON_RESIZABLE_PANEL_CONFIG.RIGHT_PANEL, + className: + 'entity-resizable-right-panel-container glossary-resizable-panel-container', + }} /> - ); - } else if (widget.i.startsWith(GlossaryTermDetailPageWidgetKeys.OWNER)) { - return ; - } else if (widget.i.startsWith(GlossaryTermDetailPageWidgetKeys.DOMAIN)) { - return ; - } else if ( - widget.i.startsWith(GlossaryTermDetailPageWidgetKeys.REVIEWER) - ) { - return ; - } else if (widget.i.startsWith(GlossaryTermDetailPageWidgetKeys.TAGS)) { - return tagsWidget; - } - - return getWidgetFromKey({ - widgetConfig: widget, - handleOpenAddWidgetModal: noop, - handlePlaceholderWidgetKey: noop, - handleRemoveWidget: noop, - isEditView: false, - }); - }; - - return layout.map((widget: WidgetConfig) => ( -
- {getWidgetFromKeyInternal(widget)} -
- )); - }, [tagsWidget, layout, permissions, termsLoading]); - - const detailsContent = useMemo(() => { - return ( - - {widgets} - + +
); - }, [permissions, glossary, termsLoading, isDescriptionEditable, widgets]); + }, [ + isVersionView, + permissions, + glossary, + termsLoading, + description, + isDescriptionEditable, + ]); const tabs = useMemo(() => { - const tabLabelMap = getTabLabelMap(customizedPage?.tabs); - - const items = [ + return [ { label: ( ), - key: EntityTabs.TERMS, + key: GlossaryTabs.TERMS, children: detailsContent, }, ...(!isVersionView @@ -310,15 +231,12 @@ const GlossaryDetails = ({ label: ( ), - key: EntityTabs.ACTIVITY_FEED, + key: GlossaryTabs.ACTIVITY_FEED, children: ( { - const pageFQN = `${EntityType.PERSONA}${FQN_SEPARATOR_CHAR}${selectedPersona.fullyQualifiedName}`; - try { - const doc = await getDocumentByFQN(pageFQN); - setCustomizedPage( - doc.data?.pages?.find((p: Page) => p.pageType === PageType.Glossary) - ); - } catch (error) { - // fail silent - } - }, [selectedPersona.fullyQualifiedName]); - - useEffect(() => { - if (selectedPersona?.fullyQualifiedName) { - fetchDocument(); - } - }, [selectedPersona]); - return ( - - data={updatedGlossary} - isVersionView={isVersionView} - permissions={permissions} - type={EntityType.GLOSSARY} - onUpdate={handleGlossaryUpdate}> - - - - - - - - - + + + + + + + + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.interface.ts index 388f47023d2b..12847a912abc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.interface.ts @@ -16,6 +16,11 @@ import { Glossary } from '../../../generated/entity/data/glossary'; import { GlossaryTerm } from '../../../generated/entity/data/glossaryTerm'; import { VotingDataProps } from '../../Entity/Voting/voting.interface'; +export enum GlossaryTabs { + TERMS = 'terms', + ACTIVITY_FEED = 'activity_feed', +} + export type GlossaryDetailsProps = { isVersionView?: boolean; permissions: OperationPermission; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.test.tsx index c27b6899d8f8..d8fd176edf8b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.test.tsx @@ -46,6 +46,13 @@ jest.mock( }) ); +jest.mock( + '../GlossaryDetailsRightPanel/GlossaryDetailsRightPanel.component', + () => { + return jest.fn().mockImplementation(() => <>testGlossaryRightPanel); + } +); + jest.mock('../../common/EntityDescription/DescriptionV1', () => jest.fn().mockImplementation(() =>
DescriptionV1
) ); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetailsRightPanel/GlossaryDetailsRightPanel.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetailsRightPanel/GlossaryDetailsRightPanel.component.tsx new file mode 100644 index 000000000000..25e64c16ae50 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetailsRightPanel/GlossaryDetailsRightPanel.component.tsx @@ -0,0 +1,310 @@ +/* + * Copyright 2023 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 { Button, Col, Row, Space, Tooltip, Typography } from 'antd'; +import { t } from 'i18next'; +import { cloneDeep, includes, isEqual } from 'lodash'; +import React, { useMemo } from 'react'; +import { ReactComponent as EditIcon } from '../../../assets/svg/edit-new.svg'; +import { ReactComponent as PlusIcon } from '../../../assets/svg/plus-primary.svg'; +import { UserTeamSelectableList } from '../../../components/common/UserTeamSelectableList/UserTeamSelectableList.component'; +import { DE_ACTIVE_COLOR } from '../../../constants/constants'; +import { OperationPermission } from '../../../context/PermissionProvider/PermissionProvider.interface'; +import { EntityType, TabSpecificField } from '../../../enums/entity.enum'; +import { Glossary, TagSource } from '../../../generated/entity/data/glossary'; +import { + GlossaryTerm, + TagLabel, +} from '../../../generated/entity/data/glossaryTerm'; +import { ChangeDescription } from '../../../generated/entity/type'; +import { EntityReference } from '../../../generated/type/entityReference'; +import { + getEntityVersionTags, + getOwnerVersionLabel, +} from '../../../utils/EntityVersionUtils'; +import { CustomPropertyTable } from '../../common/CustomPropertyTable/CustomPropertyTable'; +import { ExtentionEntitiesKeys } from '../../common/CustomPropertyTable/CustomPropertyTable.interface'; +import { DomainLabel } from '../../common/DomainLabel/DomainLabel.component'; +import TagButton from '../../common/TagButton/TagButton.component'; +import TagsContainerV2 from '../../Tag/TagsContainerV2/TagsContainerV2'; +import { DisplayType } from '../../Tag/TagsViewer/TagsViewer.interface'; + +type Props = { + isVersionView?: boolean; + permissions: OperationPermission; + selectedData: Glossary | GlossaryTerm; + isGlossary: boolean; + onUpdate: (data: GlossaryTerm | Glossary) => void | Promise; + onThreadLinkSelect: (value: string) => void; + entityType: EntityType; + refreshGlossaryTerms?: () => void; + editCustomAttributePermission?: boolean; + onExtensionUpdate?: (updatedTable: GlossaryTerm) => Promise; +}; + +const GlossaryDetailsRightPanel = ({ + permissions, + selectedData, + isGlossary, + onUpdate, + isVersionView, + onThreadLinkSelect, + refreshGlossaryTerms, + entityType, + editCustomAttributePermission, + onExtensionUpdate, +}: Props) => { + const hasEditReviewerAccess = useMemo(() => { + return permissions.EditAll || permissions.EditReviewers; + }, [permissions]); + + const hasViewAllPermission = useMemo(() => { + return permissions.ViewAll; + }, [permissions]); + + const { assignedReviewers, hasReviewers } = useMemo(() => { + const inheritedReviewers: EntityReference[] = []; + const assignedReviewers: EntityReference[] = []; + + selectedData.reviewers?.forEach((item) => { + if (item.inherited) { + inheritedReviewers.push(item); + } else { + assignedReviewers.push(item); + } + }); + + return { + inheritedReviewers, + assignedReviewers, + hasReviewers: selectedData.reviewers && selectedData.reviewers.length > 0, + }; + }, [selectedData.reviewers]); + + const handleTagsUpdate = async (updatedTags: TagLabel[]) => { + if (updatedTags) { + const updatedData = { + ...selectedData, + tags: updatedTags, + }; + + await onUpdate(updatedData); + } + }; + + const handleReviewerSave = async (data?: EntityReference[]) => { + const reviewers: EntityReference[] = data ?? []; + + if (!isEqual(reviewers, assignedReviewers)) { + let updatedGlossary = cloneDeep(selectedData); + const oldReviewer = reviewers.filter((d) => + includes(assignedReviewers, d) + ); + const newReviewer = reviewers + .filter((d) => !includes(assignedReviewers, d)) + .map((d) => ({ id: d.id, type: d.type })); + updatedGlossary = { + ...updatedGlossary, + reviewers: [...oldReviewer, ...newReviewer], + }; + await onUpdate(updatedGlossary); + } + }; + + const handleUpdatedOwner = async (newOwner?: EntityReference[]) => { + const updatedData = { + ...selectedData, + owners: newOwner, + }; + await onUpdate(updatedData); + refreshGlossaryTerms?.(); + }; + + const tags = useMemo( + () => + isVersionView + ? getEntityVersionTags( + selectedData, + selectedData.changeDescription as ChangeDescription + ) + : selectedData.tags, + [isVersionView, selectedData] + ); + + return ( + + + + + +
+ + {t('label.owner-plural')} + + {(permissions.EditOwners || permissions.EditAll) && + selectedData.owners && + selectedData.owners.length > 0 && ( + handleUpdatedOwner(updatedUser)}> + +
+ + {getOwnerVersionLabel( + selectedData, + isVersionView ?? false, + TabSpecificField.OWNERS, + permissions.EditOwners || permissions.EditAll + )} + + {selectedData.owners?.length === 0 && + (permissions.EditOwners || permissions.EditAll) && ( + handleUpdatedOwner(updatedUser)}> + } + label={t('label.add')} + tooltip="" + /> + + )} + + +
+ + {t('label.reviewer-plural')} + + {hasEditReviewerAccess && hasReviewers && ( + + +
+
+
+ {getOwnerVersionLabel( + selectedData, + isVersionView ?? false, + TabSpecificField.REVIEWERS, + hasEditReviewerAccess + )} +
+ + {hasEditReviewerAccess && !hasReviewers && ( + + } + label={t('label.add')} + tooltip="" + /> + + )} +
+ + {isGlossary && ( + +
+ +
+ + )} + + {!isGlossary && selectedData && ( + { + await onExtensionUpdate?.(updatedTable as GlossaryTerm); + }} + hasEditAccess={Boolean(editCustomAttributePermission)} + hasPermission={hasViewAllPermission} + maxDataCap={5} + /> + )} + +
+ ); +}; + +export default GlossaryDetailsRightPanel; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetailsRightPanel/GlossaryDetailsRightPanel.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetailsRightPanel/GlossaryDetailsRightPanel.test.tsx new file mode 100644 index 000000000000..adfca2cf6cd9 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetailsRightPanel/GlossaryDetailsRightPanel.test.tsx @@ -0,0 +1,90 @@ +/* + * Copyright 2023 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 { render } from '@testing-library/react'; +import React from 'react'; +import { BrowserRouter } from 'react-router-dom'; +import { OperationPermission } from '../../../context/PermissionProvider/PermissionProvider.interface'; +import { EntityType } from '../../../enums/entity.enum'; +import { mockedGlossaries } from '../../../mocks/Glossary.mock'; +import GlossaryDetailsRightPanel from './GlossaryDetailsRightPanel.component'; + +const mockPermissions = { + Create: true, + Delete: true, + EditAll: true, + EditCustomFields: true, + EditDataProfile: true, + EditDescription: true, + EditDisplayName: true, + EditLineage: true, + EditOwners: true, + EditQueries: true, + EditSampleData: true, + EditTags: true, + EditTests: true, + EditTier: true, + ViewAll: true, + ViewDataProfile: true, + ViewQueries: true, + ViewSampleData: true, + ViewTests: true, + ViewUsage: true, +} as OperationPermission; + +jest.mock( + '../../../components/common/UserSelectableList/UserSelectableList.component', + () => ({ + UserSelectableList: jest + .fn() + .mockImplementation(() => <>testUserSelectableList), + }) +); +jest.mock( + '../../../components/common/UserTeamSelectableList/UserTeamSelectableList.component', + () => ({ + UserTeamSelectableList: jest + .fn() + .mockImplementation(() => <>testUserTeamSelectableList), + }) +); + +jest.mock('../../../components/common/ProfilePicture/ProfilePicture', () => { + return jest.fn().mockImplementation(() => <>testProfilePicture); +}); + +describe('GlossaryDetailsRightPanel', () => { + it('should render the GlossaryDetailsRightPanel component', () => { + const { getByTestId } = render( + + + + ); + + expect(getByTestId('glossary-right-panel-owner-link')).toHaveTextContent( + 'label.owner-plural' + ); + expect(getByTestId('glossary-reviewer-heading-name')).toHaveTextContent( + 'label.reviewer-plural' + ); + expect(getByTestId('glossary-tags-name')).toHaveTextContent( + 'label.tag-plural' + ); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryHeader/GlossaryHeader.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryHeader/GlossaryHeader.component.tsx index 872cb57f05a1..9c0851a77ab9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryHeader/GlossaryHeader.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryHeader/GlossaryHeader.component.tsx @@ -43,6 +43,7 @@ import { ResourceEntity } from '../../../context/PermissionProvider/PermissionPr import { EntityAction, EntityType } from '../../../enums/entity.enum'; import { Glossary } from '../../../generated/entity/data/glossary'; import { + EntityReference, GlossaryTerm, Status, } from '../../../generated/entity/data/glossaryTerm'; @@ -69,33 +70,30 @@ import { import { showErrorToast } from '../../../utils/ToastUtils'; import { TitleBreadcrumbProps } from '../../common/TitleBreadcrumb/TitleBreadcrumb.interface'; import Voting from '../../Entity/Voting/Voting.component'; -import { useGenericContext } from '../../GenericProvider/GenericProvider'; import ChangeParentHierarchy from '../../Modals/ChangeParentHierarchy/ChangeParentHierarchy.component'; import StyleModal from '../../Modals/StyleModal/StyleModal.component'; import { GlossaryStatusBadge } from '../GlossaryStatusBadge/GlossaryStatusBadge.component'; import { GlossaryHeaderProps } from './GlossaryHeader.interface'; const GlossaryHeader = ({ + selectedData, + permissions, + onUpdate, onDelete, + isGlossary, onAssetAdd, onAddGlossaryTerm, updateVote, + isVersionView, }: GlossaryHeaderProps) => { const { t } = useTranslation(); const history = useHistory(); - const { fqn } = useFqn(); const { currentUser } = useApplicationStore(); - const { - onUpdate, - data: selectedData, - isVersionView, - permissions, - type: entityType, - } = useGenericContext(); const { version } = useParams<{ version: string; }>(); + const { fqn } = useFqn(); const { id } = useParams<{ id: string }>(); const { showModal } = useEntityExportModalProvider(); const [breadcrumb, setBreadcrumb] = useState< @@ -110,7 +108,6 @@ const GlossaryHeader = ({ const [isStyleEditing, setIsStyleEditing] = useState(false); const [openChangeParentHierarchyModal, setOpenChangeParentHierarchyModal] = useState(false); - const isGlossary = entityType === EntityType.GLOSSARY; const { permissions: globalPermissions } = usePermissionProvider(); const createGlossaryTermPermission = useMemo( @@ -154,7 +151,7 @@ const GlossaryHeader = ({ const glossaryTermStatus: Status | null = useMemo(() => { if (!isGlossary) { - return selectedData.status ?? Status.Approved; + return (selectedData as GlossaryTerm).status ?? Status.Approved; } return null; @@ -182,13 +179,13 @@ const GlossaryHeader = ({ ); } - if (selectedData.style?.iconURL) { + if ((selectedData as GlossaryTerm).style?.iconURL) { return ( ); @@ -206,7 +203,7 @@ const GlossaryHeader = ({ }, [selectedData, isGlossary]); const handleAddGlossaryTermClick = useCallback(() => { - onAddGlossaryTerm(!isGlossary ? selectedData : undefined); + onAddGlossaryTerm(!isGlossary ? (selectedData as GlossaryTerm) : undefined); }, [fqn]); const handleGlossaryImport = () => @@ -242,7 +239,7 @@ const GlossaryHeader = ({ setIsDelete(false); }; - const onNameSave = async (obj: { name: string; displayName?: string }) => { + const onNameSave = async (obj: { name: string; displayName: string }) => { const { name, displayName } = obj; let updatedDetails = cloneDeep(selectedData); @@ -459,7 +456,8 @@ const GlossaryHeader = ({ const statusBadge = useMemo(() => { if (!isGlossary) { - const entityStatus = selectedData.status ?? Status.Approved; + const entityStatus = + (selectedData as GlossaryTerm).status ?? Status.Approved; return ; } @@ -564,7 +562,11 @@ const GlossaryHeader = ({ entityType={EntityType.GLOSSARY_TERM} icon={icon} serviceName="" - titleColor={isGlossary ? undefined : selectedData.style?.color} + titleColor={ + isGlossary + ? undefined + : (selectedData as GlossaryTerm).style?.color + } /> @@ -652,9 +654,9 @@ const GlossaryHeader = ({ /> )} - + setIsStyleEditing(false)} onSubmit={onStyleSave} /> {openChangeParentHierarchyModal && ( setOpenChangeParentHierarchyModal(false)} onSubmit={onChangeParentSave} /> diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryHeader/GlossaryHeader.interface.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryHeader/GlossaryHeader.interface.tsx index 6be1ace7e743..5681879294b9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryHeader/GlossaryHeader.interface.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryHeader/GlossaryHeader.interface.tsx @@ -10,11 +10,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { OperationPermission } from '../../../context/PermissionProvider/PermissionProvider.interface'; +import { Glossary } from '../../../generated/entity/data/glossary'; import { GlossaryTerm } from '../../../generated/entity/data/glossaryTerm'; import { VotingDataProps } from '../../Entity/Voting/voting.interface'; export interface GlossaryHeaderProps { + isVersionView?: boolean; supportAddOwner?: boolean; + permissions: OperationPermission; + selectedData: Glossary | GlossaryTerm; + isGlossary: boolean; + onUpdate: (data: GlossaryTerm | Glossary) => Promise; onDelete: (id: string) => Promise; onAssetAdd?: () => void; updateVote?: (data: VotingDataProps) => Promise; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryHeader/GlossaryHeader.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryHeader/GlossaryHeader.test.tsx index f32c50462e0b..f3b7a3a683f7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryHeader/GlossaryHeader.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryHeader/GlossaryHeader.test.tsx @@ -12,7 +12,6 @@ */ import { act, fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; -import { EntityType } from '../../../enums/entity.enum'; import { Glossary } from '../../../generated/entity/data/glossary'; import { mockedGlossaryTerms, @@ -24,7 +23,7 @@ import { DEFAULT_ENTITY_PERMISSION } from '../../../utils/PermissionsUtils'; import { QueryVoteType } from '../../Database/TableQueries/TableQueries.interface'; import GlossaryHeader from './GlossaryHeader.component'; -const mockGlossaryTermPermission = { +const glossaryTermPermission = { All: true, Create: true, Delete: true, @@ -38,7 +37,7 @@ const mockGlossaryTermPermission = { jest.mock('../../../context/PermissionProvider/PermissionProvider', () => ({ usePermissionProvider: jest.fn().mockImplementation(() => ({ permissions: { - glossaryTerm: mockGlossaryTermPermission, + glossaryTerm: glossaryTermPermission, }, })), })); @@ -168,28 +167,21 @@ jest.mock('../../../rest/glossaryAPI', () => ({ patchGlossaryTerm: jest.fn().mockImplementation(() => Promise.resolve()), })); +const mockOnUpdate = jest.fn(); const mockOnDelete = jest.fn(); const mockOnUpdateVote = jest.fn(); -const mockContext = { - data: { displayName: 'glossaryTest' } as Glossary, - onUpdate: jest.fn(), - isVersionView: false, - type: EntityType.GLOSSARY, - permissions: DEFAULT_ENTITY_PERMISSION, -}; - -jest.mock('../../GenericProvider/GenericProvider', () => ({ - useGenericContext: jest.fn().mockImplementation(() => mockContext), -})); - describe('GlossaryHeader component', () => { it('should render name of Glossary', () => { render( ); @@ -199,9 +191,13 @@ describe('GlossaryHeader component', () => { it('should render import and export dropdown menu items only for glossary', async () => { render( ); @@ -219,13 +215,17 @@ describe('GlossaryHeader component', () => { }); it('should not render import and export dropdown menu items if no permission', async () => { - mockGlossaryTermPermission.All = false; - mockGlossaryTermPermission.EditAll = false; + glossaryTermPermission.All = false; + glossaryTermPermission.EditAll = false; render( ); @@ -233,15 +233,15 @@ describe('GlossaryHeader component', () => { }); it('should render changeParentHierarchy and style dropdown menu items only for glossaryTerm', async () => { - mockContext.type = EntityType.GLOSSARY_TERM; - mockContext.permissions = { ...DEFAULT_ENTITY_PERMISSION, EditAll: true }; - mockGlossaryTermPermission.All = true; - mockGlossaryTermPermission.EditAll = true; render( ); @@ -259,9 +259,13 @@ describe('GlossaryHeader component', () => { it('should not render ChangeParentHierarchy component when it is close', () => { render( ); @@ -273,9 +277,13 @@ describe('GlossaryHeader component', () => { it('should render ChangeParentHierarchy component after clicking dropdown menu item', async () => { render( ); @@ -299,9 +307,13 @@ describe('GlossaryHeader component', () => { it('should not render ChangeParentHierarchy component after onCancel call', async () => { render( ); @@ -329,9 +341,13 @@ describe('GlossaryHeader component', () => { it('should call onSubmit of ChangeParentHierarchy Component along with the patch API', async () => { render( ); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryHeader/GlossaryHeaderWidget.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryHeader/GlossaryHeaderWidget.tsx deleted file mode 100644 index aad1cdb8ed67..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryHeader/GlossaryHeaderWidget.tsx +++ /dev/null @@ -1,63 +0,0 @@ -/* - * 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 React, { useMemo } from 'react'; -import { ReactComponent as IconTerm } from '../../../assets/svg/book.svg'; -import { ReactComponent as GlossaryIcon } from '../../../assets/svg/glossary.svg'; -import { DE_ACTIVE_COLOR } from '../../../constants/constants'; -import { EntityType } from '../../../enums/entity.enum'; -import { EntityHeader } from '../../Entity/EntityHeader/EntityHeader.component'; - -export const GlossaryHeaderWidget = ({ - isGlossary = true, -}: { - isGlossary?: boolean; -}) => { - const icon = useMemo(() => { - if (isGlossary) { - return ( - - ); - } - - return ( - - ); - }, [isGlossary]); - - return ( -
- -
- ); -}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/GlossaryTermsV1.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/GlossaryTermsV1.component.tsx index 5d31387fcb2f..fddce22633e6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/GlossaryTermsV1.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/GlossaryTermsV1.component.tsx @@ -22,33 +22,23 @@ import React, { useState, } from 'react'; import { useHistory, useParams } from 'react-router-dom'; -import { FQN_SEPARATOR_CHAR } from '../../../constants/char.constants'; import { getGlossaryTermDetailsPath } from '../../../constants/constants'; import { FEED_COUNT_INITIAL_DATA } from '../../../constants/entity.constants'; import { EntityField } from '../../../constants/Feeds.constants'; import { EntityTabs, EntityType } from '../../../enums/entity.enum'; import { SearchIndex } from '../../../enums/search.enum'; -import { - ChangeDescription, - Glossary, -} from '../../../generated/entity/data/glossary'; +import { Glossary } from '../../../generated/entity/data/glossary'; import { GlossaryTerm, Status, } from '../../../generated/entity/data/glossaryTerm'; -import { Page, PageType } from '../../../generated/system/ui/page'; -import { useApplicationStore } from '../../../hooks/useApplicationStore'; +import { ChangeDescription } from '../../../generated/entity/type'; import { useFqn } from '../../../hooks/useFqn'; import { FeedCounts } from '../../../interface/feed.interface'; import { MOCK_GLOSSARY_NO_PERMISSIONS } from '../../../mocks/Glossary.mock'; -import { getDocumentByFQN } from '../../../rest/DocStoreAPI'; import { searchData } from '../../../rest/miscAPI'; import { getCountBadge, getFeedCounts } from '../../../utils/CommonUtils'; import { getEntityVersionByField } from '../../../utils/EntityVersionUtils'; -import { - getGlossaryTermDetailTabs, - getTabLabelMap, -} from '../../../utils/GlossaryTerm/GlossaryTermUtil'; import { getQueryFilterToExcludeTerm } from '../../../utils/GlossaryUtils'; import { getGlossaryTermsVersionsPath } from '../../../utils/RouterUtils'; import { @@ -59,7 +49,7 @@ import { ActivityFeedTab } from '../../ActivityFeed/ActivityFeedTab/ActivityFeed import { CustomPropertyTable } from '../../common/CustomPropertyTable/CustomPropertyTable'; import TabsLabel from '../../common/TabsLabel/TabsLabel.component'; import { AssetSelectionModal } from '../../DataAssets/AssetsSelectionModal/AssetSelectionModal'; -import { GenericProvider } from '../../GenericProvider/GenericProvider'; +import { GlossaryTabs } from '../GlossaryDetails/GlossaryDetails.interface'; import GlossaryHeader from '../GlossaryHeader/GlossaryHeader.component'; import GlossaryTermTab from '../GlossaryTermTab/GlossaryTermTab.component'; import { useGlossaryStore } from '../useGlossary.store'; @@ -92,11 +82,9 @@ const GlossaryTermsV1 = ({ const [feedCount, setFeedCount] = useState( FEED_COUNT_INITIAL_DATA ); - const { selectedPersona } = useApplicationStore(); const [assetCount, setAssetCount] = useState(0); const { glossaryChildTerms } = useGlossaryStore(); const childGlossaryTerms = glossaryChildTerms ?? []; - const [customizedPage, setCustomizedPage] = useState(null); const assetPermissions = useMemo(() => { const glossaryTermStatus = glossaryTerm.status ?? Status.Approved; @@ -107,7 +95,7 @@ const GlossaryTermsV1 = ({ }, [glossaryTerm, permissions]); const activeTab = useMemo(() => { - return tab ?? EntityTabs.OVERVIEW; + return tab ?? 'overview'; }, [tab]); const activeTabHandler = (tab: string) => { @@ -179,24 +167,23 @@ const GlossaryTermsV1 = ({ }; const tabItems = useMemo(() => { - const tabLabelMap = getTabLabelMap(customizedPage?.tabs); - const items = [ { - label: ( -
- {tabLabelMap[EntityTabs.OVERVIEW] ?? t('label.overview')} -
- ), - key: EntityTabs.OVERVIEW, + label:
{t('label.overview')}
, + key: 'overview', children: ( ), }, @@ -205,8 +192,7 @@ const GlossaryTermsV1 = ({ { label: (
- {tabLabelMap[EntityTabs.GLOSSARY_TERMS] ?? - t('label.glossary-term-plural')} + {t('label.glossary-term-plural')} {getCountBadge( childGlossaryTerms.length, @@ -216,7 +202,7 @@ const GlossaryTermsV1 = ({
), - key: EntityTabs.GLOSSARY_TERMS, + key: 'terms', children: ( - {tabLabelMap[EntityTabs.ASSETS] ?? t('label.asset-plural')} + {t('label.asset-plural')} {getCountBadge(assetCount ?? 0, '', activeTab === 'assets')} ), - key: EntityTabs.ASSETS, + key: 'assets', children: ( ), - key: EntityTabs.ACTIVITY_FEED, + key: GlossaryTabs.ACTIVITY_FEED, children: ( ), key: EntityTabs.CUSTOM_PROPERTIES, @@ -307,9 +287,8 @@ const GlossaryTermsV1 = ({ : []), ]; - return getGlossaryTermDetailTabs(items, customizedPage?.tabs); + return items; }, [ - customizedPage?.tabs, glossaryTerm, permissions, termsLoading, @@ -332,62 +311,46 @@ const GlossaryTermsV1 = ({ getEntityFeedCount(); }, [glossaryFqn]); - const fetchDocument = useCallback(async () => { - const pageFQN = `${EntityType.PERSONA}${FQN_SEPARATOR_CHAR}${selectedPersona.fullyQualifiedName}`; - try { - const doc = await getDocumentByFQN(pageFQN); - setCustomizedPage( - doc.data?.pages?.find((p: Page) => p.pageType === PageType.GlossaryTerm) - ); - } catch (error) { - // fail silent - } - }, [selectedPersona.fullyQualifiedName]); - - useEffect(() => { - if (selectedPersona?.fullyQualifiedName) { - fetchDocument(); - } - }, [selectedPersona]); + const name = useMemo( + () => + isVersionView + ? getEntityVersionByField( + glossaryTerm.changeDescription as ChangeDescription, + EntityField.NAME, + glossaryTerm.name + ) + : glossaryTerm.name, - const updatedGlossaryTerm = useMemo(() => { - const name = isVersionView - ? getEntityVersionByField( - glossaryTerm.changeDescription as ChangeDescription, - EntityField.NAME, - glossaryTerm.name - ) - : glossaryTerm.name; + [glossaryTerm, isVersionView] + ); - const displayName = isVersionView - ? getEntityVersionByField( - glossaryTerm.changeDescription as ChangeDescription, - EntityField.DISPLAYNAME, - glossaryTerm.displayName - ) - : glossaryTerm.displayName; + const displayName = useMemo( + () => + isVersionView + ? getEntityVersionByField( + glossaryTerm.changeDescription as ChangeDescription, + EntityField.DISPLAYNAME, + glossaryTerm.displayName + ) + : glossaryTerm.displayName, - return { - ...glossaryTerm, - name, - displayName, - }; - }, [glossaryTerm, isVersionView]); + [glossaryTerm, isVersionView] + ); return ( - + <> setAssetModalVisible(true)} onDelete={handleGlossaryTermDelete} + onUpdate={onTermUpdate} /> @@ -413,7 +376,7 @@ const GlossaryTermsV1 = ({ onSave={handleAssetSave} /> )} - + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/GlossaryTermsV1.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/GlossaryTermsV1.test.tsx index d4be445a1349..f75af90044ab 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/GlossaryTermsV1.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/GlossaryTermsV1.test.tsx @@ -11,7 +11,7 @@ * limitations under the License. */ -import { render, screen } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import React from 'react'; import { OperationPermission } from '../../../context/PermissionProvider/PermissionProvider.interface'; import { @@ -44,6 +44,15 @@ jest.mock('../../../rest/miscAPI', () => ({ .mockImplementation(() => Promise.resolve(MOCK_ASSETS_DATA)), })); +jest.mock('./tabs/RelatedTerms', () => + jest.fn().mockReturnValue(
RelatedTermsComponent
) +); +jest.mock('./tabs/GlossaryTermSynonyms', () => + jest.fn().mockReturnValue(
GlossaryTermSynonymsComponent
) +); +jest.mock('./tabs/GlossaryTermReferences', () => + jest.fn().mockReturnValue(
GlossaryTermReferencesComponent
) +); jest.mock('./tabs/AssetsTabs.component', () => jest.fn().mockReturnValue(
AssetsTabs
) ); @@ -53,9 +62,6 @@ jest.mock('../GlossaryTermTab/GlossaryTermTab.component', () => jest.mock('../GlossaryHeader/GlossaryHeader.component', () => jest.fn().mockReturnValue(
GlossaryHeader.component
) ); -jest.mock('./tabs/GlossaryOverviewTab.component', () => - jest.fn().mockReturnValue(
GlossaryOverviewTab.component
) -); const mockProps = { isSummaryPanelOpen: false, @@ -80,15 +86,13 @@ const mockProps = { onThreadLinkSelect: jest.fn(), }; -jest.mock('../../../utils/GlossaryTerm/GlossaryTermUtil', () => ({ - getGlossaryTermDetailTabs: jest.fn().mockImplementation((itemes) => itemes), - getTabLabelMap: jest.fn().mockReturnValue({}), -})); - -describe.skip('Test Glossary-term component', () => { +describe('Test Glossary-term component', () => { it('Should render Glossary-term component', async () => { render(); + act(() => { + jest.runAllTimers(); + }); const glossaryTerm = screen.getByTestId('glossary-term'); const tabs = await screen.findAllByRole('tab'); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/GlossaryOverviewTab.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/GlossaryOverviewTab.component.tsx index e9fb9bc11c8d..4a85cb9f81e2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/GlossaryOverviewTab.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/GlossaryOverviewTab.component.tsx @@ -10,85 +10,55 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { noop } from 'lodash'; +import { Col, Row, Space } from 'antd'; import React, { useMemo, useState } from 'react'; -import RGL, { WidthProvider } from 'react-grid-layout'; -import { useParams } from 'react-router-dom'; import { EntityField } from '../../../../constants/Feeds.constants'; -import { GlossaryTermDetailPageWidgetKeys } from '../../../../enums/CustomizeDetailPage.enum'; -import { EntityTabs, EntityType } from '../../../../enums/entity.enum'; +import { COMMON_RESIZABLE_PANEL_CONFIG } from '../../../../constants/ResizablePanel.constants'; +import { OperationPermission } from '../../../../context/PermissionProvider/PermissionProvider.interface'; +import { EntityType } from '../../../../enums/entity.enum'; import { Glossary } from '../../../../generated/entity/data/glossary'; import { GlossaryTerm } from '../../../../generated/entity/data/glossaryTerm'; import { ChangeDescription } from '../../../../generated/entity/type'; -import { Page, PageType, Tab } from '../../../../generated/system/ui/page'; import { TagLabel, TagSource } from '../../../../generated/type/tagLabel'; -import { useGridLayoutDirection } from '../../../../hooks/useGridLayoutDirection'; -import { WidgetConfig } from '../../../../pages/CustomizablePage/CustomizablePage.interface'; -import { useCustomizeStore } from '../../../../pages/CustomizablePage/CustomizeStore'; -import customizeGlossaryTermPageClassBase from '../../../../utils/CustomiseGlossaryTermPage/CustomizeGlossaryTermPage'; import { getEntityName } from '../../../../utils/EntityUtils'; import { getEntityVersionByField, getEntityVersionTags, } from '../../../../utils/EntityVersionUtils'; -import { getWidgetFromKey } from '../../../../utils/GlossaryTerm/GlossaryTermUtil'; -import { CustomPropertyTable } from '../../../common/CustomPropertyTable/CustomPropertyTable'; -import { DomainLabel } from '../../../common/DomainLabel/DomainLabel.component'; import DescriptionV1 from '../../../common/EntityDescription/DescriptionV1'; -import { OwnerLabelV2 } from '../../../DataAssets/OwnerLabelV2/OwnerLabelV2'; -import { ReviewerLabelV2 } from '../../../DataAssets/ReviewerLabelV2/ReviewerLabelV2'; -import { useGenericContext } from '../../../GenericProvider/GenericProvider'; +import ResizablePanels from '../../../common/ResizablePanels/ResizablePanels'; import TagsContainerV2 from '../../../Tag/TagsContainerV2/TagsContainerV2'; import { DisplayType } from '../../../Tag/TagsViewer/TagsViewer.interface'; +import GlossaryDetailsRightPanel from '../../GlossaryDetailsRightPanel/GlossaryDetailsRightPanel.component'; import { GlossaryUpdateConfirmationModal } from '../../GlossaryUpdateConfirmationModal/GlossaryUpdateConfirmationModal'; import GlossaryTermReferences from './GlossaryTermReferences'; import GlossaryTermSynonyms from './GlossaryTermSynonyms'; import RelatedTerms from './RelatedTerms'; -const ReactGridLayout = WidthProvider(RGL); - type Props = { + selectedData: Glossary | GlossaryTerm; + permissions: OperationPermission; + onUpdate: (data: GlossaryTerm | Glossary) => Promise; + isGlossary: boolean; + isVersionView?: boolean; onThreadLinkSelect: (value: string) => void; editCustomAttributePermission: boolean; onExtensionUpdate: (updatedTable: GlossaryTerm) => Promise; }; const GlossaryOverviewTab = ({ + selectedData, + permissions, + onUpdate, + isGlossary, + isVersionView, onThreadLinkSelect, editCustomAttributePermission, onExtensionUpdate, }: Props) => { const [isDescriptionEditable, setIsDescriptionEditable] = useState(false); - const [tagsUpdating, setTagsUpdating] = useState(); - const { currentPersonaDocStore } = useCustomizeStore(); - // Since we are rendering this component for all customized tabs we need tab ID to get layout form store - const { tab = EntityTabs.OVERVIEW } = useParams<{ tab: EntityTabs }>(); - const { - data: selectedData, - permissions, - onUpdate, - isVersionView, - type: entityType, - } = useGenericContext(); - - const isGlossary = entityType === EntityType.GLOSSARY; - - const layout = useMemo(() => { - if (!currentPersonaDocStore) { - return customizeGlossaryTermPageClassBase.getDefaultWidgetForTab(tab); - } - const pageType = isGlossary ? PageType.Glossary : PageType.GlossaryTerm; - const page = currentPersonaDocStore?.data?.pages.find( - (p: Page) => p.pageType === pageType - ); - - if (page) { - return page.tabs.find((t: Tab) => t.id === tab)?.layout; - } else { - return customizeGlossaryTermPageClassBase.getDefaultWidgetForTab(tab); - } - }, [currentPersonaDocStore, isGlossary, tab]); + const [tagsUpdatating, setTagsUpdating] = useState(); const onDescriptionUpdate = async (updatedHTML: string) => { if (selectedData.description !== updatedHTML) { @@ -107,10 +77,6 @@ const GlossaryOverviewTab = ({ return permissions.EditAll || permissions.EditTags; }, [permissions]); - const hasViewAllPermission = useMemo(() => { - return permissions.ViewAll; - }, [permissions]); - const glossaryDescription = useMemo(() => { if (isVersionView) { return getEntityVersionByField( @@ -142,191 +108,118 @@ const GlossaryOverviewTab = ({ if (selectedData) { await onUpdate({ ...selectedData, - tags: tagsUpdating, + tags: tagsUpdatating, }); } }; - const descriptionWidget = useMemo(() => { - return ( - setIsDescriptionEditable(false)} - onDescriptionEdit={() => setIsDescriptionEditable(true)} - onDescriptionUpdate={onDescriptionUpdate} - onThreadLinkSelect={onThreadLinkSelect} - /> - ); - }, [ - glossaryDescription, - isDescriptionEditable, - selectedData, - onDescriptionUpdate, - onThreadLinkSelect, - permissions, - ]); - - const tagsWidget = useMemo(() => { - return ( - - ); - }, [ - tags, - selectedData.fullyQualifiedName, - hasEditTagsPermissions, - onThreadLinkSelect, - ]); - - const domainWidget = useMemo(() => { - return ( - - ); - }, [ - selectedData.domain, - selectedData.fullyQualifiedName, - selectedData.id, - permissions.EditAll, - isGlossary, - ]); - - const customPropertyWidget = useMemo(() => { - return ( - { - await onExtensionUpdate?.(updatedTable); - }} - hasEditAccess={Boolean(editCustomAttributePermission)} - hasPermission={hasViewAllPermission} - maxDataCap={5} - /> - ); - }, [selectedData, editCustomAttributePermission, hasViewAllPermission]); - - const widgets = useMemo(() => { - const getWidgetFromKeyInternal = (widgetConfig: WidgetConfig) => { - if ( - widgetConfig.i.startsWith( - GlossaryTermDetailPageWidgetKeys.RELATED_TERMS - ) - ) { - return ; - } else if ( - widgetConfig.i.startsWith(GlossaryTermDetailPageWidgetKeys.SYNONYMS) - ) { - return ; - } else if ( - widgetConfig.i.startsWith(GlossaryTermDetailPageWidgetKeys.TAGS) - ) { - return tagsWidget; - } else if ( - widgetConfig.i.startsWith(GlossaryTermDetailPageWidgetKeys.REFERENCES) - ) { - return ; - } else if ( - widgetConfig.i.startsWith(GlossaryTermDetailPageWidgetKeys.DESCRIPTION) - ) { - return descriptionWidget; - } else if ( - widgetConfig.i.startsWith(GlossaryTermDetailPageWidgetKeys.OWNER) - ) { - return ; - } else if ( - widgetConfig.i.startsWith(GlossaryTermDetailPageWidgetKeys.DOMAIN) - ) { - return domainWidget; - } else if ( - widgetConfig.i.startsWith(GlossaryTermDetailPageWidgetKeys.REVIEWER) - ) { - return ; - } else if ( - widgetConfig.i.startsWith( - GlossaryTermDetailPageWidgetKeys.CUSTOM_PROPERTIES - ) && - !isGlossary - ) { - return customPropertyWidget; - } - - return getWidgetFromKey({ - widgetConfig: widgetConfig, - handleOpenAddWidgetModal: noop, - handlePlaceholderWidgetKey: noop, - handleRemoveWidget: noop, - isEditView: false, - }); - }; - - return layout.map((widget: WidgetConfig) => ( -
- {getWidgetFromKeyInternal(widget)} -
- )); - }, [ - layout, - descriptionWidget, - tagsWidget, - domainWidget, - customPropertyWidget, - isGlossary, - ]); - - // call the hook to set the direction of the grid layout - useGridLayoutDirection(); - return ( - <> - - {widgets} - - {tagsUpdating && ( + + + + + + setIsDescriptionEditable(false)} + onDescriptionEdit={() => setIsDescriptionEditable(true)} + onDescriptionUpdate={onDescriptionUpdate} + onThreadLinkSelect={onThreadLinkSelect} + /> + + + + {!isGlossary && ( + <> + + + + + + + + + + + )} + + + + + + + + + + + ), + ...COMMON_RESIZABLE_PANEL_CONFIG.LEFT_PANEL, + }} + secondPanel={{ + children: ( + + ), + ...COMMON_RESIZABLE_PANEL_CONFIG.RIGHT_PANEL, + className: 'entity-resizable-right-panel-container', + }} + /> + + + {tagsUpdatating && ( setTagsUpdating(undefined)} onValidationSuccess={handleGlossaryTagUpdateValidationConfirm} /> )} - + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/GlossaryOverviewTab.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/GlossaryOverviewTab.test.tsx index b408ae8fafcd..67b12b7682ab 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/GlossaryOverviewTab.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/GlossaryOverviewTab.test.tsx @@ -13,6 +13,10 @@ import { act, render } from '@testing-library/react'; import React from 'react'; import { MemoryRouter } from 'react-router-dom'; +import { + MOCKED_GLOSSARY_TERMS, + MOCK_PERMISSIONS, +} from '../../../../mocks/Glossary.mock'; import GlossaryOverviewTab from './GlossaryOverviewTab.component'; jest.mock('./GlossaryTermSynonyms', () => { @@ -29,6 +33,13 @@ jest.mock('../../../common/EntityDescription/DescriptionV1', () => { return jest.fn().mockReturnValue(

Description

); }); +jest.mock( + '../../GlossaryDetailsRightPanel/GlossaryDetailsRightPanel.component', + () => { + return jest.fn().mockImplementation(() => <>testGlossaryRightPanel); + } +); + jest.mock('../../../common/ResizablePanels/ResizablePanels', () => { return jest.fn().mockImplementation(({ firstPanel, secondPanel }) => (
@@ -37,13 +48,22 @@ jest.mock('../../../common/ResizablePanels/ResizablePanels', () => { )); }); -describe.skip('GlossaryOverviewTab', () => { +const onUpdate = jest.fn(); + +describe('GlossaryOverviewTab', () => { + const selectedData = MOCKED_GLOSSARY_TERMS[0]; + const permissions = MOCK_PERMISSIONS; + it('renders the component', async () => { const { findByText } = render( , { wrapper: MemoryRouter } ); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/GlossaryTermReferences.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/GlossaryTermReferences.test.tsx index 96517ebdfbd1..a38bbdb3af6d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/GlossaryTermReferences.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/GlossaryTermReferences.test.tsx @@ -18,23 +18,19 @@ import { } from '../../../../mocks/Glossary.mock'; import GlossaryTermReferences from './GlossaryTermReferences'; -const [mockGlossaryTerm1, mockGlossaryTerm2] = MOCKED_GLOSSARY_TERMS; - -const mockContext = { - data: mockGlossaryTerm1, - onUpdate: jest.fn(), - isVersionView: false, - permissions: MOCK_PERMISSIONS, -}; - -jest.mock('../../../GenericProvider/GenericProvider', () => ({ - useGenericContext: jest.fn().mockImplementation(() => mockContext), -})); +const mockOnGlossaryTermUpdate = jest.fn(); describe('GlossaryTermReferences', () => { it('renders glossary term references', async () => { - mockContext.data = mockGlossaryTerm2; - const { getByText, getByTestId } = render(); + const mockGlossaryTerm = MOCKED_GLOSSARY_TERMS[1]; + const mockPermissions = MOCK_PERMISSIONS; + const { getByText, getByTestId } = render( + + ); const sectionTitle = getByTestId('section-label.reference-plural'); const editBtn = getByTestId('edit-button'); @@ -57,18 +53,29 @@ describe('GlossaryTermReferences', () => { }); it('renders add button', async () => { - mockContext.data = mockGlossaryTerm1; - - const { getByTestId } = render(); + const mockGlossaryTerm = MOCKED_GLOSSARY_TERMS[0]; + const mockPermissions = MOCK_PERMISSIONS; + const { getByTestId } = render( + + ); expect(getByTestId('term-references-add-button')).toBeInTheDocument(); }); it('should not render add button if no permission', async () => { - mockContext.data = mockGlossaryTerm1; - mockContext.permissions = { ...MOCK_PERMISSIONS, EditAll: false }; - - const { queryByTestId, findByText } = render(); + const mockGlossaryTerm = MOCKED_GLOSSARY_TERMS[0]; + const mockPermissions = { ...MOCK_PERMISSIONS, EditAll: false }; + const { queryByTestId, findByText } = render( + + ); expect(queryByTestId('term-references-add-button')).toBeNull(); @@ -78,7 +85,15 @@ describe('GlossaryTermReferences', () => { }); it('should not render edit button if no permission', async () => { - const { queryByTestId } = render(); + const mockGlossaryTerm = MOCKED_GLOSSARY_TERMS[1]; + const mockPermissions = { ...MOCK_PERMISSIONS, EditAll: false }; + const { queryByTestId } = render( + + ); expect(queryByTestId('edit-button')).toBeNull(); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/GlossaryTermReferences.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/GlossaryTermReferences.tsx index a72a5a1a5a92..8f0dff3c8b71 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/GlossaryTermReferences.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/GlossaryTermReferences.tsx @@ -11,18 +11,26 @@ * limitations under the License. */ -import { Button, Space, Tooltip, Typography } from 'antd'; +import Icon from '@ant-design/icons/lib/components/Icon'; +import { Button, Space, Tag, Tooltip, Typography } from 'antd'; +import classNames from 'classnames'; import { t } from 'i18next'; import { cloneDeep, isEmpty, isEqual } from 'lodash'; import React, { useCallback, useEffect, useState } from 'react'; import { ReactComponent as EditIcon } from '../../../../assets/svg/edit-new.svg'; +import { ReactComponent as ExternalLinkIcon } from '../../../../assets/svg/external-links.svg'; import { ReactComponent as PlusIcon } from '../../../../assets/svg/plus-primary.svg'; import { DE_ACTIVE_COLOR, + ICON_DIMENSION, NO_DATA_PLACEHOLDER, + SUCCESS_COLOR, + TEXT_BODY_COLOR, + TEXT_GREY_MUTED, } from '../../../../constants/constants'; import { EntityField } from '../../../../constants/Feeds.constants'; import { NO_PERMISSION_FOR_ACTION } from '../../../../constants/HelperTextUtil'; +import { OperationPermission } from '../../../../context/PermissionProvider/PermissionProvider.interface'; import { GlossaryTerm, TermReference, @@ -33,20 +41,25 @@ import { getChangedEntityOldValue, getDiffByFieldName, } from '../../../../utils/EntityVersionUtils'; -import { renderReferenceElement } from '../../../../utils/GlossaryUtils'; +import { VersionStatus } from '../../../../utils/EntityVersionUtils.interface'; import TagButton from '../../../common/TagButton/TagButton.component'; -import { useGenericContext } from '../../../GenericProvider/GenericProvider'; import GlossaryTermReferencesModal from '../GlossaryTermReferencesModal.component'; -const GlossaryTermReferences = () => { +interface GlossaryTermReferencesProps { + isVersionView?: boolean; + glossaryTerm: GlossaryTerm; + permissions: OperationPermission; + onGlossaryTermUpdate: (glossaryTerm: GlossaryTerm) => Promise; +} + +const GlossaryTermReferences = ({ + glossaryTerm, + permissions, + onGlossaryTermUpdate, + isVersionView, +}: GlossaryTermReferencesProps) => { const [references, setReferences] = useState([]); const [isViewMode, setIsViewMode] = useState(true); - const { - data: glossaryTerm, - onUpdate: onGlossaryTermUpdate, - isVersionView, - permissions, - } = useGenericContext(); const handleReferencesSave = async ( newReferences: TermReference[], @@ -78,6 +91,52 @@ const GlossaryTermReferences = () => { setReferences(glossaryTerm.references ? glossaryTerm.references : []); }, [glossaryTerm.references]); + const getReferenceElement = useCallback( + (ref: TermReference, versionStatus?: VersionStatus) => { + let iconColor: string; + let textClassName: string; + if (versionStatus?.added) { + iconColor = SUCCESS_COLOR; + textClassName = 'text-success'; + } else if (versionStatus?.removed) { + iconColor = TEXT_GREY_MUTED; + textClassName = 'text-grey-muted'; + } else { + iconColor = TEXT_BODY_COLOR; + textClassName = 'text-body'; + } + + return ( + + + +
+ + {ref.name} +
+
+
+
+ ); + }, + [] + ); + const getVersionReferenceElements = useCallback(() => { const changeDescription = glossaryTerm.changeDescription; const referencesDiff = getDiffByFieldName( @@ -113,14 +172,12 @@ const GlossaryTermReferences = () => { return (
- {unchangedReferences.map((reference) => - renderReferenceElement(reference) - )} + {unchangedReferences.map((reference) => getReferenceElement(reference))} {addedReferences.map((reference) => - renderReferenceElement(reference, { added: true }) + getReferenceElement(reference, { added: true }) )} {deletedReferences.map((reference) => - renderReferenceElement(reference, { removed: true }) + getReferenceElement(reference, { removed: true }) )}
); @@ -163,7 +220,7 @@ const GlossaryTermReferences = () => { getVersionReferenceElements() ) : (
- {references.map((ref) => renderReferenceElement(ref))} + {references.map((ref) => getReferenceElement(ref))} {permissions.EditAll && references.length === 0 && ( ({ - useGenericContext: jest.fn().mockImplementation(() => mockContext), -})); +const onGlossaryTermUpdate = jest.fn(); describe('GlossaryTermSynonyms', () => { it('renders synonyms and edit button', () => { - mockContext.data = mockGlossaryTerm2; - const { getByTestId, getByText } = render(); + const glossaryTerm = MOCKED_GLOSSARY_TERMS[1]; + const permissions = MOCK_PERMISSIONS; + const { getByTestId, getByText } = render( + + ); const synonymsContainer = getByTestId('synonyms-container'); const synonymItem = getByText('accessory'); const editBtn = getByTestId('edit-button'); @@ -45,8 +41,15 @@ describe('GlossaryTermSynonyms', () => { }); it('renders add button', () => { - mockContext.data = mockGlossaryTerm1; - const { getByTestId } = render(); + const glossaryTerm = MOCKED_GLOSSARY_TERMS[0]; + const permissions = MOCK_PERMISSIONS; + const { getByTestId } = render( + + ); const synonymsContainer = getByTestId('synonyms-container'); const synonymAddBtn = getByTestId('synonym-add-button'); @@ -55,10 +58,14 @@ describe('GlossaryTermSynonyms', () => { }); it('should not render add button if no permission', async () => { - mockContext.data = mockGlossaryTerm1; - mockContext.permissions = { ...MOCK_PERMISSIONS, EditAll: false }; + const glossaryTerm = MOCKED_GLOSSARY_TERMS[0]; + const permissions = { ...MOCK_PERMISSIONS, EditAll: false }; const { getByTestId, queryByTestId, findByText } = render( - + ); const synonymsContainer = getByTestId('synonyms-container'); const synonymAddBtn = queryByTestId('synonym-add-button'); @@ -72,9 +79,15 @@ describe('GlossaryTermSynonyms', () => { }); it('should not render edit button if no permission', () => { - mockContext.data = mockGlossaryTerm2; - mockContext.permissions = { ...MOCK_PERMISSIONS, EditAll: false }; - const { getByTestId, queryByTestId } = render(); + const glossaryTerm = MOCKED_GLOSSARY_TERMS[1]; + const permissions = { ...MOCK_PERMISSIONS, EditAll: false }; + const { getByTestId, queryByTestId } = render( + + ); const synonymsContainer = getByTestId('synonyms-container'); const editBtn = queryByTestId('edit-button'); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/GlossaryTermSynonyms.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/GlossaryTermSynonyms.tsx index a1fb34d9a824..e66fdc58c635 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/GlossaryTermSynonyms.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/GlossaryTermSynonyms.tsx @@ -24,6 +24,7 @@ import { } from '../../../../constants/constants'; import { EntityField } from '../../../../constants/Feeds.constants'; import { NO_PERMISSION_FOR_ACTION } from '../../../../constants/HelperTextUtil'; +import { OperationPermission } from '../../../../context/PermissionProvider/PermissionProvider.interface'; import { GlossaryTerm } from '../../../../generated/entity/data/glossaryTerm'; import { ChangeDescription } from '../../../../generated/entity/type'; import { @@ -32,18 +33,23 @@ import { getDiffByFieldName, } from '../../../../utils/EntityVersionUtils'; import TagButton from '../../../common/TagButton/TagButton.component'; -import { useGenericContext } from '../../../GenericProvider/GenericProvider'; -const GlossaryTermSynonyms = () => { +interface GlossaryTermSynonymsProps { + isVersionView?: boolean; + permissions: OperationPermission; + glossaryTerm: GlossaryTerm; + onGlossaryTermUpdate: (glossaryTerm: GlossaryTerm) => Promise; +} + +const GlossaryTermSynonyms = ({ + permissions, + glossaryTerm, + onGlossaryTermUpdate, + isVersionView, +}: GlossaryTermSynonymsProps) => { const [isViewMode, setIsViewMode] = useState(true); const [synonyms, setSynonyms] = useState([]); const [saving, setSaving] = useState(false); - const { - data: glossaryTerm, - onUpdate: onGlossaryTermUpdate, - isVersionView, - permissions, - } = useGenericContext(); const getSynonyms = () => (
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/RelatedTerms.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/RelatedTerms.test.tsx index 02efd7c68c5e..23a3481a6a89 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/RelatedTerms.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/RelatedTerms.test.tsx @@ -18,40 +18,57 @@ import { } from '../../../../mocks/Glossary.mock'; import RelatedTerms from './RelatedTerms'; -const mockContext = { - data: MOCKED_GLOSSARY_TERMS[2], - onUpdate: jest.fn(), - isVersionView: false, - permissions: MOCK_PERMISSIONS, -}; +const glossaryTerm = MOCKED_GLOSSARY_TERMS[2]; -jest.mock('../../../GenericProvider/GenericProvider', () => ({ - useGenericContext: jest.fn().mockImplementation(() => mockContext), -})); +const permissions = MOCK_PERMISSIONS; + +const onGlossaryTermUpdate = jest.fn(); describe('RelatedTerms', () => { it('should render the component', () => { - const { container } = render(); + const { container } = render( + + ); expect(container).toBeInTheDocument(); }); it('should show the related terms', () => { - const { getByText } = render(); + const { getByText } = render( + + ); expect(getByText('Business Customer')).toBeInTheDocument(); }); it('should show the add button if there are no related terms and the user has edit permissions', () => { - mockContext.data = { ...mockContext.data, relatedTerms: [] }; - const { getByTestId } = render(); + const { getByTestId } = render( + + ); expect(getByTestId('related-term-add-button')).toBeInTheDocument(); }); it('should not show the add button if there are no related terms and the user does not have edit permissions', async () => { - mockContext.permissions = { ...mockContext.permissions, EditAll: false }; - const { queryByTestId, findByText } = render(); + const { queryByTestId, findByText } = render( + + ); expect(queryByTestId('related-term-add-button')).toBeNull(); @@ -61,16 +78,25 @@ describe('RelatedTerms', () => { }); it('should show the edit button if there are related terms and the user has edit permissions', () => { - mockContext.permissions = MOCK_PERMISSIONS; - mockContext.data = { ...MOCKED_GLOSSARY_TERMS[2] }; - const { getByTestId } = render(); + const { getByTestId } = render( + + ); expect(getByTestId('edit-button')).toBeInTheDocument(); }); it('should not show the edit button if there are no related terms and the user has edit permissions', () => { - mockContext.data = { ...MOCKED_GLOSSARY_TERMS[2], relatedTerms: [] }; - const { queryByTestId } = render(); + const { queryByTestId } = render( + + ); expect(queryByTestId('edit-button')).toBeNull(); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/RelatedTerms.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/RelatedTerms.tsx index d9a528bb19e7..d0221548db31 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/RelatedTerms.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/tabs/RelatedTerms.tsx @@ -27,6 +27,7 @@ import { } from '../../../../constants/constants'; import { EntityField } from '../../../../constants/Feeds.constants'; import { NO_PERMISSION_FOR_ACTION } from '../../../../constants/HelperTextUtil'; +import { OperationPermission } from '../../../../context/PermissionProvider/PermissionProvider.interface'; import { EntityType } from '../../../../enums/entity.enum'; import { GlossaryTerm } from '../../../../generated/entity/data/glossaryTerm'; import { @@ -46,17 +47,21 @@ import { VersionStatus } from '../../../../utils/EntityVersionUtils.interface'; import { getGlossaryPath } from '../../../../utils/RouterUtils'; import { SelectOption } from '../../../common/AsyncSelectList/AsyncSelectList.interface'; import TagButton from '../../../common/TagButton/TagButton.component'; -import { useGenericContext } from '../../../GenericProvider/GenericProvider'; -const RelatedTerms = () => { - const history = useHistory(); - const { - data: glossaryTerm, - onUpdate, - isVersionView, - permissions, - } = useGenericContext(); +interface RelatedTermsProps { + isVersionView?: boolean; + permissions: OperationPermission; + glossaryTerm: GlossaryTerm; + onGlossaryTermUpdate: (data: GlossaryTerm) => Promise; +} +const RelatedTerms = ({ + isVersionView, + glossaryTerm, + permissions, + onGlossaryTermUpdate, +}: RelatedTermsProps) => { + const history = useHistory(); const [isIconVisible, setIsIconVisible] = useState(true); const [selectedOption, setSelectedOption] = useState([]); @@ -99,7 +104,7 @@ const RelatedTerms = () => { relatedTerms: newOptions, }; - await onUpdate(updatedGlossaryTerm); + await onGlossaryTermUpdate(updatedGlossaryTerm); setIsIconVisible(true); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.component.tsx index 87ac05380160..c92528e87b93 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.component.tsx @@ -25,12 +25,11 @@ import { OperationPermission, ResourceEntity, } from '../../context/PermissionProvider/PermissionProvider.interface'; -import { EntityAction, EntityTabs } from '../../enums/entity.enum'; +import { EntityAction } from '../../enums/entity.enum'; import { CreateThread, ThreadType, } from '../../generated/api/feed/createThread'; -import { Glossary } from '../../generated/entity/data/glossary'; import { GlossaryTerm } from '../../generated/entity/data/glossaryTerm'; import { VERSION_VIEW_GLOSSARY_PERMISSION } from '../../mocks/Glossary.mock'; import { postThread } from '../../rest/feedsAPI'; @@ -263,7 +262,7 @@ const GlossaryV1 = ({ history.push( getGlossaryTermDetailsPath( selectedData.fullyQualifiedName || '', - EntityTabs.TERMS + 'terms' ) ); } @@ -341,17 +340,6 @@ const GlossaryV1 = ({ } }; - const handleGlossaryUpdate = async (newGlossary: Glossary) => { - const jsonPatch = compare(selectedData, newGlossary); - - const shouldRefreshTerms = jsonPatch.some((patch) => - patch.path.startsWith('/owners') - ); - - await updateGlossary(newGlossary); - shouldRefreshTerms && loadGlossaryTerms(true); - }; - const initPermissions = async () => { setIsPermissionLoading(true); const permissionFetch = isGlossaryActive @@ -393,7 +381,7 @@ const GlossaryV1 = ({ permissions={glossaryPermission} refreshGlossaryTerms={() => loadGlossaryTerms(true)} termsLoading={isTermsLoading} - updateGlossary={handleGlossaryUpdate} + updateGlossary={updateGlossary} updateVote={updateVote} onAddGlossaryTerm={(term) => handleGlossaryTermModalAction(false, term ?? null) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Modals/EntityNameModal/EntityNameModal.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Modals/EntityNameModal/EntityNameModal.component.tsx index ec9fd1069182..c983a2a6abdc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Modals/EntityNameModal/EntityNameModal.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Modals/EntityNameModal/EntityNameModal.component.tsx @@ -15,9 +15,9 @@ import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ENTITY_NAME_REGEX } from '../../../constants/regex.constants'; import SanitizedInput from '../../common/SanitizedInput/SanitizedInput'; -import { EntityName, EntityNameModalProps } from './EntityNameModal.interface'; +import { EntityNameModalProps } from './EntityNameModal.interface'; -const EntityNameModal = ({ +const EntityNameModal: React.FC = ({ visible, entity, onCancel, @@ -28,7 +28,7 @@ const EntityNameModal = ({ allowRename = false, nameValidationRules = [], additionalFields, -}: EntityNameModalProps) => { +}) => { const { t } = useTranslation(); const [form] = Form.useForm(); const [isLoading, setIsLoading] = useState(false); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Modals/EntityNameModal/EntityNameModal.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Modals/EntityNameModal/EntityNameModal.interface.ts index 2106081055e3..9d217278538e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Modals/EntityNameModal/EntityNameModal.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Modals/EntityNameModal/EntityNameModal.interface.ts @@ -19,14 +19,12 @@ export type EntityNameWithAdditionFields = EntityName & { constraint: Constraint; }; -export interface EntityNameModalProps< - T extends { name: string; displayName?: string } -> { +export interface EntityNameModalProps { visible: boolean; allowRename?: boolean; onCancel: () => void; - onSave: (obj: T) => void | Promise; - entity: T; + onSave: (obj: EntityName) => void | Promise; + entity: Partial; title: string; nameValidationRules?: Rule[]; additionalFields?: React.ReactNode; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/AddDetailsPageWidgetModal/AddDetailsPageWidgetModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/AddDetailsPageWidgetModal/AddDetailsPageWidgetModal.tsx deleted file mode 100644 index 0b19ee104540..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/AddDetailsPageWidgetModal/AddDetailsPageWidgetModal.tsx +++ /dev/null @@ -1,147 +0,0 @@ -/* - * Copyright 2023 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 { CheckOutlined } from '@ant-design/icons'; -import { Modal, Space, Tabs, TabsProps } from 'antd'; -import { isEmpty, toString } from 'lodash'; -import { default as React, useCallback, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { LIGHT_GREEN_COLOR } from '../../../../constants/constants'; -import { - CommonWidgetType, - GridSizes, -} from '../../../../constants/CustomizeWidgets.constants'; -import { ERROR_PLACEHOLDER_TYPE } from '../../../../enums/common.enum'; -import { WidgetWidths } from '../../../../enums/CustomizablePage.enum'; -import { Document } from '../../../../generated/entity/docStore/document'; -import { getWidgetWidthLabelFromKey } from '../../../../utils/CustomizableLandingPageUtils'; -import ErrorPlaceHolder from '../../../common/ErrorWithPlaceholder/ErrorPlaceHolder'; -import { WidgetSizeInfo } from '../AddWidgetModal/AddWidgetModal.interface'; -import AddWidgetTabContent from '../AddWidgetModal/AddWidgetTabContent'; - -interface Props { - open: boolean; - maxGridSizeSupport: number; - placeholderWidgetKey: string; - addedWidgetsList: Array; - handleCloseAddWidgetModal: () => void; - handleAddWidget: ( - widget: CommonWidgetType, - widgetKey: string, - widgetSize: number - ) => void; - widgetsList: Array; -} - -function AddDetailsPageWidgetModal({ - open, - widgetsList, - addedWidgetsList, - handleCloseAddWidgetModal, - handleAddWidget, - maxGridSizeSupport, - placeholderWidgetKey, -}: Readonly) { - const { t } = useTranslation(); - - const getAddWidgetHandler = useCallback( - (widget: Document, widgetSize: number) => () => - handleAddWidget( - widget as unknown as CommonWidgetType, - placeholderWidgetKey, - widgetSize - ), - [handleAddWidget, placeholderWidgetKey] - ); - - const tabItems: TabsProps['items'] = useMemo( - () => - widgetsList?.map((widget) => { - const widgetSizeOptions: Array = - widget.data.gridSizes.map((size: GridSizes) => ({ - label: ( - - {getWidgetWidthLabelFromKey(toString(size))} - - ), - value: WidgetWidths[size], - })); - - return { - label: ( - - {widget.name} - {addedWidgetsList.some( - (w) => - w.startsWith(widget.fullyQualifiedName) && - !w.includes('EmptyWidgetPlaceholder') - ) && ( - - )} - - ), - key: widget.fullyQualifiedName, - children: ( - - ), - }; - }), - [widgetsList, addedWidgetsList, getAddWidgetHandler, maxGridSizeSupport] - ); - - const widgetsInfo = useMemo(() => { - if (isEmpty(widgetsList)) { - return ( - - {t('message.no-widgets-to-add')} - - ); - } - - return ( - - ); - }, [widgetsList, tabItems]); - - return ( - - {widgetsInfo} - - ); -} - -export default AddDetailsPageWidgetModal; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/AddWidgetModal/AddWidgetTabContent.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/AddWidgetModal/AddWidgetTabContent.test.tsx index 276df5dba826..22fe5a7a5aa2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/AddWidgetModal/AddWidgetTabContent.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/AddWidgetModal/AddWidgetTabContent.test.tsx @@ -28,7 +28,7 @@ const mockProps: AddWidgetTabContentProps = { widgetSizeOptions: mockWidgetSizes, }; -jest.mock('../../../../utils/CustomizeMyDataPageClassBase', () => ({ +jest.mock('../../../../utils/CustomizePageClassBase', () => ({ getWidgetImageFromKey: jest.fn().mockImplementation(() => ''), })); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/AddWidgetModal/AddWidgetTabContent.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/AddWidgetModal/AddWidgetTabContent.tsx index 50d44a2f95bb..2d02ad294156 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/AddWidgetModal/AddWidgetTabContent.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/AddWidgetModal/AddWidgetTabContent.tsx @@ -25,7 +25,7 @@ import { } from 'antd'; import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import customizePageClassBase from '../../../../utils/CustomizeMyDataPageClassBase'; +import customizePageClassBase from '../../../../utils/CustomizePageClassBase'; import { AddWidgetTabContentProps } from './AddWidgetModal.interface'; function AddWidgetTabContent({ diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomiseGlossaryTermDetailPage/CustomiseGlossaryTermDetailPage.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomiseGlossaryTermDetailPage/CustomiseGlossaryTermDetailPage.tsx deleted file mode 100644 index 772fcfc3174b..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomiseGlossaryTermDetailPage/CustomiseGlossaryTermDetailPage.tsx +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright 2023 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 React, { useCallback, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import gridBgImg from '../../../../assets/img/grid-bg-img.png'; -import { Page } from '../../../../generated/system/ui/page'; -import { useGridLayoutDirection } from '../../../../hooks/useGridLayoutDirection'; -import { WidgetConfig } from '../../../../pages/CustomizablePage/CustomizablePage.interface'; -import { useCustomizeStore } from '../../../../pages/CustomizablePage/CustomizeStore'; -import '../../../../pages/MyDataPage/my-data.less'; -import customizeGlossaryTermPageClassBase from '../../../../utils/CustomiseGlossaryTermPage/CustomizeGlossaryTermPage'; -import { - getLayoutWithEmptyWidgetPlaceholder, - getUniqueFilteredLayout, -} from '../../../../utils/CustomizableLandingPageUtils'; -import { getEntityName } from '../../../../utils/EntityUtils'; -import { CustomizeTabWidget } from '../../../Glossary/CustomiseWidgets/CustomizeTabWidget/CustomizeTabWidget'; -import { GlossaryHeaderWidget } from '../../../Glossary/GlossaryHeader/GlossaryHeaderWidget'; -import PageLayoutV1 from '../../../PageLayoutV1/PageLayoutV1'; -import { CustomizablePageHeader } from '../CustomizablePageHeader/CustomizablePageHeader'; -import { CustomizeMyDataProps } from '../CustomizeMyData/CustomizeMyData.interface'; - -function CustomizeGlossaryTermDetailPage({ - personaDetails, - onSaveLayout, - isGlossary, -}: Readonly) { - const { t } = useTranslation(); - const { currentPage, currentPageType } = useCustomizeStore(); - - const [layout, setLayout] = useState>( - (currentPage?.layout as WidgetConfig[]) ?? - customizeGlossaryTermPageClassBase.defaultLayout - ); - - const handleReset = useCallback(async () => { - // Get default layout with the empty widget added at the end - const newMainPanelLayout = getLayoutWithEmptyWidgetPlaceholder( - customizeGlossaryTermPageClassBase.defaultLayout, - 2, - 4 - ); - setLayout(newMainPanelLayout); - await onSaveLayout(); - }, []); - - const handleSave = async () => { - await onSaveLayout({ - ...(currentPage ?? ({ pageType: currentPageType } as Page)), - layout: getUniqueFilteredLayout(layout), - }); - }; - - // call the hook to set the direction of the grid layout - useGridLayoutDirection(); - - return ( - - - - - - ); -} - -export default CustomizeGlossaryTermDetailPage; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomizablePageHeader/CustomizablePageHeader.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomizablePageHeader/CustomizablePageHeader.tsx deleted file mode 100644 index 2257b40ed277..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomizablePageHeader/CustomizablePageHeader.tsx +++ /dev/null @@ -1,132 +0,0 @@ -/* - * 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 { Button, Col, Modal, Space, Typography } from 'antd'; -import { startCase } from 'lodash'; -import React, { useCallback, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Link, useHistory } from 'react-router-dom'; -import { useApplicationStore } from '../../../../hooks/useApplicationStore'; -import { useFqn } from '../../../../hooks/useFqn'; -import { useCustomizeStore } from '../../../../pages/CustomizablePage/CustomizeStore'; -import { Transi18next } from '../../../../utils/CommonUtils'; -import { getPersonaDetailsPath } from '../../../../utils/RouterUtils'; - -export const CustomizablePageHeader = ({ - onReset, - onSave, - personaName, -}: { - onSave: () => Promise; - onReset: () => void; - personaName: string; -}) => { - const { t } = useTranslation(); - const { fqn: personaFqn } = useFqn(); - const { currentPageType } = useCustomizeStore(); - const history = useHistory(); - const [isResetModalOpen, setIsResetModalOpen] = React.useState(false); - const [saving, setSaving] = React.useState(false); - const { theme } = useApplicationStore(); - - const handleCancel = () => { - history.push(getPersonaDetailsPath(personaFqn)); - }; - - const handleOpenResetModal = useCallback(() => { - setIsResetModalOpen(true); - }, []); - - const handleCloseResetModal = useCallback(() => { - setIsResetModalOpen(false); - }, []); - - const handleSave = useCallback(async () => { - setSaving(true); - await onSave(); - setSaving(false); - }, [onSave]); - - const handleReset = useCallback(async () => { - onReset(); - setIsResetModalOpen(false); - }, [onReset]); - const i18Values = useMemo( - () => ({ - persona: personaName, - pageName: startCase(currentPageType as string) ?? t('label.landing-page'), - }), - [personaName] - ); - - return ( - -
- - - } - values={i18Values} - /> - -
- - - - - - {isResetModalOpen && ( - - {t('message.reset-layout-confirmation')} - - )} - - ); -}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomizeMyData/CustomizeMyData.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomizeMyData/CustomizeMyData.interface.ts index b867f91d0da1..18d402af23a0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomizeMyData/CustomizeMyData.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomizeMyData/CustomizeMyData.interface.ts @@ -11,12 +11,13 @@ * limitations under the License. */ +import { Document } from '../../../../generated/entity/docStore/document'; import { Persona } from '../../../../generated/entity/teams/persona'; -import { Page } from '../../../../generated/system/ui/page'; export interface CustomizeMyDataProps { personaDetails?: Persona; - isGlossary?: boolean; - initialPageData: Page | null; - onSaveLayout: (page?: Page) => Promise; + initialPageData: Document; + onSaveLayout: () => Promise; + handlePageDataChange: (newPageData: Document) => void; + handleSaveCurrentPageLayout: (value: boolean) => void; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomizeMyData/CustomizeMyData.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomizeMyData/CustomizeMyData.test.tsx index 5a42979f77a9..1791575e2ebc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomizeMyData/CustomizeMyData.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomizeMyData/CustomizeMyData.test.tsx @@ -18,18 +18,22 @@ import { PageType } from '../../../../generated/system/ui/page'; import { mockActiveAnnouncementData, mockCustomizePageClassBase, + mockDefaultLayout, mockDocumentData, mockPersonaName, mockUserData, } from '../../../../mocks/MyDataPage.mock'; +import { WidgetConfig } from '../../../../pages/CustomizablePage/CustomizablePage.interface'; import CustomizeMyData from './CustomizeMyData'; import { CustomizeMyDataProps } from './CustomizeMyData.interface'; const mockPush = jest.fn(); const mockProps: CustomizeMyDataProps = { - initialPageData: mockDocumentData.data.pages[0], + initialPageData: mockDocumentData, onSaveLayout: jest.fn(), + handlePageDataChange: jest.fn(), + handleSaveCurrentPageLayout: jest.fn(), }; jest.mock( @@ -64,7 +68,7 @@ jest.mock( } ); -jest.mock('../../../../utils/CustomizeMyDataPageClassBase', () => { +jest.mock('../../../../utils/CustomizePageClassBase', () => { return mockCustomizePageClassBase; }); @@ -161,7 +165,9 @@ describe('CustomizeMyData component', () => { await act(async () => userEvent.click(cancelButton)); - expect(mockPush).toHaveBeenCalledWith('/settings/persona/testPersona'); + expect(mockPush).toHaveBeenCalledWith( + '/settings/preferences/customizeLandingPage' + ); }); it('CustomizeMyData should display reset layout confirmation modal on click of reset button', async () => { @@ -181,6 +187,9 @@ describe('CustomizeMyData component', () => { render(); }); + // handlePageDataChange is called 1 time on mount + expect(mockProps.handlePageDataChange).toHaveBeenCalledTimes(1); + const resetButton = screen.getByTestId('reset-button'); await act(async () => userEvent.click(resetButton)); @@ -191,6 +200,19 @@ describe('CustomizeMyData component', () => { await act(async () => userEvent.click(yesButton)); + expect(mockProps.handlePageDataChange).toHaveBeenCalledTimes(3); + // Check if the handlePageDataChange is passed an object with the default layout + expect(mockProps.handlePageDataChange).toHaveBeenCalledWith( + expect.objectContaining({ + ...mockDocumentData, + data: { + page: { + layout: expect.arrayContaining(mockDefaultLayout), + }, + }, + }) + ); + expect(screen.queryByTestId('reset-layout-modal')).toBeNull(); }); @@ -199,6 +221,9 @@ describe('CustomizeMyData component', () => { render(); }); + // handlePageDataChange is called 1 time on mount + expect(mockProps.handlePageDataChange).toHaveBeenCalledTimes(1); + const resetButton = screen.getByTestId('reset-button'); await act(async () => userEvent.click(resetButton)); @@ -209,6 +234,9 @@ describe('CustomizeMyData component', () => { await act(async () => userEvent.click(noButton)); + // handlePageDataChange is not called again + expect(mockProps.handlePageDataChange).toHaveBeenCalledTimes(1); + expect(screen.queryByTestId('reset-layout-modal')).toBeNull(); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomizeMyData/CustomizeMyData.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomizeMyData/CustomizeMyData.tsx index b875cfa3aded..5d2fb94458e2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomizeMyData/CustomizeMyData.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomizeMyData/CustomizeMyData.tsx @@ -11,24 +11,30 @@ * limitations under the License. */ +import { Button, Col, Modal, Space, Typography } from 'antd'; import { AxiosError } from 'axios'; -import { isEmpty } from 'lodash'; +import { isEmpty, isNil } from 'lodash'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import RGL, { Layout, WidthProvider } from 'react-grid-layout'; import { useTranslation } from 'react-i18next'; +import { Link, useHistory } from 'react-router-dom'; import gridBgImg from '../../../../assets/img/grid-bg-img.png'; import { KNOWLEDGE_LIST_LENGTH } from '../../../../constants/constants'; +import { + GlobalSettingOptions, + GlobalSettingsMenuCategory, +} from '../../../../constants/GlobalSettings.constants'; import { LandingPageWidgetKeys } from '../../../../enums/CustomizablePage.enum'; import { SearchIndex } from '../../../../enums/search.enum'; import { Document } from '../../../../generated/entity/docStore/document'; import { EntityReference } from '../../../../generated/entity/type'; -import { Page } from '../../../../generated/system/ui/page'; -import { PageType } from '../../../../generated/system/ui/uiCustomization'; import { useApplicationStore } from '../../../../hooks/useApplicationStore'; +import { useFqn } from '../../../../hooks/useFqn'; import { useGridLayoutDirection } from '../../../../hooks/useGridLayoutDirection'; import { WidgetConfig } from '../../../../pages/CustomizablePage/CustomizablePage.interface'; import '../../../../pages/MyDataPage/my-data.less'; import { searchQuery } from '../../../../rest/searchAPI'; +import { Transi18next } from '../../../../utils/CommonUtils'; import { getAddWidgetHandler, getLayoutUpdateHandler, @@ -37,13 +43,16 @@ import { getUniqueFilteredLayout, getWidgetFromKey, } from '../../../../utils/CustomizableLandingPageUtils'; -import customizeMyDataPageClassBase from '../../../../utils/CustomizeMyDataPageClassBase'; +import customizePageClassBase from '../../../../utils/CustomizePageClassBase'; import { getEntityName } from '../../../../utils/EntityUtils'; +import { + getPersonaDetailsPath, + getSettingPath, +} from '../../../../utils/RouterUtils'; import { showErrorToast } from '../../../../utils/ToastUtils'; import ActivityFeedProvider from '../../../ActivityFeed/ActivityFeedProvider/ActivityFeedProvider'; import PageLayoutV1 from '../../../PageLayoutV1/PageLayoutV1'; import AddWidgetModal from '../AddWidgetModal/AddWidgetModal'; -import { CustomizablePageHeader } from '../CustomizablePageHeader/CustomizablePageHeader'; import './customize-my-data.less'; import { CustomizeMyDataProps } from './CustomizeMyData.interface'; @@ -53,14 +62,17 @@ function CustomizeMyData({ personaDetails, initialPageData, onSaveLayout, + handlePageDataChange, + handleSaveCurrentPageLayout, }: Readonly) { const { t } = useTranslation(); - const { currentUser } = useApplicationStore(); - + const { currentUser, theme } = useApplicationStore(); + const history = useHistory(); + const { fqn: decodedPersonaFQN } = useFqn(); const [layout, setLayout] = useState>( getLayoutWithEmptyWidgetPlaceholder( - (initialPageData?.layout as WidgetConfig[]) ?? - customizeMyDataPageClassBase.defaultLayout, + initialPageData.data?.page?.layout ?? + customizePageClassBase.defaultLayout, 2, 4 ) @@ -70,10 +82,11 @@ function CustomizeMyData({ LandingPageWidgetKeys.EMPTY_WIDGET_PLACEHOLDER ); const [isWidgetModalOpen, setIsWidgetModalOpen] = useState(false); - + const [isResetModalOpen, setIsResetModalOpen] = useState(false); const [followedData, setFollowedData] = useState>([]); const [followedDataCount, setFollowedDataCount] = useState(0); const [isLoadingOwnedData, setIsLoadingOwnedData] = useState(false); + const [saving, setSaving] = useState(false); const handlePlaceholderWidgetKey = useCallback((value: string) => { setPlaceholderWidgetKey(value); @@ -94,7 +107,7 @@ function CustomizeMyData({ newWidgetData, placeholderWidgetKey, widgetSize, - customizeMyDataPageClassBase.landingPageMaxGridSize + customizePageClassBase.landingPageMaxGridSize ) ); setIsWidgetModalOpen(false); @@ -111,6 +124,14 @@ function CustomizeMyData({ [layout] ); + const handleOpenResetModal = useCallback(() => { + setIsResetModalOpen(true); + }, []); + + const handleCloseResetModal = useCallback(() => { + setIsResetModalOpen(false); + }, []); + const handleOpenAddWidgetModal = useCallback(() => { setIsWidgetModalOpen(true); }, []); @@ -176,15 +197,44 @@ function CustomizeMyData({ ] ); + useEffect(() => { + handlePageDataChange({ + ...initialPageData, + data: { + page: { + layout: getUniqueFilteredLayout(layout), + }, + }, + }); + }, [layout]); + + const handleCancel = useCallback(() => { + history.push( + getSettingPath( + GlobalSettingsMenuCategory.PREFERENCES, + GlobalSettingOptions.CUSTOMIZE_LANDING_PAGE + ) + ); + }, []); + const handleReset = useCallback(() => { // Get default layout with the empty widget added at the end const newMainPanelLayout = getLayoutWithEmptyWidgetPlaceholder( - customizeMyDataPageClassBase.defaultLayout, + customizePageClassBase.defaultLayout, 2, 4 ); setLayout(newMainPanelLayout); - onSaveLayout(); + handlePageDataChange({ + ...initialPageData, + data: { + page: { + layout: getUniqueFilteredLayout(newMainPanelLayout), + }, + }, + }); + handleSaveCurrentPageLayout(true); + setIsResetModalOpen(false); }, []); useEffect(() => { @@ -192,13 +242,10 @@ function CustomizeMyData({ }, []); const handleSave = async () => { - await onSaveLayout({ - ...(initialPageData ?? - ({ - pageType: PageType.LandingPage, - } as Page)), - layout: getUniqueFilteredLayout(layout), - }); + setSaving(true); + await onSaveLayout(); + + setSaving(false); }; // call the hook to set the direction of the grid layout @@ -207,6 +254,59 @@ function CustomizeMyData({ return ( +
+ + + } + values={{ + persona: isNil(personaDetails) + ? decodedPersonaFQN + : getEntityName(personaDetails), + }} + /> + +
+ + + + + + + } + headerClassName="m-0 p-0" mainContainerClassName="p-t-0" pageContainerStyle={{ backgroundImage: `url(${gridBgImg})`, @@ -214,21 +314,16 @@ function CustomizeMyData({ pageTitle={t('label.customize-entity', { entity: t('label.landing-page'), })}> - {widgets} @@ -239,13 +334,24 @@ function CustomizeMyData({ addedWidgetsList={addedWidgetsList} handleAddWidget={handleMainPanelAddWidget} handleCloseAddWidgetModal={handleCloseAddWidgetModal} - maxGridSizeSupport={ - customizeMyDataPageClassBase.landingPageMaxGridSize - } + maxGridSizeSupport={customizePageClassBase.landingPageMaxGridSize} open={isWidgetModalOpen} placeholderWidgetKey={placeholderWidgetKey} /> )} + {isResetModalOpen && ( + + {t('message.reset-layout-confirmation')} + + )}
); } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/LeftSidebar/LeftSidebar.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MyData/LeftSidebar/LeftSidebar.component.tsx index 78fc05cd0ec1..f08046d34e6c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/LeftSidebar/LeftSidebar.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/MyData/LeftSidebar/LeftSidebar.component.tsx @@ -13,8 +13,8 @@ import { Button, Col, Menu, MenuProps, Row, Typography } from 'antd'; import Modal from 'antd/lib/modal/Modal'; import classNames from 'classnames'; -import { isEmpty, noop } from 'lodash'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { noop } from 'lodash'; +import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { @@ -25,15 +25,8 @@ import { import { SidebarItem } from '../../../enums/sidebar.enum'; import leftSidebarClassBase from '../../../utils/LeftSidebarClassBase'; -import { EntityType } from '../../../enums/entity.enum'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import useCustomLocation from '../../../hooks/useCustomLocation/useCustomLocation'; -import { useCustomizeStore } from '../../../pages/CustomizablePage/CustomizeStore'; -import { getDocumentByFQN } from '../../../rest/DocStoreAPI'; -import { - filterAndArrangeTreeByKeys, - getNestedKeysFromNavigationItems, -} from '../../../utils/CustomizaNavigation/CustomizeNavigation'; import BrandImage from '../../common/BrandImage/BrandImage'; import './left-sidebar.less'; import { LeftSidebarItem as LeftSidebarItemType } from './LeftSidebar.interface'; @@ -45,21 +38,8 @@ const LeftSidebar = () => { const { onLogoutHandler } = useApplicationStore(); const [showConfirmLogoutModal, setShowConfirmLogoutModal] = useState(false); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(true); - const { selectedPersona } = useApplicationStore(); - - const { currentPersonaDocStore, setCurrentPersonaDocStore } = - useCustomizeStore(); - - const navigationItems = useMemo(() => { - return currentPersonaDocStore?.data?.navigation; - }, [currentPersonaDocStore]); - const sideBarItems = isEmpty(navigationItems) - ? leftSidebarClassBase.getSidebarItems() - : filterAndArrangeTreeByKeys( - leftSidebarClassBase.getSidebarItems(), - getNestedKeysFromNavigationItems(navigationItems) - ); + const sideBarItems = leftSidebarClassBase.getSidebarItems(); const selectedKeys = useMemo(() => { const pathArray = location.pathname.split('/'); @@ -78,6 +58,23 @@ const LeftSidebar = () => { setShowConfirmLogoutModal(false); }; + const TOP_SIDEBAR_MENU_ITEMS: MenuProps['items'] = useMemo(() => { + return [ + ...sideBarItems.map((item) => { + return { + key: item.key, + label: , + children: item.children?.map((item: LeftSidebarItemType) => { + return { + key: item.key, + label: , + }; + }), + }; + }), + ]; + }, []); + const LOWER_SIDEBAR_TOP_SIDEBAR_MENU_ITEMS: MenuProps['items'] = useMemo( () => [SETTING_ITEM, LOGOUT_ITEM].map((item) => ({ @@ -106,23 +103,6 @@ const LeftSidebar = () => { setIsSidebarCollapsed(true); }, []); - const fetchCustomizedDocStore = useCallback(async (personaFqn: string) => { - try { - const pageLayoutFQN = `${EntityType.PERSONA}.${personaFqn}`; - - const document = await getDocumentByFQN(pageLayoutFQN); - setCurrentPersonaDocStore(document); - } catch (error) { - // silent error - } - }, []); - - useEffect(() => { - if (selectedPersona.fullyQualifiedName) { - fetchCustomizedDocStore(selectedPersona.fullyQualifiedName); - } - }, [selectedPersona]); - return (
{ { - return { - key: item.key, - label: , - children: item.children?.map((item: LeftSidebarItemType) => { - return { - key: item.key, - label: , - }; - }), - }; - })} + items={TOP_SIDEBAR_MENU_ITEMS} mode="inline" rootClassName="left-sidebar-menu" selectedKeys={selectedKeys} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/LeftSidebar/LeftSidebar.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/MyData/LeftSidebar/LeftSidebar.interface.ts index 62265f54e5e3..d39658a5589f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/LeftSidebar/LeftSidebar.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/MyData/LeftSidebar/LeftSidebar.interface.ts @@ -14,7 +14,7 @@ export interface LeftSidebarItem { key: string; isBeta?: boolean; - title: string; + label: string; redirect_url?: string; icon: SvgComponent; dataTestId: string; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/LeftSidebar/LeftSidebarItem.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MyData/LeftSidebar/LeftSidebarItem.component.tsx index 605d0b86943b..c11c9ddc14f3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/LeftSidebar/LeftSidebarItem.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/MyData/LeftSidebar/LeftSidebarItem.component.tsx @@ -19,7 +19,7 @@ import { NavLink } from 'react-router-dom'; interface LeftSidebarItemProps { data: { key: string; - title: string; + label: string; dataTestId: string; redirect_url?: string; icon: SvgComponent; @@ -29,7 +29,7 @@ interface LeftSidebarItemProps { } const LeftSidebarItem = ({ - data: { title, redirect_url, dataTestId, icon, isBeta, onClick }, + data: { label, redirect_url, dataTestId, icon, isBeta, onClick }, }: LeftSidebarItemProps) => { const { t } = useTranslation(); @@ -42,7 +42,7 @@ const LeftSidebarItem = ({ }}>
- {title} + {label} {isBeta && ( - {title} + {label} ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/Persona/PersonaDetailsCard/PersonaDetailsCard.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MyData/Persona/PersonaDetailsCard/PersonaDetailsCard.test.tsx index d298dcaca2cb..dae6a2919924 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/Persona/PersonaDetailsCard/PersonaDetailsCard.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/MyData/Persona/PersonaDetailsCard/PersonaDetailsCard.test.tsx @@ -48,9 +48,7 @@ describe('PersonaDetailsCard Component', () => { }); expect( - await screen.findByTestId( - `persona-details-card-${personaWithDescription.name}` - ) + await screen.findByTestId('persona-details-card') ).toBeInTheDocument(); }); @@ -83,7 +81,7 @@ describe('PersonaDetailsCard Component', () => { userEvent.click(personaCardTitle); }); - expect(mockPush).toHaveBeenCalledWith('/settings/persona/john-doe'); + expect(mockPush).toHaveBeenCalledWith('/settings/members/persona/john-doe'); }); it('should not navigate when persona.fullyQualifiedName is missing', async () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/Persona/PersonaDetailsCard/PersonaDetailsCard.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MyData/Persona/PersonaDetailsCard/PersonaDetailsCard.tsx index e6153d7f14d2..adaa39a25b7b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/Persona/PersonaDetailsCard/PersonaDetailsCard.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/MyData/Persona/PersonaDetailsCard/PersonaDetailsCard.tsx @@ -37,7 +37,7 @@ export const PersonaDetailsCard = ({ persona }: PersonaDetailsCardProps) => { { - const history = useHistory(); - const { fqn: personaFQN } = useFqn(); - - const [items, setItems] = React.useState(categories); - - const handleCustomizeItemClick = (category: string) => { - const nestedItems = getCustomizePageOptions(category); - - if (isEmpty(nestedItems)) { - history.push(getCustomizePagePath(personaFQN, category)); - } else { - setItems(nestedItems); - } - }; - - return ( - - {items.map((value) => ( - - - - ))} - - ); -}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/ExtensionTable.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/ExtensionTable.tsx index 374bc4549cf2..ebe0a1ed6d40 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/ExtensionTable.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/ExtensionTable.tsx @@ -12,7 +12,6 @@ */ import { Table, Typography } from 'antd'; import { ColumnsType } from 'antd/lib/table'; -import classNames from 'classnames'; import { isObject, isString, map } from 'lodash'; import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -29,10 +28,8 @@ interface ExtensionDataSource { export const ExtensionTable = ({ extension, - tableClassName, }: { extension: ExtentionEntities[ExtentionEntitiesKeys]['extension']; - tableClassName?: string; }) => { const { t } = useTranslation(); const dataSource: ExtensionDataSource[] = useMemo(() => { @@ -72,7 +69,7 @@ export const ExtensionTable = ({ return ( { - index: React.Key; - moveNode: (dragIndex: React.Key, hoverIndex: React.Key) => void; -} - -export const DraggableTabNode = ({ - index, - children, - moveNode, -}: DraggableTabPaneProps) => { - const ref = useRef(null); - const [{ isOver, dropClassName }, drop] = useDrop({ - accept: type, - collect: (monitor) => { - const { index: dragIndex } = monitor.getItem<{ index: string }>() || {}; - if (dragIndex === index) { - return {}; - } - - return { - isOver: monitor.isOver(), - dropClassName: 'dropping', - }; - }, - drop: (item: { index: React.Key }) => { - moveNode(item.index, index); - }, - }); - const [, drag] = useDrag({ - type, - item: { index }, - collect: (monitor) => ({ - isDragging: monitor.isDragging(), - }), - }); - drop(drag(ref)); - - return ( -
- {children} -
- ); -}; - -export const DraggableTabs: React.FC< - TabsProps & { onTabChange?: (newKeys: React.Key[]) => void } -> = (props) => { - const { items = [] } = props; - const [order, setOrder] = useState([]); - - const moveTabNode = (dragKey: React.Key, hoverKey: React.Key) => { - const newOrder = order.slice(); - - items.forEach((item) => { - if (item.key && newOrder.indexOf(item.key) === -1) { - newOrder.push(item.key); - } - }); - - const dragIndex = newOrder.indexOf(dragKey); - const hoverIndex = newOrder.indexOf(hoverKey); - - newOrder.splice(dragIndex, 1); - newOrder.splice(hoverIndex, 0, dragKey); - - props.onTabChange?.(newOrder); - setOrder(newOrder); - }; - - const renderTabBar: TabsProps['renderTabBar'] = ( - tabBarProps, - DefaultTabBar - ) => ( - - {(node) => ( - - {node} - - )} - - ); - - const orderItems = sortTabs(items, order as string[]); - - return ( - - - - ); -}; diff --git a/openmetadata-ui/src/main/resources/ui/src/constants/CustomizeWidgets.constants.ts b/openmetadata-ui/src/main/resources/ui/src/constants/CustomizeWidgets.constants.ts deleted file mode 100644 index 233d4e00a9d5..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/constants/CustomizeWidgets.constants.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* - * 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 { WidgetWidths } from '../enums/CustomizablePage.enum'; -import { - DetailPageWidgetKeys, - GlossaryTermDetailPageWidgetKeys, -} from '../enums/CustomizeDetailPage.enum'; -import i18n from '../utils/i18next/LocalUtil'; - -export type GridSizes = keyof typeof WidgetWidths; -export interface CommonWidgetType { - fullyQualifiedName: string; - name: string; - description?: string; - data: { - gridSizes: Array; - }; -} - -export const DESCRIPTION_WIDGET: CommonWidgetType = { - fullyQualifiedName: DetailPageWidgetKeys.DESCRIPTION, - name: i18n.t('label.description'), - data: { - gridSizes: ['small', 'large'], - }, -}; - -export const TAGS_WIDGET: CommonWidgetType = { - fullyQualifiedName: DetailPageWidgetKeys.TAGS, - name: i18n.t('label.tag-plural'), - data: { gridSizes: ['small'] }, -}; - -export const GLOSSARY_TERMS_WIDGET: CommonWidgetType = { - fullyQualifiedName: DetailPageWidgetKeys.GLOSSARY_TERMS, - name: i18n.t('label.tag-plural'), - data: { gridSizes: ['small'] }, -}; - -export const CUSTOM_PROPERTIES_WIDGET: CommonWidgetType = { - fullyQualifiedName: DetailPageWidgetKeys.CUSTOM_PROPERTIES, - name: i18n.t('label.custom-property-plural'), - data: { gridSizes: ['small'] }, -}; - -export const DOMAIN_WIDGET: CommonWidgetType = { - fullyQualifiedName: GlossaryTermDetailPageWidgetKeys.DOMAIN, - name: i18n.t('label.domain'), - data: { gridSizes: ['small'] }, -}; - -export const OWNER_WIDGET: CommonWidgetType = { - fullyQualifiedName: GlossaryTermDetailPageWidgetKeys.OWNER, - name: i18n.t('label.owner'), - data: { gridSizes: ['small'] }, -}; diff --git a/openmetadata-ui/src/main/resources/ui/src/constants/GlobalSettings.constants.ts b/openmetadata-ui/src/main/resources/ui/src/constants/GlobalSettings.constants.ts index 2ba84c2ba0f1..04326e552960 100644 --- a/openmetadata-ui/src/main/resources/ui/src/constants/GlobalSettings.constants.ts +++ b/openmetadata-ui/src/main/resources/ui/src/constants/GlobalSettings.constants.ts @@ -22,7 +22,6 @@ export enum GlobalSettingsMenuCategory { SERVICES = 'services', BOTS = 'bots', APPLICATIONS = 'apps', - PERSONA = 'persona', } export enum GlobalSettingOptions { diff --git a/openmetadata-ui/src/main/resources/ui/src/constants/LeftSidebar.constants.ts b/openmetadata-ui/src/main/resources/ui/src/constants/LeftSidebar.constants.ts index 63bca174bc00..532be8e0af09 100644 --- a/openmetadata-ui/src/main/resources/ui/src/constants/LeftSidebar.constants.ts +++ b/openmetadata-ui/src/main/resources/ui/src/constants/LeftSidebar.constants.ts @@ -38,34 +38,34 @@ export const SIDEBAR_NESTED_KEYS = { export const SIDEBAR_LIST: Array = [ { key: ROUTES.EXPLORE, - title: i18next.t('label.explore'), + label: i18next.t('label.explore'), redirect_url: ROUTES.EXPLORE, icon: ExploreIcon, dataTestId: `app-bar-item-${SidebarItem.EXPLORE}`, }, { key: ROUTES.OBSERVABILITY, - title: i18next.t('label.observability'), + label: i18next.t('label.observability'), icon: ObservabilityIcon, dataTestId: SidebarItem.OBSERVABILITY, children: [ { key: ROUTES.DATA_QUALITY, - title: i18next.t('label.data-quality'), + label: i18next.t('label.data-quality'), redirect_url: ROUTES.DATA_QUALITY, icon: DataQualityIcon, dataTestId: `app-bar-item-${SidebarItem.DATA_QUALITY}`, }, { key: ROUTES.INCIDENT_MANAGER, - title: i18next.t('label.incident-manager'), + label: i18next.t('label.incident-manager'), redirect_url: ROUTES.INCIDENT_MANAGER, icon: IncidentMangerIcon, dataTestId: `app-bar-item-${SidebarItem.INCIDENT_MANAGER}`, }, { key: ROUTES.OBSERVABILITY_ALERTS, - title: i18next.t('label.alert-plural'), + label: i18next.t('label.alert-plural'), redirect_url: ROUTES.OBSERVABILITY_ALERTS, icon: AlertIcon, dataTestId: `app-bar-item-${SidebarItem.OBSERVABILITY_ALERT}`, @@ -74,41 +74,41 @@ export const SIDEBAR_LIST: Array = [ }, { key: ROUTES.DATA_INSIGHT, - title: i18next.t('label.insight-plural'), + label: i18next.t('label.insight-plural'), redirect_url: getDataInsightPathWithFqn(), icon: InsightsIcon, dataTestId: `app-bar-item-${SidebarItem.DATA_INSIGHT}`, }, { key: ROUTES.DOMAIN, - title: i18next.t('label.domain-plural'), + label: i18next.t('label.domain-plural'), redirect_url: ROUTES.DOMAIN, icon: DomainsIcon, dataTestId: `app-bar-item-${SidebarItem.DOMAIN}`, }, { key: 'governance', - title: i18next.t('label.govern'), + label: i18next.t('label.govern'), icon: GovernIcon, dataTestId: SidebarItem.GOVERNANCE, children: [ { key: ROUTES.GLOSSARY, - title: i18next.t('label.glossary'), + label: i18next.t('label.glossary'), redirect_url: ROUTES.GLOSSARY, icon: GlossaryIcon, dataTestId: `app-bar-item-${SidebarItem.GLOSSARY}`, }, { key: ROUTES.TAGS, - title: i18next.t('label.classification'), + label: i18next.t('label.classification'), redirect_url: ROUTES.TAGS, icon: ClassificationIcon, dataTestId: `app-bar-item-${SidebarItem.TAGS}`, }, { key: ROUTES.METRICS, - title: i18next.t('label.metric-plural'), + label: i18next.t('label.metric-plural'), redirect_url: ROUTES.METRICS, icon: MetricIcon, dataTestId: `app-bar-item-${SidebarItem.METRICS}`, @@ -119,7 +119,7 @@ export const SIDEBAR_LIST: Array = [ export const SETTING_ITEM = { key: ROUTES.SETTINGS, - title: i18next.t('label.setting-plural'), + label: i18next.t('label.setting-plural'), redirect_url: ROUTES.SETTINGS, icon: SettingsIcon, dataTestId: `app-bar-item-${SidebarItem.SETTINGS}`, @@ -127,7 +127,7 @@ export const SETTING_ITEM = { export const LOGOUT_ITEM = { key: SidebarItem.LOGOUT, - title: i18next.t('label.logout'), + label: i18next.t('label.logout'), icon: LogoutIcon, dataTestId: `app-bar-item-${SidebarItem.LOGOUT}`, }; diff --git a/openmetadata-ui/src/main/resources/ui/src/constants/constants.ts b/openmetadata-ui/src/main/resources/ui/src/constants/constants.ts index 1752e79f02f2..08c93373b05e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/constants/constants.ts +++ b/openmetadata-ui/src/main/resources/ui/src/constants/constants.ts @@ -271,7 +271,7 @@ export const ROUTES = { SETTINGS_EDIT_CUSTOM_LOGIN_CONFIG: `/settings/OpenMetadata/loginConfiguration/edit-custom-login-configuration`, - CUSTOMIZE_PAGE: `/customize-page/${PLACEHOLDER_ROUTE_FQN}/:pageFqn`, + CUSTOMIZE_PAGE: `/customize-page/:fqn/:pageFqn`, ADD_CUSTOM_METRIC: `/add-custom-metric/${PLACEHOLDER_DASHBOARD_TYPE}/${PLACEHOLDER_ROUTE_FQN}`, diff --git a/openmetadata-ui/src/main/resources/ui/src/enums/CustomizeDetailPage.enum.ts b/openmetadata-ui/src/main/resources/ui/src/enums/CustomizeDetailPage.enum.ts deleted file mode 100644 index ee626b72d07a..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/enums/CustomizeDetailPage.enum.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * 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 enum WidgetWidths { - large = 3, - medium = 2, - small = 1, -} - -export enum DetailPageWidgetKeys { - TABS = 'KnowledgePanel.Tabs', - HEADER = 'KnowledgePanel.Header', - ANNOUNCEMENTS = 'KnowledgePanel.Announcements', - DESCRIPTION = 'KnowledgePanel.Description', - TABLE_SCHEMA = 'KnowledgePanel.TableSchema', - TOPIC_SCHEMA = 'KnowledgePanel.TopicSchema', - FREQUENTLY_JOINED_TABLES = 'KnowledgePanel.FrequentlyJoinedTables', - DATA_PRODUCTS = 'KnowledgePanel.DataProducts', - TAGS = 'KnowledgePanel.Tags', - DOMAIN = 'KnowledgePanel.Domain', - GLOSSARY_TERMS = 'KnowledgePanel.GlossaryTerms', - CUSTOM_PROPERTIES = 'KnowledgePanel.CustomProperties', - EMPTY_WIDGET_PLACEHOLDER = 'ExtraWidget.EmptyWidgetPlaceholder', -} - -export enum GlossaryTermDetailPageWidgetKeys { - TABS = 'KnowledgePanel.Tabs', - HEADER = 'KnowledgePanel.Header', - DESCRIPTION = 'KnowledgePanel.Description', - TAGS = 'KnowledgePanel.Tags', - SYNONYMS = 'KnowledgePanel.Synonyms', - RELATED_TERMS = 'KnowledgePanel.RelatedTerms', - REFERENCES = 'KnowledgePanel.References', - OWNER = 'KnowledgePanel.Owner', - DOMAIN = 'KnowledgePanel.Domain', - REVIEWER = 'KnowledgePanel.Reviewer', - CUSTOM_PROPERTIES = 'KnowledgePanel.CustomProperties', - EMPTY_WIDGET_PLACEHOLDER = 'ExtraWidget.EmptyWidgetPlaceholder', - TERMS_TABLE = 'KnowledgePanel.TermsTable', -} diff --git a/openmetadata-ui/src/main/resources/ui/src/enums/entity.enum.ts b/openmetadata-ui/src/main/resources/ui/src/enums/entity.enum.ts index 6ae69752d416..b265b68462a7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/enums/entity.enum.ts +++ b/openmetadata-ui/src/main/resources/ui/src/enums/entity.enum.ts @@ -157,7 +157,6 @@ export enum TabSpecificField { CUSTOM_PROPERTIES = 'customProperties', LOCATION = 'location', RELATED_METRICS = 'relatedMetrics', - UI_CUSTOMIZATION = 'uiCustomization', } export enum FqnPart { @@ -206,9 +205,6 @@ export enum EntityTabs { API_ENDPOINT = 'apiEndpoint', OVERVIEW = 'overview', INCIDENTS = 'incidents', - TERMS = 'terms', - GLOSSARY_TERMS = 'glossary_terms', - ASSETS = 'assets', EXPRESSION = 'expression', } diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json index ba02828eaee9..d9eb209b715a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json @@ -260,7 +260,6 @@ "custom-theme": "Custom Theme", "customise": "Customise", "customize-entity": "Customize {{entity}}", - "customize-ui": "Customize UI", "dag": "DAG", "dag-view": "DAG-Ansicht", "daily-active-users-on-the-platform": "Täglich aktive Benutzer auf der Plattform", @@ -593,7 +592,6 @@ "hide-deleted-entity": "Gelöschte {{entity}} verbergen", "history": "History", "home": "Startseite", - "homepage": "Homepage", "hour": "Stunde", "http-config-source": "HTTP-Konfigurationsquelle", "http-method": "HTTP Method", @@ -792,7 +790,6 @@ "my-data": "Meine Daten", "name": "Name", "name-lowercase": "name", - "navigation": "Navigation", "need-help": "Need Help", "new": "Neu", "new-password": "Neues Passwort", @@ -1499,7 +1496,7 @@ "custom-properties-description": " Capture custom metadata to enrich your data assets by extending the attributes.", "custom-property-is-set-to-message": "{{fieldName}} is set to", "custom-property-name-validation": "Der Name muss mit Kleinbuchstaben beginnen und darf keine Leerzeichen, Unterstriche oder Punkte enthalten.", - "customize-landing-page-header": "Customize {{pageName}} for Persona \"<0>{{persona}}\"", + "customize-landing-page-header": "Customize Landing Page for Persona \"<0>{{persona}}\"", "customize-open-metadata-description": "Tailor the OpenMetadata UX to suit your organizational and team needs.", "data-asset-has-been-action-type": "Der Datenvermögenswert wurde {{actionType}}", "data-insight-alert-destination-description": "Senden Sie E-Mail-Benachrichtigungen an Administratoren oder Teams.", @@ -1700,7 +1697,6 @@ "no-config-available": "Keine Verbindungskonfigurationen verfügbar.", "no-config-plural": "No Configs.", "no-custom-properties-entity": "Derzeit sind keine benutzerdefinierten Eigenschaften für das {{entity}} Data Asset definiert. Um zu erfahren, wie man benutzerdefinierte Eigenschaften hinzufügt, lesen Sie bitte <0>{{docs}}", - "no-customization-available": "No customization available for this tab", "no-data": "Keine Daten", "no-data-assets": "Welcome to OpenMetadata! It looks like no data assets have been added yet. Check out our <0>How to Get Started guide to begin.", "no-data-available": "Keine Daten verfügbar.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json index 647cee71acb5..81a40b7d821d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json @@ -260,7 +260,6 @@ "custom-theme": "Custom Theme", "customise": "Customise", "customize-entity": "Customize {{entity}}", - "customize-ui": "Customize UI", "dag": "Dag", "dag-view": "DAG view", "daily-active-users-on-the-platform": "Daily Active Users on the Platform", @@ -593,7 +592,6 @@ "hide-deleted-entity": "Hide Deleted {{entity}}", "history": "History", "home": "Home", - "homepage": "Homepage", "hour": "Hour", "http-config-source": "HTTP Config Source", "http-method": "HTTP Method", @@ -792,7 +790,6 @@ "my-data": "My Data", "name": "Name", "name-lowercase": "name", - "navigation": "Navigation", "need-help": "Need Help", "new": "New", "new-password": "New Password", @@ -1499,7 +1496,7 @@ "custom-properties-description": " Capture custom metadata to enrich your data assets by extending the attributes.", "custom-property-is-set-to-message": "{{fieldName}} is set to", "custom-property-name-validation": "Name must start with lower case with no space, underscore, or dots.", - "customize-landing-page-header": "Customize {{pageName}} for Persona \"<0>{{persona}}\"", + "customize-landing-page-header": "Customize Landing Page for Persona \"<0>{{persona}}\"", "customize-open-metadata-description": "Tailor the OpenMetadata UX to suit your organizational and team needs.", "data-asset-has-been-action-type": "Data Asset has been {{actionType}}", "data-insight-alert-destination-description": "Send email notifications to admins or teams.", @@ -1700,7 +1697,6 @@ "no-config-available": "No Connection Configs available.", "no-config-plural": "No Configs.", "no-custom-properties-entity": "There are currently no Custom Properties defined for the {{entity}} Data Asset. To discover how to add Custom Properties, please refer to <0>{{docs}}", - "no-customization-available": "No customization available for this tab", "no-data": "No data", "no-data-assets": "Welcome to OpenMetadata! It looks like no data assets have been added yet. Check out our <0>How to Get Started guide to begin.", "no-data-available": "No data available.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json index a971e536564b..1bf28882a2ca 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json @@ -260,7 +260,6 @@ "custom-theme": "Custom Theme", "customise": "Personalizar", "customize-entity": "Personalizar {{entity}}", - "customize-ui": "Customize UI", "dag": "DAG", "dag-view": "Vista DAG", "daily-active-users-on-the-platform": "Usuarios activos diarios en la plataforma", @@ -593,7 +592,6 @@ "hide-deleted-entity": "Ocultar {{entity}} eliminados", "history": "Historia", "home": "Inicio", - "homepage": "Homepage", "hour": "Hora", "http-config-source": "Fuente de configuración HTTP", "http-method": "HTTP Method", @@ -792,7 +790,6 @@ "my-data": "Mis Datos", "name": "Nombre", "name-lowercase": "nombre", - "navigation": "Navigation", "need-help": "necesita ayuda", "new": "Nuevo", "new-password": "Nueva Contraseña", @@ -1499,7 +1496,7 @@ "custom-properties-description": "Captura metadatos personalizados para enriquecer tus activos de datos mediante la extensión de los atributos.", "custom-property-is-set-to-message": "{{fieldName}} is set to", "custom-property-name-validation": "El nombre debe comenzar con minúsculas sin espacios, guiones bajos o puntos.", - "customize-landing-page-header": "Customize {{pageName}} for Persona \"<0>{{persona}}\"", + "customize-landing-page-header": "Personalizar la página de inicio para la persona \"<0>{{persona}}\"", "customize-open-metadata-description": "Adapte la experiencia de usuario de OpenMetadata para satisfacer las necesidades de su organización y equipo.", "data-asset-has-been-action-type": "El activo de datos ha sido {{actionType}}", "data-insight-alert-destination-description": "Enviar notificaciones por correo electrónico a administradores o equipos.", @@ -1700,7 +1697,6 @@ "no-config-available": "No hay configuraciones de conexión disponibles.", "no-config-plural": "No hay configuraciones.", "no-custom-properties-entity": "Actualmente no hay Propiedades Personalizadas definidas para el Activo de Datos {{entity}}. Para saber cómo añadir Propiedades Personalizadas, consulte <0>{{docs}}.", - "no-customization-available": "No customization available for this tab", "no-data": "No hay datos", "no-data-assets": "Welcome to OpenMetadata! It looks like no data assets have been added yet. Check out our <0>How to Get Started guide to begin.", "no-data-available": "No hay datos disponibles.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json index 2951d8e6a600..173272d518b0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json @@ -260,7 +260,6 @@ "custom-theme": "Thème personnalisé", "customise": "Personnaliser", "customize-entity": "Personnaliser {{entity}}", - "customize-ui": "Customize UI", "dag": "DAG", "dag-view": "Vue DAG", "daily-active-users-on-the-platform": "Utilisateurs Actifs Quotidiens sur la Plateforme", @@ -593,7 +592,6 @@ "hide-deleted-entity": "Masquer les {{entity}} Supprimé·e·s", "history": "History", "home": "Accueil", - "homepage": "Homepage", "hour": "Heure", "http-config-source": "Source de Configuration HTTP", "http-method": "HTTP Method", @@ -792,7 +790,6 @@ "my-data": "Mes Données", "name": "Nom", "name-lowercase": "nom", - "navigation": "Navigation", "need-help": "Need Help", "new": "Nouveau", "new-password": "Nouveau mot de passe", @@ -1499,7 +1496,7 @@ "custom-properties-description": " Capturer des métadonnées personnalisées pour enrichir vos actifs de données en étendant leurs attributs.", "custom-property-is-set-to-message": "{{fieldName}} est réglé sur", "custom-property-name-validation": "Le nom doit commencer par une lettre minuscule, sans espace, ni tiret bas ou point.", - "customize-landing-page-header": "Customize {{pageName}} for Persona \"<0>{{persona}}\"", + "customize-landing-page-header": "Personnalisez la page d'accueil pour le Persona \"<0>{{persona}}\"", "customize-open-metadata-description": "Ajustez l'expérience utilisateur d'OpenMetadata pour répondre à vos besoins d'équipe et d'organisation.", "data-asset-has-been-action-type": "l'actif de données a été {{actionType}}", "data-insight-alert-destination-description": "Envoyez des notifications par email aux administrateurs ou aux équipes.", @@ -1700,7 +1697,6 @@ "no-config-available": "Aucun paramètre de connexion disponible.", "no-config-plural": "Pas de Configs.", "no-custom-properties-entity": "Aucune propriété personnalisée n'est actuellement définie pour l'actif de données {{entity}}. Pour découvrir comment ajouter des propriétés personnalisées, veuillez consulter <0>{{docs}}", - "no-customization-available": "No customization available for this tab", "no-data": "Aucune donnée", "no-data-assets": "Bienvenue dans OpenMetadata! Il semble qu'auncun actif de données n'ai encore été ajouté. Consultez le guide <0>How to Get Started pour démarrer.", "no-data-available": "Aucune donnée disponible", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json index 97f03731d4ba..262e2fd22bad 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json @@ -260,7 +260,6 @@ "custom-theme": "Tema personalizado", "customise": "Personalizar", "customize-entity": "Personalizar {{entity}}", - "customize-ui": "Personalizar a interface", "dag": "DAG", "dag-view": "Vista de DAG", "daily-active-users-on-the-platform": "Usuarios activos diarios na plataforma", @@ -593,7 +592,6 @@ "hide-deleted-entity": "Ocultar {{entity}} eliminado", "history": "Historial", "home": "Inicio", - "homepage": "Homepage", "hour": "Hora", "http-config-source": "Fonte de configuración HTTP", "http-method": "HTTP Method", @@ -792,7 +790,6 @@ "my-data": "Os meus datos", "name": "Nome", "name-lowercase": "nome", - "navigation": "Navigation", "need-help": "Necesitas axuda", "new": "Novo", "new-password": "Novo contrasinal", @@ -1499,7 +1496,7 @@ "custom-properties-description": "Captura metadatos personalizados para enriquecer os teus activos de datos estendendo os atributos.", "custom-property-is-set-to-message": "{{fieldName}} está configurado para", "custom-property-name-validation": "O nome debe comezar en minúscula sen espazos, subliñados nin puntos.", - "customize-landing-page-header": "Customize {{pageName}} for Persona \"<0>{{persona}}\"", + "customize-landing-page-header": "Personalizar a páxina de inicio para a persoa \"<0>{{persona}}\"", "customize-open-metadata-description": "Adapta a experiencia de usuario de OpenMetadata ás necesidades da túa organización e equipo.", "data-asset-has-been-action-type": "O activo de datos foi {{actionType}}", "data-insight-alert-destination-description": "Envía notificacións por correo electrónico aos administradores ou equipos.", @@ -1700,7 +1697,6 @@ "no-config-available": "Non hai configuracións de conexión dispoñibles.", "no-config-plural": "Non hai configuracións.", "no-custom-properties-entity": "Actualmente non hai Propiedades Personalizadas definidas para o Activo de Datos {{entity}}. Para descubrir como engadir Propiedades Personalizadas, consulte <0>{{docs}}", - "no-customization-available": "No customization available for this tab", "no-data": "Non hai datos", "no-data-assets": "Benvido a OpenMetadata! Parece que aínda non se engadiron activos de datos. Consulta a nosa guía de <0>Como comezar para iniciar.", "no-data-available": "Non hai datos dispoñibles.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json index 7ee8ad3bb546..f3806c469606 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json @@ -260,7 +260,6 @@ "custom-theme": "Custom Theme", "customise": "התאמה אישית", "customize-entity": "התאם אישית את {{entity}}", - "customize-ui": "Customize UI", "dag": "גרף מופע", "dag-view": "תצוגת גרף מופע", "daily-active-users-on-the-platform": "משתמשים פעילים יומיים בפלטפורמה", @@ -593,7 +592,6 @@ "hide-deleted-entity": "הסתר ישות {{entity}} שנמחקה", "history": "היסטוריה", "home": "דף הבית", - "homepage": "Homepage", "hour": "שעה", "http-config-source": "מקור תצורת HTTP", "http-method": "HTTP Method", @@ -792,7 +790,6 @@ "my-data": "הנתונים שלי", "name": "שם", "name-lowercase": "שם", - "navigation": "Navigation", "need-help": "Need Help", "new": "חדש", "new-password": "סיסמה חדשה", @@ -1499,7 +1496,7 @@ "custom-properties-description": " Capture custom metadata to enrich your data assets by extending the attributes.", "custom-property-is-set-to-message": "{{fieldName}} is set to", "custom-property-name-validation": "השם חייב להתחיל באות קטנה ללא רווח, קו תחתון או נקודות.", - "customize-landing-page-header": "Customize {{pageName}} for Persona \"<0>{{persona}}\"", + "customize-landing-page-header": "התאמה אישית של עמוד הנחיתה עבור דמות \"<0>{{persona}}\"", "customize-open-metadata-description": "Tailor the OpenMetadata UX to suit your organizational and team needs.", "data-asset-has-been-action-type": "נכנס של {{actionType}} נתונים", "data-insight-alert-destination-description": "שלח התראות בדואר אלקטרוני למנהלים או לצוותים.", @@ -1700,7 +1697,6 @@ "no-config-available": "אין הגדרות חיבור זמינות.", "no-config-plural": "אין הגדרות.", "no-custom-properties-entity": "נכון לעכשיו לא מוגדרות תכונות מותאמות אישית עבור נכס הנתונים {{entity}}. כדי לגלות כיצד להוסיף תכונות מותאמות אישית, נא עיין ב-<0>{{docs}}", - "no-customization-available": "No customization available for this tab", "no-data": "אין נתונים", "no-data-assets": "Welcome to OpenMetadata! It looks like no data assets have been added yet. Check out our <0>How to Get Started guide to begin.", "no-data-available": "אין נתונים זמינים.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json index 302afcc124a5..64b360352f1c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json @@ -260,7 +260,6 @@ "custom-theme": "Custom Theme", "customise": "Customise", "customize-entity": "Customize {{entity}}", - "customize-ui": "Customize UI", "dag": "Dag", "dag-view": "DAGビュー", "daily-active-users-on-the-platform": "このプラットフォームのアクティブなユーザー", @@ -593,7 +592,6 @@ "hide-deleted-entity": "Hide Deleted {{entity}}", "history": "History", "home": "ホーム", - "homepage": "Homepage", "hour": "時", "http-config-source": "HTTP Config Source", "http-method": "HTTP Method", @@ -792,7 +790,6 @@ "my-data": "マイデータ", "name": "名前", "name-lowercase": "名前", - "navigation": "Navigation", "need-help": "Need Help", "new": "新しい", "new-password": "新しいパスワード", @@ -1499,7 +1496,7 @@ "custom-properties-description": " Capture custom metadata to enrich your data assets by extending the attributes.", "custom-property-is-set-to-message": "{{fieldName}} is set to", "custom-property-name-validation": "Name must start with lower case with no space, underscore, or dots.", - "customize-landing-page-header": "Customize {{pageName}} for Persona \"<0>{{persona}}\"", + "customize-landing-page-header": "Customize Landing Page for Persona \"<0>{{persona}}\"", "customize-open-metadata-description": "Tailor the OpenMetadata UX to suit your organizational and team needs.", "data-asset-has-been-action-type": "データアセットが{{actionType}}されました", "data-insight-alert-destination-description": "Send email notifications to admins or teams.", @@ -1700,7 +1697,6 @@ "no-config-available": "利用可能な接続の設定はありません。", "no-config-plural": "No Configs.", "no-custom-properties-entity": "現在、{{entity}} データアセットにはカスタムプロパティが定義されていません。カスタムプロパティの追加方法については、<0>{{docs}} を参照してください。", - "no-customization-available": "No customization available for this tab", "no-data": "データがありません", "no-data-assets": "Welcome to OpenMetadata! It looks like no data assets have been added yet. Check out our <0>How to Get Started guide to begin.", "no-data-available": "利用できるデータがありません", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json index e85a66c511c4..736db14c8319 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json @@ -260,7 +260,6 @@ "custom-theme": "Custom Theme", "customise": "Aanpassen", "customize-entity": "{{entity}} aanpassen", - "customize-ui": "Customize UI", "dag": "Dag", "dag-view": "DAG-weergave", "daily-active-users-on-the-platform": "Dagelijks actieve gebruikers op het platform", @@ -593,7 +592,6 @@ "hide-deleted-entity": "Verwijderde {{entity}} verbergen", "history": "Geschiedenis", "home": "Home", - "homepage": "Homepage", "hour": "Uur", "http-config-source": "HTTP Configuratiebron", "http-method": "HTTP Method", @@ -792,7 +790,6 @@ "my-data": "Mijn data", "name": "Naam", "name-lowercase": "naam", - "navigation": "Navigation", "need-help": "Need Help", "new": "Nieuw", "new-password": "Nieuw wachtwoord", @@ -1499,7 +1496,7 @@ "custom-properties-description": "Leg aangepaste metadata vast om uw data-assets te verrijken door het uitbreiden van de attributen.", "custom-property-is-set-to-message": "{{fieldName}} is set to", "custom-property-name-validation": "De naam moet beginnen met een kleine letter zonder spaties, underscores of punten.", - "customize-landing-page-header": "Customize {{pageName}} for Persona \"<0>{{persona}}\"", + "customize-landing-page-header": "Pas de landingspagina aan voor Persona \"<0>{{persona}}\"", "customize-open-metadata-description": "De OpenMetadata UX aanpassen aan de behoeften van uw organisatie en team.", "data-asset-has-been-action-type": "Data-asset is {{actionType}}", "data-insight-alert-destination-description": "Stuur e-mailmeldingen naar beheerders of teams.", @@ -1700,7 +1697,6 @@ "no-config-available": "Geen connectieconfiguraties beschikbaar.", "no-config-plural": "Geen configuraties.", "no-custom-properties-entity": "Er zijn momenteel geen aangepaste eigenschappen gedefinieerd voor het {{entity}} Data Asset. Raadpleeg <0>{{docs}} voor meer informatie over het toevoegen van aangepaste eigenschappen.", - "no-customization-available": "No customization available for this tab", "no-data": "Geen data", "no-data-assets": "Welcome to OpenMetadata! It looks like no data assets have been added yet. Check out our <0>How to Get Started guide to begin.", "no-data-available": "Geen data beschikbaar.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json index dc1eb20ba9b5..5c56d7b58813 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json @@ -260,7 +260,6 @@ "custom-theme": "تم سفارشی", "customise": "سفارشی‌سازی", "customize-entity": "سفارشی‌سازی {{entity}}", - "customize-ui": "Customize UI", "dag": "دگ", "dag-view": "نمای دگ", "daily-active-users-on-the-platform": "کاربران فعال روزانه در پلتفرم", @@ -593,7 +592,6 @@ "hide-deleted-entity": "مخفی کردن {{entity}} حذف شده", "history": "تاریخچه", "home": "خانه", - "homepage": "Homepage", "hour": "ساعت", "http-config-source": "منبع پیکربندی HTTP", "http-method": "HTTP Method", @@ -792,7 +790,6 @@ "my-data": "داده‌های من", "name": "نام", "name-lowercase": "نام", - "navigation": "Navigation", "need-help": "نیاز به کمک", "new": "جدید", "new-password": "رمز عبور جدید", @@ -1499,7 +1496,7 @@ "custom-properties-description": "متادیتای سفارشی را برای غنی‌سازی دارایی‌های داده‌ای خود با افزودن ویژگی‌های اضافی ضبط کنید.", "custom-property-is-set-to-message": "{{fieldName}} به مقدار", "custom-property-name-validation": "نام باید با حرف کوچک شروع شود و هیچ فاصله، زیرخط یا نقطه‌ای نداشته باشد.", - "customize-landing-page-header": "Customize {{pageName}} for Persona \"<0>{{persona}}\"", + "customize-landing-page-header": "سفارشی‌سازی صفحه اصلی برای نقش \"<0>{{persona}}\"", "customize-open-metadata-description": "تجربه کاربری OpenMetadata را برای نیازهای سازمانی و تیمی خود تنظیم کنید.", "data-asset-has-been-action-type": "دارایی داده‌ای {{actionType}} شده است", "data-insight-alert-destination-description": "اعلان‌های ایمیلی را به مدیران یا تیم‌ها ارسال کنید.", @@ -1700,7 +1697,6 @@ "no-config-available": "هیچ پیکربندی اتصالی در دسترس نیست.", "no-config-plural": "هیچ پیکربندی‌ای وجود ندارد.", "no-custom-properties-entity": "در حال حاضر هیچ ویژگی سفارشی برای دارایی داده {{entity}} تعریف نشده است. برای یادگیری نحوه افزودن ویژگی‌های سفارشی، لطفاً به <0>{{docs}} مراجعه کنید.", - "no-customization-available": "No customization available for this tab", "no-data": "بدون داده", "no-data-assets": "به OpenMetadata خوش آمدید! به نظر می‌رسد هنوز هیچ دارایی داده‌ای اضافه نشده است. راهنمای <0>چگونه شروع کنیم را بررسی کنید تا شروع کنید.", "no-data-available": "بدون داده در دسترس.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json index affb40ecc1ab..4c4cf535c277 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json @@ -260,7 +260,6 @@ "custom-theme": "Custom Theme", "customise": "Personalizar", "customize-entity": "Personalizar {{entity}}", - "customize-ui": "Customize UI", "dag": "DAG", "dag-view": "Visualização DAG", "daily-active-users-on-the-platform": "Usuários Ativos Diários na Plataforma", @@ -593,7 +592,6 @@ "hide-deleted-entity": "Ocultar {{entity}} Excluída", "history": "Histórico", "home": "Início", - "homepage": "Homepage", "hour": "Hora", "http-config-source": "Fonte de Configuração HTTP", "http-method": "HTTP Method", @@ -792,7 +790,6 @@ "my-data": "Meus Dados", "name": "Nome", "name-lowercase": "nome", - "navigation": "Navigation", "need-help": "Need Help", "new": "Novo", "new-password": "Nova Senha", @@ -1499,7 +1496,7 @@ "custom-properties-description": " Capture custom metadata to enrich your data assets by extending the attributes.", "custom-property-is-set-to-message": "{{fieldName}} is set to", "custom-property-name-validation": "O nome deve começar com letra minúscula sem espaço, sublinhado ou pontos.", - "customize-landing-page-header": "Customize {{pageName}} for Persona \"<0>{{persona}}\"", + "customize-landing-page-header": "Personalize a Página de Entrada para a Persona \"<0>{{persona}}\"", "customize-open-metadata-description": "Tailor the OpenMetadata UX to suit your organizational and team needs.", "data-asset-has-been-action-type": "O Ativo de Dados foi {{actionType}}", "data-insight-alert-destination-description": "Envie notificações por e-mail para administradores ou equipes.", @@ -1700,7 +1697,6 @@ "no-config-available": "Nenhuma Configuração de Conexão disponível.", "no-config-plural": "Nenhuma Configuração.", "no-custom-properties-entity": "Atualmente, não há Propriedades Personalizadas definidas para o Ativo de Dados {{entity}}. Para saber como adicionar Propriedades Personalizadas, consulte <0>{{docs}}.", - "no-customization-available": "No customization available for this tab", "no-data": "Sem dados", "no-data-assets": "Welcome to OpenMetadata! It looks like no data assets have been added yet. Check out our <0>How to Get Started guide to begin.", "no-data-available": "Nenhum dado disponível.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json index 1aae806030dc..9fc8129ca7aa 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json @@ -260,7 +260,6 @@ "custom-theme": "Custom Theme", "customise": "Personalizar", "customize-entity": "Personalizar {{entity}}", - "customize-ui": "Customize UI", "dag": "DAG", "dag-view": "Visualização DAG", "daily-active-users-on-the-platform": "Utilizadors Ativos Diários na Plataforma", @@ -593,7 +592,6 @@ "hide-deleted-entity": "Ocultar {{entity}} Excluída", "history": "Histórico", "home": "Início", - "homepage": "Homepage", "hour": "Hora", "http-config-source": "Fonte de Configuração HTTP", "http-method": "HTTP Method", @@ -792,7 +790,6 @@ "my-data": "Meus Dados", "name": "Nome", "name-lowercase": "nome", - "navigation": "Navigation", "need-help": "Need Help", "new": "Novo", "new-password": "Nova Senha", @@ -1700,7 +1697,6 @@ "no-config-available": "Nenhuma Configuração de Conexão disponível.", "no-config-plural": "Nenhuma Configuração.", "no-custom-properties-entity": "Atualmente, não existem Propriedades Personalizadas definidas para o Ativo de Dados {{entity}}. Para descobrir como adicionar Propriedades Personalizadas, consulte <0>{{docs}}", - "no-customization-available": "No customization available for this tab", "no-data": "Sem dados", "no-data-assets": "Welcome to OpenMetadata! It looks like no data assets have been added yet. Check out our <0>How to Get Started guide to begin.", "no-data-available": "Nenhum dado disponível.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json index 7c66fe72cb72..6c9ac09dc76a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json @@ -260,7 +260,6 @@ "custom-theme": "Custom Theme", "customise": "Customise", "customize-entity": "Customize {{entity}}", - "customize-ui": "Customize UI", "dag": "Dag", "dag-view": "Просмотр DAG", "daily-active-users-on-the-platform": "Количество активных пользователей на платформе", @@ -593,7 +592,6 @@ "hide-deleted-entity": "Скрыть удаленные {{entity}}", "history": "History", "home": "Домой", - "homepage": "Homepage", "hour": "Час", "http-config-source": "Источник конфигурации HTTP", "http-method": "HTTP Method", @@ -792,7 +790,6 @@ "my-data": "Мои данные", "name": "Наименование", "name-lowercase": "наименование", - "navigation": "Navigation", "need-help": "Need Help", "new": "Новый", "new-password": "Новый пароль", @@ -1499,7 +1496,7 @@ "custom-properties-description": " Capture custom metadata to enrich your data assets by extending the attributes.", "custom-property-is-set-to-message": "{{fieldName}} is set to", "custom-property-name-validation": "Имя должно начинаться со строчной буквы без пробелов, подчеркивания и точек.", - "customize-landing-page-header": "Customize {{pageName}} for Persona \"<0>{{persona}}\"", + "customize-landing-page-header": "Customize Landing Page for Persona \"<0>{{persona}}\"", "customize-open-metadata-description": "Tailor the OpenMetadata UX to suit your organizational and team needs.", "data-asset-has-been-action-type": "Объект данных был {{actionType}}", "data-insight-alert-destination-description": "Отправляйте уведомления по электронной почте администраторам или командам.", @@ -1700,7 +1697,6 @@ "no-config-available": "Нет доступных конфигураций подключения.", "no-config-plural": "No Configs.", "no-custom-properties-entity": "В настоящее время для {{entity}} Data Asset не определены пользовательские свойства. Чтобы узнать, как добавить пользовательские свойства, обратитесь к <0>{{docs}}", - "no-customization-available": "No customization available for this tab", "no-data": "Нет данных", "no-data-assets": "Welcome to OpenMetadata! It looks like no data assets have been added yet. Check out our <0>How to Get Started guide to begin.", "no-data-available": "Данные недоступны.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json index 0de47bc325d6..22787ea96f88 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json @@ -260,7 +260,6 @@ "custom-theme": "ธีมที่กำหนดเอง", "customise": "ปรับแต่ง", "customize-entity": "ปรับแต่ง {{entity}}", - "customize-ui": "ปรับแต่ง UI", "dag": "Dag", "dag-view": "มุมมอง DAG", "daily-active-users-on-the-platform": "ผู้ใช้ที่ใช้งานอยู่รายวันบนแพลตฟอร์ม", @@ -593,7 +592,6 @@ "hide-deleted-entity": "ซ่อน {{entity}} ที่ถูกลบ", "history": "ประวัติ", "home": "หน้าแรก", - "homepage": "หน้าแรก", "hour": "ชั่วโมง", "http-config-source": "แหล่งที่มาของการตั้งค่า HTTP", "http-method": "HTTP Method", @@ -792,7 +790,6 @@ "my-data": "ข้อมูลของฉัน", "name": "ชื่อ", "name-lowercase": "ชื่อ", - "navigation": "การนำทาง", "need-help": "ต้องการความช่วยเหลือ", "new": "ใหม่", "new-password": "รหัสผ่านใหม่", @@ -1700,7 +1697,6 @@ "no-config-available": "ไม่มีการตั้งค่าการเชื่อมต่อที่สามารถใช้งานได้", "no-config-plural": "ไม่มีการตั้งค่า", "no-custom-properties-entity": "ขณะนี้ไม่มีการกำหนดคุณสมบัติเฉพาะสำหรับสินทรัพย์ข้อมูล {{entity}} หากต้องการเรียนรู้วิธีการเพิ่มคุณสมบัติเฉพาะ โปรดดูที่ <0>{{docs}}.", - "no-customization-available": "ไม่มีการปรับแต่งให้ใช้งานได้สำหรับแท็บนี้", "no-data": "ไม่มีข้อมูล", "no-data-assets": "ยินดีต้อนรับสู่ OpenMetadata! ดูเหมือนว่ายังไม่มีสินทรัพย์ข้อมูลถูกเพิ่ม ตรวจสอบเรา <0>วิธีการเริ่มต้นใช้งาน เพื่อเริ่มต้น", "no-data-available": "ไม่มีข้อมูลที่สามารถใช้งานได้", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json index c59d5059a02b..4bcd4f27a5d1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json @@ -260,7 +260,6 @@ "custom-theme": "自定义主题", "customise": "自定义", "customize-entity": "自定义{{entity}}", - "customize-ui": "Customize UI", "dag": "DAG", "dag-view": "DAG 视图", "daily-active-users-on-the-platform": "平台上的每日活跃用户", @@ -593,7 +592,6 @@ "hide-deleted-entity": "隐藏已删除的{{entity}}", "history": "历史", "home": "主页", - "homepage": "Homepage", "hour": "小时", "http-config-source": "HTTP 配置源", "http-method": "HTTP Method", @@ -792,7 +790,6 @@ "my-data": "我的数据", "name": "名称", "name-lowercase": "名称", - "navigation": "Navigation", "need-help": "Need Help", "new": "新", "new-password": "新密码", @@ -1499,7 +1496,7 @@ "custom-properties-description": " 获取自定义元数据, 通过扩展属性来丰富数据资产", "custom-property-is-set-to-message": "{{fieldName}}设置为", "custom-property-name-validation": "命名首字母必须是小写字母不能为空格、下划线或点号", - "customize-landing-page-header": "Customize {{pageName}} for Persona \"<0>{{persona}}\"", + "customize-landing-page-header": "为用户角色\"<0>{{persona}}\"自定义登陆页面", "customize-open-metadata-description": "自定义 OpenMetadata, 以满足您的组织和团队需求", "data-asset-has-been-action-type": "数据资产已{{actionType}}", "data-insight-alert-destination-description": "发送通知邮件给管理员或团队", @@ -1700,7 +1697,6 @@ "no-config-available": "没有可用的连接配置", "no-config-plural": "No Configs.", "no-custom-properties-entity": "当前没有为 {{entity}} 数据资产定义自定义属性。要了解如何添加自定义属性,请参考 <0>{{docs}}", - "no-customization-available": "No customization available for this tab", "no-data": "没有数据", "no-data-assets": "欢迎访问 OpenMetadata! 看起来还没有添加数据资产, 请查看我们的<0>How to Get Started开始指南", "no-data-available": "没有可用的数据", diff --git a/openmetadata-ui/src/main/resources/ui/src/mocks/MyDataPage.mock.tsx b/openmetadata-ui/src/main/resources/ui/src/mocks/MyDataPage.mock.tsx index bacc213dd97c..f500c9e666d4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/mocks/MyDataPage.mock.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/mocks/MyDataPage.mock.tsx @@ -16,7 +16,6 @@ import { LandingPageWidgetKeys } from '../enums/CustomizablePage.enum'; import { Document } from '../generated/entity/docStore/document'; import { Thread, ThreadType } from '../generated/entity/feed/thread'; import { User } from '../generated/entity/teams/user'; -import { PageType } from '../generated/system/ui/page'; import { Paging } from '../generated/type/paging'; import { WidgetConfig } from '../pages/CustomizablePage/CustomizablePage.interface'; @@ -134,12 +133,9 @@ export const mockDocumentData: Document = { fullyQualifiedName: `persona.${mockPersonaName}.Page.LandingPage`, entityType: 'Page', data: { - pages: [ - { - pageType: PageType.LandingPage, - layout: mockCustomizedLayout, - }, - ], + page: { + layout: mockCustomizedLayout, + }, }, }; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/CustomizablePage/CustomizablePage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/CustomizablePage/CustomizablePage.test.tsx index 791cdd6491cd..5d682931c0cc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/CustomizablePage/CustomizablePage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/CustomizablePage/CustomizablePage.test.tsx @@ -12,10 +12,13 @@ */ import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React from 'react'; import { useParams } from 'react-router-dom'; -import { Page, PageType } from '../../generated/system/ui/page'; +import { LandingPageWidgetKeys } from '../../enums/CustomizablePage.enum'; +import { PageType } from '../../generated/system/ui/page'; import { + mockCustomizePageClassBase, mockDocumentData, mockPersonaDetails, mockPersonaName, @@ -57,6 +60,10 @@ jest.mock('../../components/common/Loader/Loader', () => { return jest.fn().mockImplementation(() =>
Loader
); }); +jest.mock('../../utils/CustomizePageClassBase', () => { + return mockCustomizePageClassBase; +}); + jest.mock('../../rest/DocStoreAPI', () => ({ createDocument: jest .fn() @@ -88,33 +95,6 @@ jest.mock('react-router-dom', () => ({ Link: jest.fn().mockImplementation(() =>
Link
), })); -jest.mock('./CustomizeStore', () => ({ - useCustomizeStore: jest.fn().mockImplementation(() => ({ - document: mockDocumentData, - setDocument: jest.fn(), - getNavigation: jest.fn(), - currentPage: {} as Page, - getPage: jest.fn(), - setCurrentPageType: jest.fn(), - })), -})); - -jest.mock( - '../../components/MyData/CustomizableComponents/CustomizeMyData/CustomizeMyData', - () => { - return jest.fn().mockImplementation(() =>
CustomizeMyData
); - } -); - -jest.mock( - '../../components/MyData/CustomizableComponents/CustomiseGlossaryTermDetailPage/CustomiseGlossaryTermDetailPage', - () => { - return jest - .fn() - .mockImplementation(() =>
CustomizeGlossaryTermDetailPage
); - } -); - describe('CustomizablePage component', () => { it('CustomizablePage should show ErrorPlaceholder if the API to fetch the persona details fails', async () => { (getPersonaByName as jest.Mock).mockImplementationOnce(() => @@ -134,7 +114,7 @@ describe('CustomizablePage component', () => { expect(screen.getByText('Loader')).toBeInTheDocument(); expect(screen.queryByText('ErrorPlaceHolder')).toBeNull(); - expect(screen.queryByTestId('CustomizeMyData')).toBeNull(); + expect(screen.queryByTestId('customize-my-data')).toBeNull(); }); }); @@ -143,8 +123,22 @@ describe('CustomizablePage component', () => { render(); }); - expect(screen.getByText('CustomizeMyData')).toBeInTheDocument(); + expect(screen.getByTestId('customize-my-data')).toBeInTheDocument(); expect(screen.queryByText('ErrorPlaceHolder')).toBeNull(); + expect( + screen.getByText(LandingPageWidgetKeys.ACTIVITY_FEED) + ).toBeInTheDocument(); + expect( + screen.getByText(LandingPageWidgetKeys.FOLLOWING) + ).toBeInTheDocument(); + expect( + screen.getByText(LandingPageWidgetKeys.RECENTLY_VIEWED) + ).toBeInTheDocument(); + expect(screen.queryByText(LandingPageWidgetKeys.MY_DATA)).toBeNull(); + expect(screen.queryByText(LandingPageWidgetKeys.KPI)).toBeNull(); + expect( + screen.queryByText(LandingPageWidgetKeys.TOTAL_DATA_ASSETS) + ).toBeNull(); }); it('CustomizablePage should pass the default layout data when no layout is present for the persona', async () => { @@ -159,11 +153,68 @@ describe('CustomizablePage component', () => { render(); }); - expect(screen.queryByText('CustomizeMyData')).toBeInTheDocument(); + expect(screen.getByTestId('customize-my-data')).toBeInTheDocument(); expect(screen.queryByText('ErrorPlaceHolder')).toBeNull(); + expect( + screen.getByText(LandingPageWidgetKeys.ACTIVITY_FEED) + ).toBeInTheDocument(); + expect( + screen.getByText(LandingPageWidgetKeys.FOLLOWING) + ).toBeInTheDocument(); + expect( + screen.getByText(LandingPageWidgetKeys.RECENTLY_VIEWED) + ).toBeInTheDocument(); + expect(screen.getByText(LandingPageWidgetKeys.MY_DATA)).toBeInTheDocument(); + expect(screen.getByText(LandingPageWidgetKeys.KPI)).toBeInTheDocument(); + expect( + screen.getByText(LandingPageWidgetKeys.TOTAL_DATA_ASSETS) + ).toBeInTheDocument(); }); - it('CustomizablePage should return ErrorPlaceHolder for invalid page FQN', async () => { + it('CustomizablePage should update the layout when layout data is present for persona', async () => { + await act(async () => { + render(); + }); + + const saveCurrentPageLayoutBtn = screen.getByText( + 'handleSaveCurrentPageLayout' + ); + + await act(async () => { + userEvent.click(saveCurrentPageLayoutBtn); + }); + + expect(mockShowSuccessToast).toHaveBeenCalledWith( + 'server.page-layout-operation-success' + ); + }); + + it('CustomizablePage should save the layout when no layout data present for persona', async () => { + (getDocumentByFQN as jest.Mock).mockImplementationOnce(() => + Promise.reject({ + response: { + status: 404, + }, + }) + ); + await act(async () => { + render(); + }); + + const saveCurrentPageLayoutBtn = screen.getByText( + 'handleSaveCurrentPageLayout' + ); + + await act(async () => { + userEvent.click(saveCurrentPageLayoutBtn); + }); + + expect(mockShowSuccessToast).toHaveBeenCalledWith( + 'server.page-layout-operation-success' + ); + }); + + it('CustomizablePage should return null for invalid page FQN', async () => { (useParams as jest.Mock).mockImplementation(() => ({ fqn: mockPersonaName, pageFqn: 'invalidName', @@ -173,7 +224,7 @@ describe('CustomizablePage component', () => { render(); }); - expect(screen.queryByText('ErrorPlaceHolder')).toBeInTheDocument(); + expect(screen.queryByText('ErrorPlaceHolder')).toBeNull(); expect(screen.queryByText('Loader')).toBeNull(); expect(screen.queryByTestId('customize-my-data')).toBeNull(); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/CustomizablePage/CustomizablePage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/CustomizablePage/CustomizablePage.tsx index 3f3bf34da1f1..ee21418bd24d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/CustomizablePage/CustomizablePage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/CustomizablePage/CustomizablePage.tsx @@ -13,13 +13,12 @@ import { Col, Row, Typography } from 'antd'; import { AxiosError } from 'axios'; import { compare } from 'fast-json-patch'; -import { cloneDeep, isUndefined } from 'lodash'; -import React, { useEffect, useState } from 'react'; +import { isUndefined } from 'lodash'; +import React, { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link, useParams } from 'react-router-dom'; import ErrorPlaceHolder from '../../components/common/ErrorWithPlaceholder/ErrorPlaceHolder'; import Loader from '../../components/common/Loader/Loader'; -import CustomizeGlossaryTermDetailPage from '../../components/MyData/CustomizableComponents/CustomiseGlossaryTermDetailPage/CustomiseGlossaryTermDetailPage'; import CustomizeMyData from '../../components/MyData/CustomizableComponents/CustomizeMyData/CustomizeMyData'; import { GlobalSettingOptions, @@ -30,8 +29,7 @@ import { ERROR_PLACEHOLDER_TYPE } from '../../enums/common.enum'; import { EntityType } from '../../enums/entity.enum'; import { Document } from '../../generated/entity/docStore/document'; import { Persona } from '../../generated/entity/teams/persona'; -import { Page, PageType } from '../../generated/system/ui/page'; -import { UICustomization } from '../../generated/system/ui/uiCustomization'; +import { PageType } from '../../generated/system/ui/page'; import { useApplicationStore } from '../../hooks/useApplicationStore'; import { useFqn } from '../../hooks/useFqn'; import { @@ -41,108 +39,91 @@ import { } from '../../rest/DocStoreAPI'; import { getPersonaByName } from '../../rest/PersonaAPI'; import { Transi18next } from '../../utils/CommonUtils'; +import customizePageClassBase from '../../utils/CustomizePageClassBase'; import { getSettingPath } from '../../utils/RouterUtils'; import { showErrorToast, showSuccessToast } from '../../utils/ToastUtils'; -import { CustomizeTableDetailPage } from '../CustomizeTableDetailPage/CustomizeTableDetailPage'; -import { SettingsNavigationPage } from '../SettingsNavigationPage/SettingsNavigationPage'; -import { useCustomizeStore } from './CustomizeStore'; export const CustomizablePage = () => { - const { pageFqn } = useParams<{ pageFqn: string }>(); - const { fqn: personaFQN } = useFqn(); + const { pageFqn } = useParams<{ pageFqn: PageType }>(); + const { fqn: decodedPageFQN } = useFqn(); const { t } = useTranslation(); const { theme } = useApplicationStore(); - const [isLoading, setIsLoading] = useState(true); + const [page, setPage] = useState({} as Document); + const [editedPage, setEditedPage] = useState({} as Document); + const [isLoading, setIsLoading] = useState(false); + const [isPersonaLoading, setIsPersonaLoading] = useState(true); const [personaDetails, setPersonaDetails] = useState(); - const { - document, - setDocument, - getNavigation, - currentPage, - getPage, - setCurrentPageType, - } = useCustomizeStore(); + const [saveCurrentPageLayout, setSaveCurrentPageLayout] = useState(false); - const handlePageCustomizeSave = async (newPage?: Page) => { - if (!document) { - return; - } - try { - let response: Document; - const newDoc = cloneDeep(document); - const pageData = getPage(pageFqn); + const handlePageDataChange = useCallback((newPageData: Document) => { + setEditedPage(newPageData); + }, []); - if (pageData) { - newDoc.data.pages = newPage - ? newDoc.data?.pages?.map((p: Page) => - p.pageType === pageFqn ? newPage : p - ) - : newDoc.data?.pages.filter((p: Page) => p.pageType !== pageFqn); - } else { - newDoc.data = { - ...newDoc.data, - pages: [...(newDoc.data.pages ?? []), newPage], - }; - } + const handleSaveCurrentPageLayout = useCallback((value: boolean) => { + setSaveCurrentPageLayout(value); + }, []); - if (document.id) { - const jsonPatch = compare(document, newDoc); + const fetchPersonaDetails = useCallback(async () => { + try { + setIsPersonaLoading(true); + const response = await getPersonaByName(decodedPageFQN); - response = await updateDocument(document.id ?? '', jsonPatch); - } else { - response = await createDocument({ - ...newDoc, - domain: newDoc.domain?.fullyQualifiedName, - }); + setPersonaDetails(response); + } catch { + // No error handling needed + // No data placeholder will be shown in case of failure + } finally { + setIsPersonaLoading(false); + } + }, [decodedPageFQN]); + + const fetchDocument = async () => { + if (!isUndefined(personaDetails)) { + const pageLayoutFQN = `${EntityType.PERSONA}.${decodedPageFQN}.${EntityType.PAGE}.${pageFqn}`; + try { + setIsLoading(true); + const pageData = await getDocumentByFQN(pageLayoutFQN); + + setPage(pageData); + setEditedPage(pageData); + } catch (error) { + if ((error as AxiosError).response?.status === ClientErrors.NOT_FOUND) { + setPage({ + name: `${personaDetails.name}-${decodedPageFQN}`, + fullyQualifiedName: pageLayoutFQN, + entityType: EntityType.PAGE, + data: { + page: { layout: customizePageClassBase.defaultLayout }, + }, + }); + } else { + showErrorToast(error as AxiosError); + } + } finally { + setIsLoading(false); } - setDocument(response); - - showSuccessToast( - t('server.page-layout-operation-success', { - operation: document.id - ? t('label.updated-lowercase') - : t('label.created-lowercase'), - }) - ); - } catch (error) { - // Error - showErrorToast( - t('server.page-layout-operation-error', { - operation: document.id - ? t('label.updating-lowercase') - : t('label.creating-lowercase'), - }) - ); } }; - const handleNavigationSave = async ( - uiNavigation: UICustomization['navigation'] - ) => { - if (!document) { - return; - } + const handleSave = async () => { try { let response: Document; - const newDoc = cloneDeep(document); - newDoc.data.navigation = uiNavigation; + if (page.id) { + const jsonPatch = compare(page, editedPage); - if (document.id) { - const jsonPatch = compare(document, newDoc); - - response = await updateDocument(document.id ?? '', jsonPatch); + response = await updateDocument(page.id ?? '', jsonPatch); } else { response = await createDocument({ - ...newDoc, - domain: newDoc.domain?.fullyQualifiedName, + ...editedPage, + domain: editedPage.domain?.fullyQualifiedName, }); } - setDocument(response); - + setPage(response); + setEditedPage(response); showSuccessToast( t('server.page-layout-operation-success', { - operation: document.id + operation: page.id ? t('label.updated-lowercase') : t('label.created-lowercase'), }) @@ -151,7 +132,7 @@ export const CustomizablePage = () => { // Error showErrorToast( t('server.page-layout-operation-error', { - operation: document.id + operation: page.id ? t('label.updating-lowercase') : t('label.creating-lowercase'), }) @@ -159,47 +140,22 @@ export const CustomizablePage = () => { } }; - const initializeCustomizeStore = async () => { - setIsLoading(true); - const pageLayoutFQN = `${EntityType.PERSONA}.${personaFQN}`; - try { - const personaDetails = await getPersonaByName(personaFQN); - setPersonaDetails(personaDetails); - - if (personaDetails) { - try { - const pageData = await getDocumentByFQN(pageLayoutFQN); - - setDocument(pageData); - setCurrentPageType(pageFqn as PageType); - } catch (error) { - if ( - (error as AxiosError).response?.status === ClientErrors.NOT_FOUND - ) { - setDocument({ - name: `${personaDetails.name}-${personaFQN}`, - fullyQualifiedName: pageLayoutFQN, - entityType: EntityType.PAGE, - data: {}, - }); - setCurrentPageType(pageFqn as PageType); - } else { - showErrorToast(error as AxiosError); - } - } - } - } catch (error) { - showErrorToast(error as AxiosError); - } finally { - setIsLoading(false); + useEffect(() => { + if (saveCurrentPageLayout) { + handleSave(); + setSaveCurrentPageLayout(false); } - }; + }, [saveCurrentPageLayout]); useEffect(() => { - initializeCustomizeStore(); - }, []); + fetchPersonaDetails(); + }, [decodedPageFQN, pageFqn]); + + useEffect(() => { + fetchDocument(); + }, [personaDetails]); - if (isLoading) { + if (isLoading || isPersonaLoading) { return ; } @@ -233,45 +189,17 @@ export const CustomizablePage = () => { ); } - switch (pageFqn) { - case 'navigation': - return ( - - ); - - case PageType.LandingPage: - case 'homepage': - return ( - - ); - - case PageType.Glossary: - case PageType.GlossaryTerm: - return ( - - ); - case PageType.Table: - return ( - - ); - default: - return ; + if (pageFqn === PageType.LandingPage) { + return ( + + ); } + + return null; }; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/CustomizablePage/CustomizeStore.ts b/openmetadata-ui/src/main/resources/ui/src/pages/CustomizablePage/CustomizeStore.ts deleted file mode 100644 index b68c56c894fb..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/pages/CustomizablePage/CustomizeStore.ts +++ /dev/null @@ -1,100 +0,0 @@ -/* - * 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 { create } from 'zustand'; - -import { Document } from '../../generated/entity/docStore/document'; -import { Page, PageType } from '../../generated/system/ui/page'; -import { NavigationItem } from '../../generated/system/ui/uiCustomization'; - -interface CustomizePageStore { - document: Document | null; - currentPageType: PageType | null; - currentPage: Page | null; - currentPersonaDocStore: Document | null; - setDocument: (document: Document) => void; - - setPage: (page: Page) => void; - - getPage: (pageType: string) => Page; - - getNavigation: () => NavigationItem[]; - setCurrentPageType: (pageType: PageType) => void; - updateCurrentPage: (page: Page) => void; - setCurrentPersonaDocStore: (document: Document) => void; - resetCurrentPersonaDocStore: () => void; -} - -export const useCustomizeStore = create()((set, get) => ({ - document: null, - currentPage: null, - currentPageType: null, - currentPersonaDocStore: null, - setDocument: (document: Document) => { - set({ document }); - }, - - setPage: (page: Page) => { - const { document } = get(); - const newDocument = { - ...document, - data: { - ...document?.data, - pages: document?.data?.pages?.map((p: Page) => - p.pageType === page.pageType ? page : p - ), - }, - } as Document; - set({ document: newDocument }); - }, - - getPage: (pageType: string) => { - const { document } = get(); - - return document?.data?.pages?.find((p: Page) => p.pageType === pageType); - }, - - getNavigation: () => { - const { document } = get(); - - return document?.data?.navigation; - }, - - updateCurrentPage: (page: Page) => { - set({ currentPage: page }); - }, - - setCurrentPageType: (pageType: PageType) => { - const { getPage } = get(); - - set({ - currentPage: getPage(pageType) ?? { pageType }, - currentPageType: pageType, - }); - }, - - setCurrentPersonaDocStore: (document: Document) => { - set({ currentPersonaDocStore: document }); - }, - - reset: () => { - set({ document: null, currentPage: null }); - }, - - resetCurrentPage: () => { - set({ currentPage: null }); - }, - resetCurrentPersonaDocStore: () => { - set({ currentPersonaDocStore: null }); - }, -})); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/CustomizeTableDetailPage/CustomizeTableDetailPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/CustomizeTableDetailPage/CustomizeTableDetailPage.tsx deleted file mode 100644 index 080be151a1bc..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/pages/CustomizeTableDetailPage/CustomizeTableDetailPage.tsx +++ /dev/null @@ -1,85 +0,0 @@ -/* - * 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 { noop } from 'lodash'; -import React, { useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import gridBgImg from '../../assets/img/grid-bg-img.png'; -import { DataAssetsHeader } from '../../components/DataAssets/DataAssetsHeader/DataAssetsHeader.component'; -import { CustomizeTabWidget } from '../../components/Glossary/CustomiseWidgets/CustomizeTabWidget/CustomizeTabWidget'; -import { CustomizablePageHeader } from '../../components/MyData/CustomizableComponents/CustomizablePageHeader/CustomizablePageHeader'; -import { CustomizeMyDataProps } from '../../components/MyData/CustomizableComponents/CustomizeMyData/CustomizeMyData.interface'; -import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1'; -import { OperationPermission } from '../../context/PermissionProvider/PermissionProvider.interface'; -import { EntityType } from '../../enums/entity.enum'; -import { Table } from '../../generated/entity/data/table'; -import { Page, PageType } from '../../generated/system/ui/page'; -import { useGridLayoutDirection } from '../../hooks/useGridLayoutDirection'; -import { getDummyDataByPage } from '../../utils/CustomizePage/CustomizePageUtils'; -import { getEntityName } from '../../utils/EntityUtils'; -import { useCustomizeStore } from '../CustomizablePage/CustomizeStore'; - -export const CustomizeTableDetailPage = ({ - personaDetails, - onSaveLayout, -}: CustomizeMyDataProps) => { - const { t } = useTranslation(); - const { currentPage, currentPageType } = useCustomizeStore(); - - const handleReset = useCallback(async () => { - await onSaveLayout(); - }, [onSaveLayout]); - - const handleSave = async () => { - await onSaveLayout(currentPage ?? ({ pageType: currentPageType } as Page)); - }; - - const entityDummyData = getDummyDataByPage( - currentPageType as PageType - ) as unknown; - - // call the hook to set the direction of the grid layout - useGridLayoutDirection(); - - const asyncNoop = async () => { - noop(); - }; - - return ( - - -
- -
- -
- ); -}; 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..ab2d3592eb7a 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 @@ -279,11 +279,6 @@ const GlossaryPage = () => { if (isEmpty(jsonPatch)) { return; } - - const shouldRefreshTerms = jsonPatch.some((patch) => - patch.path.startsWith('/owners') - ); - try { const response = await patchGlossaryTerm(activeGlossary?.id, jsonPatch); if (response) { @@ -292,7 +287,6 @@ const GlossaryPage = () => { history.push(getGlossaryPath(response.fullyQualifiedName)); fetchGlossaryList(); } - shouldRefreshTerms && fetchGlossaryTermDetails(); } else { throw t('server.entity-updating-error', { entity: t('label.glossary-term'), diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx index 55565b683804..96e541a15516 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx @@ -33,7 +33,7 @@ import { import { EntityType } from '../../enums/entity.enum'; import { SearchIndex } from '../../enums/search.enum'; import { Thread } from '../../generated/entity/feed/thread'; -import { Page, PageType } from '../../generated/system/ui/page'; +import { PageType } from '../../generated/system/ui/page'; import { EntityReference } from '../../generated/type/entityReference'; import LimitWrapper from '../../hoc/LimitWrapper'; import { useApplicationStore } from '../../hooks/useApplicationStore'; @@ -42,7 +42,7 @@ import { getDocumentByFQN } from '../../rest/DocStoreAPI'; import { getActiveAnnouncement } from '../../rest/feedsAPI'; import { searchQuery } from '../../rest/searchAPI'; import { getWidgetFromKey } from '../../utils/CustomizableLandingPageUtils'; -import customizePageClassBase from '../../utils/CustomizeMyDataPageClassBase'; +import customizePageClassBase from '../../utils/CustomizePageClassBase'; import { showErrorToast } from '../../utils/ToastUtils'; import { WidgetConfig } from '../CustomizablePage/CustomizablePage.interface'; import './my-data.less'; @@ -78,18 +78,9 @@ const MyDataPage = () => { try { setIsLoading(true); if (!isEmpty(selectedPersona)) { - const pageFQN = `${EntityType.PERSONA}.${selectedPersona.fullyQualifiedName}`; - const docData = await getDocumentByFQN(pageFQN); - - const pageData = docData.data?.pages?.find( - (p: Page) => p.pageType === PageType.LandingPage - ) ?? { layout: [], pageType: PageType.LandingPage }; - - setLayout( - isEmpty(pageData.layout) - ? customizePageClassBase.defaultLayout - : pageData.layout - ); + const pageFQN = `${EntityType.PERSONA}.${selectedPersona.fullyQualifiedName}.${EntityType.PAGE}.${PageType.LandingPage}`; + const pageData = await getDocumentByFQN(pageFQN); + setLayout(pageData.data.page.layout); } else { setLayout(customizePageClassBase.defaultLayout); } @@ -223,7 +214,6 @@ const MyDataPage = () => { { return jest.fn().mockImplementation(() =>
Loader
); }); -jest.mock('../../utils/CustomizeMyDataPageClassBase', () => { +jest.mock('../../utils/CustomizePageClassBase', () => { return mockCustomizePageClassBase; }); jest.mock('../../components/PageLayoutV1/PageLayoutV1', () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/Persona/PersonaDetailsPage/PersonaDetailsPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/Persona/PersonaDetailsPage/PersonaDetailsPage.test.tsx index 40a57d7e2aaf..fe70f17845b6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/Persona/PersonaDetailsPage/PersonaDetailsPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/Persona/PersonaDetailsPage/PersonaDetailsPage.test.tsx @@ -17,7 +17,6 @@ import { waitForElementToBeRemoved, } from '@testing-library/react'; import React from 'react'; -import { MemoryRouter } from 'react-router-dom'; import { getPersonaByName, updatePersona } from '../../../rest/PersonaAPI'; import { PersonaDetailsPage } from './PersonaDetailsPage'; @@ -150,13 +149,9 @@ jest.mock( () => jest.fn().mockImplementation(() =>
EntityHeaderTitle
) ); -jest.mock('../../../hooks/useCustomLocation/useCustomLocation', () => { - return jest.fn().mockImplementation(() => ({ pathname: '', hash: '' })); -}); - describe('PersonaDetailsPage', () => { it('Component should render', async () => { - render(), { wrapper: MemoryRouter }; + render(); await waitForElementToBeRemoved(() => screen.getByTestId('loader')); @@ -175,7 +170,7 @@ describe('PersonaDetailsPage', () => { (getPersonaByName as jest.Mock).mockImplementationOnce(() => Promise.reject() ); - render(, { wrapper: MemoryRouter }); + render(); expect( await screen.findByText('NoDataPlaceholder.component') @@ -183,18 +178,20 @@ describe('PersonaDetailsPage', () => { }); it('handleAfterDeleteAction should call after delete', async () => { - render(, { wrapper: MemoryRouter }); + render(); const deleteBtn = await screen.findByTestId('delete-btn'); fireEvent.click(deleteBtn); - expect(mockUseHistory.push).toHaveBeenCalledWith('/settings/persona'); + expect(mockUseHistory.push).toHaveBeenCalledWith( + '/settings/members/persona' + ); }); it('handleDisplayNameUpdate should call after updating displayName', async () => { const mockUpdatePersona = updatePersona as jest.Mock; - render(, { wrapper: MemoryRouter }); + render(); const updateName = await screen.findByTestId('display-name-btn'); @@ -207,7 +204,7 @@ describe('PersonaDetailsPage', () => { it('add user should work', async () => { const mockUpdatePersona = updatePersona as jest.Mock; - render(, { wrapper: MemoryRouter }); + render(); const addUser = await screen.findByTestId('user-selectable-list'); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/Persona/PersonaDetailsPage/PersonaDetailsPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/Persona/PersonaDetailsPage/PersonaDetailsPage.tsx index bdc96d78643e..9ccda19b8761 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/Persona/PersonaDetailsPage/PersonaDetailsPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/Persona/PersonaDetailsPage/PersonaDetailsPage.tsx @@ -28,15 +28,16 @@ import { UserSelectableList } from '../../../components/common/UserSelectableLis import EntityHeaderTitle from '../../../components/Entity/EntityHeaderTitle/EntityHeaderTitle.component'; import { EntityName } from '../../../components/Modals/EntityNameModal/EntityNameModal.interface'; import PageLayoutV1 from '../../../components/PageLayoutV1/PageLayoutV1'; -import { CustomizeUI } from '../../../components/Settings/Persona/CustomizeUI/CustomizeUI'; import { UsersTab } from '../../../components/Settings/Users/UsersTab/UsersTabs.component'; -import { GlobalSettingsMenuCategory } from '../../../constants/GlobalSettings.constants'; +import { + GlobalSettingOptions, + GlobalSettingsMenuCategory, +} from '../../../constants/GlobalSettings.constants'; import { usePermissionProvider } from '../../../context/PermissionProvider/PermissionProvider'; import { ResourceEntity } from '../../../context/PermissionProvider/PermissionProvider.interface'; import { SIZE } from '../../../enums/common.enum'; import { EntityType } from '../../../enums/entity.enum'; import { Persona } from '../../../generated/entity/teams/persona'; -import useCustomLocation from '../../../hooks/useCustomLocation/useCustomLocation'; import { useFqn } from '../../../hooks/useFqn'; import { getPersonaByName, updatePersona } from '../../../rest/PersonaAPI'; import { getEntityName } from '../../../utils/EntityUtils'; @@ -54,11 +55,6 @@ export const PersonaDetailsPage = () => { const [entityPermission, setEntityPermission] = useState( DEFAULT_ENTITY_PERMISSION ); - const location = useCustomLocation(); - const activeKey = useMemo( - () => location.hash?.replace('#', '') || 'users', - [location] - ); const { getEntityPermissionByFqn } = usePermissionProvider(); @@ -66,7 +62,10 @@ export const PersonaDetailsPage = () => { () => [ { name: t('label.persona-plural'), - url: getSettingPath(GlobalSettingsMenuCategory.PERSONA), + url: getSettingPath( + GlobalSettingsMenuCategory.MEMBERS, + GlobalSettingOptions.PERSONA + ), }, { name: getEntityName(personaDetails), @@ -165,35 +164,14 @@ export const PersonaDetailsPage = () => { ); const handleAfterDeleteAction = () => { - history.push(getSettingPath(GlobalSettingsMenuCategory.PERSONA)); - }; - - const handleTabChange = (activeKey: string) => { - history.push({ - hash: activeKey, - }); + history.push( + getSettingPath( + GlobalSettingsMenuCategory.MEMBERS, + GlobalSettingOptions.PERSONA + ) + ); }; - const tabItems = useMemo(() => { - return [ - { - label: t('label.user-plural'), - key: 'users', - children: ( - - ), - }, - { - label: t('label.customize-ui'), - key: 'customize-ui', - children: , - }, - ]; - }, [personaDetails]); - if (isLoading) { return ; } @@ -251,8 +229,19 @@ export const PersonaDetailsPage = () => {
+ ), + }, + ]} tabBarExtraContent={ { } - onChange={handleTabChange} /> diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/Persona/PersonaListPage/PersonaPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/Persona/PersonaListPage/PersonaPage.test.tsx index 4801d246c694..7f1682c942b6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/Persona/PersonaListPage/PersonaPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/Persona/PersonaListPage/PersonaPage.test.tsx @@ -89,39 +89,28 @@ jest.mock('../../../rest/PersonaAPI', () => { describe('PersonaPage', () => { it('Component should render', async () => { - await act(async () => { + act(() => { render(); }); + expect( + await screen.findByTestId('user-list-v1-component') + ).toBeInTheDocument(); + expect(await screen.findByTestId('add-persona-button')).toBeInTheDocument(); + expect( + await screen.findByText('TitleBreadcrumb.component') + ).toBeInTheDocument(); + expect(await screen.findByText('PageHeader.component')).toBeInTheDocument(); expect( await screen.findByText('ErrorPlaceHolder.component') ).toBeInTheDocument(); }); it('AddEditPersonaForm should render onclick of add persona', async () => { - (getAllPersonas as jest.Mock).mockImplementationOnce(() => - Promise.resolve({ - data: [ - { - id: 'id1', - name: 'sales', - fullyQualifiedName: 'sales', - displayName: 'Sales', - }, - { - id: 'id2', - name: 'purchase', - fullyQualifiedName: 'purchase', - displayName: 'purchase', - }, - ], - }) - ); act(() => { render(); }); const addPersonaButton = await screen.findByTestId('add-persona-button'); - await act(async () => { fireEvent.click(addPersonaButton); }); @@ -133,24 +122,6 @@ describe('PersonaPage', () => { it('handlePersonaAddEditSave should be called onClick of save button', async () => { const mockGetAllPersonas = getAllPersonas as jest.Mock; - (getAllPersonas as jest.Mock).mockImplementationOnce(() => - Promise.resolve({ - data: [ - { - id: 'id1', - name: 'sales', - fullyQualifiedName: 'sales', - displayName: 'Sales', - }, - { - id: 'id2', - name: 'purchase', - fullyQualifiedName: 'purchase', - displayName: 'purchase', - }, - ], - }) - ); act(() => { render(); }); 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..e0cdef31555c 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 @@ -55,7 +55,11 @@ export const PersonaPage = () => { } = usePaging(); const breadcrumbs: TitleBreadcrumbProps['titleLinks'] = useMemo( - () => getSettingPageEntityBreadCrumb(GlobalSettingsMenuCategory.PERSONA), + () => + getSettingPageEntityBreadCrumb( + GlobalSettingsMenuCategory.MEMBERS, + t('label.persona-plural') + ), [] ); @@ -88,9 +92,8 @@ export const PersonaPage = () => { const errorPlaceHolder = useMemo( () => ( - + { } }; - if (isEmpty(persona) && !isLoading) { - return ( - <> - {errorPlaceHolder} - {Boolean(addEditPersona) && ( - - )} - - ); - } - return ( - + @@ -169,6 +160,8 @@ export const PersonaPage = () => { ))} + {isEmpty(persona) && !isLoading && errorPlaceHolder} + {showPagination && ( { /> )} + {Boolean(addEditPersona) && ( Promise; - currentNavigation?: NavigationItem[]; -} - -export const SettingsNavigationPage = ({ - onSave, - currentNavigation, -}: Props) => { - const { fqn } = useFqn(); - const [isPersonaLoading, setIsPersonaLoading] = useState(true); - const [personaDetails, setPersonaDetails] = useState(null); - const { t } = useTranslation(); - const [saving, setSaving] = useState(false); - const [targetKeys, setTargetKeys] = useState( - currentNavigation - ? getNestedKeysFromNavigationItems(currentNavigation) - : getNestedKeys(sidebarOptions) - ); - - const treeData = filterAndArrangeTreeByKeys( - cloneDeep(sidebarOptions), - targetKeys - ); - - const handleChange = (newTargetKeys: string[]) => { - setTargetKeys(newTargetKeys); - }; - - const titleLinks = useMemo( - () => [ - { - name: 'Settings', - url: '/settings', - }, - ...(personaDetails - ? [ - { - name: getEntityName(personaDetails), - url: getPersonaDetailsPath(fqn), - }, - ] - : []), - ], - [personaDetails?.name] - ); - - const fetchPersonaDetails = async () => { - try { - setIsPersonaLoading(true); - const persona = await getPersonaByName(fqn); - - setPersonaDetails(persona); - } catch (error) { - showErrorToast(error as AxiosError); - } finally { - setIsPersonaLoading(false); - } - }; - - const handleSave = async () => { - setSaving(true); - const navigationItems = getNavigationItems( - filterAndArrangeTreeByKeys( - cloneDeep(sidebarOptions), - targetKeys - ).filter((t) => !isNil(t)) - ); - - await onSave(navigationItems); - setSaving(false); - }; - - const onDrop: TreeProps['onDrop'] = (info) => { - const dropKey = info.node.key; - const dragKey = info.dragNode.key; - const dropPos = info.node.pos.split('-'); - const dropPosition = - info.dropPosition - Number(dropPos[dropPos.length - 1]); // the drop position relative to the drop node, inside 0, top -1, bottom 1 - - const loop = ( - data: TreeDataNode[], - key: React.Key, - callback: (node: TreeDataNode, i: number, data: TreeDataNode[]) => void - ) => { - for (let i = 0; i < data.length; i++) { - if (data[i].key === key) { - return callback(data[i], i, data); - } - if (data[i].children) { - loop(data[i].children!, key, callback); - } - } - }; - const tempData = cloneDeep(treeData); - - // Find dragObject - let dragObj: TreeDataNode; - loop(tempData, dragKey, (item, index, arr) => { - arr.splice(index, 1); - dragObj = item; - }); - - if (!info.dropToGap) { - // Drop on the content - loop(tempData, dropKey, (item) => { - item.children = item.children || []; - // where to insert. New item was inserted to the start of the array in this example, but can be anywhere - item.children.unshift(dragObj); - }); - } else { - let ar: TreeDataNode[] = []; - let i: number; - loop(tempData, dropKey, (_item, index, arr) => { - ar = arr; - i = index; - }); - if (dropPosition === -1) { - // Drop on the top of the drop node - ar.splice(i!, 0, dragObj!); - } else { - // Drop on the bottom of the drop node - ar.splice(i! + 1, 0, dragObj!); - } - } - - handleChange(getNestedKeys(tempData)); - }; - - const handleRemove = (key: string) => { - setTargetKeys(targetKeys.filter((k) => k !== key)); - }; - - const switcherIcon = useCallback(({ expanded }) => { - return expanded ? : ; - }, []); - - const handleReset = () => { - handleChange(getNestedKeys(sidebarOptions)); - }; - - const titleRenderer = (node: TreeDataNode) => ( -
- {node.title}{' '} - handleRemove(node.key as string)} - /> -
- ); - - useEffect(() => { - fetchPersonaDetails(); - }, [fqn]); - - if (isPersonaLoading) { - return ; - } - - return ( - - -
- - - - - - - - - - - - - - - ); -}; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx index 57de014c90fb..45527f49cf99 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx @@ -61,7 +61,11 @@ import { } from '../../enums/entity.enum'; import { CreateThread } from '../../generated/api/feed/createThread'; import { Tag } from '../../generated/entity/classification/tag'; -import { Table, TableType } from '../../generated/entity/data/table'; +import { + JoinedWith, + Table, + TableType, +} from '../../generated/entity/data/table'; import { Suggestion } from '../../generated/entity/feed/suggestion'; import { ThreadType } from '../../generated/entity/feed/thread'; import { TestSummary } from '../../generated/tests/testCase'; @@ -87,6 +91,7 @@ import { addToRecentViewed, getFeedCounts, getPartialNameFromTableFQN, + getTableFQNFromColumnFQN, sortTagsCaseInsensitive, } from '../../utils/CommonUtils'; import { defaultFields } from '../../utils/DatasetDetailsUtils'; @@ -95,11 +100,7 @@ import entityUtilClassBase from '../../utils/EntityUtilClassBase'; import { getEntityName } from '../../utils/EntityUtils'; import { DEFAULT_ENTITY_PERMISSION } from '../../utils/PermissionsUtils'; import tableClassBase from '../../utils/TableClassBase'; -import { - getJoinsFromTableJoins, - getTagsWithoutTier, - getTierTags, -} from '../../utils/TableUtils'; +import { getTagsWithoutTier, getTierTags } from '../../utils/TableUtils'; import { createTagObject, updateTierTag } from '../../utils/TagsUtils'; import { showErrorToast, showSuccessToast } from '../../utils/ToastUtils'; import { useTestCaseStore } from '../IncidentManager/IncidentManagerDetailPage/useTestCase.store'; @@ -308,13 +309,44 @@ const TableDetailsPageV1: React.FC = () => { const { tags } = tableDetails; const { joins } = tableDetails ?? {}; + const tableFQNGrouping = [ + ...(joins?.columnJoins?.flatMap( + (cjs) => + cjs.joinedWith?.map((jw) => ({ + fullyQualifiedName: getTableFQNFromColumnFQN( + jw.fullyQualifiedName + ), + joinCount: jw.joinCount, + })) ?? [] + ) ?? []), + ...(joins?.directTableJoins ?? []), + ].reduce( + (result, jw) => ({ + ...result, + [jw.fullyQualifiedName]: + (result[jw.fullyQualifiedName] ?? 0) + jw.joinCount, + }), + {} as Record + ); return { ...tableDetails, tier: getTierTags(tags ?? []), tableTags: getTagsWithoutTier(tags ?? []), entityName: getEntityName(tableDetails), - joinedTables: getJoinsFromTableJoins(joins), + joinedTables: Object.entries(tableFQNGrouping) + .map( + ([fullyQualifiedName, joinCount]) => ({ + fullyQualifiedName, + joinCount, + name: getPartialNameFromTableFQN( + fullyQualifiedName, + [FqnPart.Database, FqnPart.Table], + FQN_SEPARATOR_CHAR + ), + }) + ) + .sort((a, b) => b.joinCount - a.joinCount), }; } diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/PersonaAPI.ts b/openmetadata-ui/src/main/resources/ui/src/rest/PersonaAPI.ts index 3637229b85d1..29b2aa23b33d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/PersonaAPI.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/PersonaAPI.ts @@ -36,12 +36,12 @@ export const getAllPersonas = async (params: GetPersonasParams) => { return response.data; }; -export const getPersonaByName = async (fqn: string, fields?: string) => { +export const getPersonaByName = async (fqn: string) => { const response = await axiosClient.get( `${BASE_URL}/name/${getEncodedFqn(fqn)}`, { params: { - fields: fields ?? TabSpecificField.USERS, + fields: TabSpecificField.USERS, }, } ); diff --git a/openmetadata-ui/src/main/resources/ui/src/styles/tree.less b/openmetadata-ui/src/main/resources/ui/src/styles/tree.less index 80b91f77ed1c..1b71463bfb8e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/styles/tree.less +++ b/openmetadata-ui/src/main/resources/ui/src/styles/tree.less @@ -55,9 +55,7 @@ } .ant-tree-switcher-icon { - width: 12px; - height: 12px; - color: @grey-4; + color: black; } .execution-node-container { diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/ApplicationRoutesClassBase.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/ApplicationRoutesClassBase.test.ts index 927930dc8654..e6d407ab9195 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/ApplicationRoutesClassBase.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ApplicationRoutesClassBase.test.ts @@ -11,9 +11,13 @@ * limitations under the License. */ import { FC } from 'react'; -import AuthenticatedAppRouter from '../components/AppRouter/AuthenticatedAppRouter'; import { ApplicationRoutesClassBase } from './ApplicationRoutesClassBase'; +jest.mock('../components/AppRouter/AuthenticatedAppRouter', () => ({ + __esModule: true, + default: 'AuthenticatedAppRouter', +})); + describe('ApplicationRoutesClassBase', () => { let applicationRoutesClassBase: ApplicationRoutesClassBase; @@ -24,6 +28,6 @@ describe('ApplicationRoutesClassBase', () => { it('should return AuthenticatedAppRouter from getRouteElements', () => { const result: FC = applicationRoutesClassBase.getRouteElements(); - expect(result).toBe(AuthenticatedAppRouter); + expect(result).toBe('AuthenticatedAppRouter'); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/ContainerDetailUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/ContainerDetailUtils.ts index 656ba18bc230..e47ba8654f5a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/ContainerDetailUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ContainerDetailUtils.ts @@ -12,8 +12,7 @@ */ import { isEmpty, omit } from 'lodash'; import { EntityTags } from 'Models'; -import { DetailPageWidgetKeys } from '../enums/CustomizeDetailPage.enum'; -import { EntityTabs, TabSpecificField } from '../enums/entity.enum'; +import { TabSpecificField } from '../enums/entity.enum'; import { Column, ContainerDataModel } from '../generated/entity/data/container'; import { LabelType, State, TagLabel } from '../generated/type/tagLabel'; @@ -96,72 +95,5 @@ export const updateContainerColumnDescription = ( }); }; -export const getContainerDetailsPageDefaultLayout = (tab: EntityTabs) => { - switch (tab) { - case EntityTabs.SCHEMA: - return [ - { - h: 2, - i: DetailPageWidgetKeys.DESCRIPTION, - w: 6, - x: 0, - y: 0, - static: false, - }, - { - h: 8, - i: DetailPageWidgetKeys.TABLE_SCHEMA, - w: 6, - x: 0, - y: 0, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.FREQUENTLY_JOINED_TABLES, - w: 2, - x: 6, - y: 0, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.DATA_PRODUCTS, - w: 2, - x: 6, - y: 1, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.TAGS, - w: 2, - x: 6, - y: 2, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.GLOSSARY_TERMS, - w: 2, - x: 6, - y: 3, - static: false, - }, - { - h: 3, - i: DetailPageWidgetKeys.CUSTOM_PROPERTIES, - w: 2, - x: 6, - y: 4, - static: false, - }, - ]; - - default: - return []; - } -}; - // eslint-disable-next-line max-len export const ContainerFields = `${TabSpecificField.TAGS}, ${TabSpecificField.OWNERS},${TabSpecificField.FOLLOWERS},${TabSpecificField.DATAMODEL}, ${TabSpecificField.DOMAIN},${TabSpecificField.DATA_PRODUCTS}`; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/CustomiseGlossaryTermPage/CustomizeGlossaryTermPage.ts b/openmetadata-ui/src/main/resources/ui/src/utils/CustomiseGlossaryTermPage/CustomizeGlossaryTermPage.ts deleted file mode 100644 index bf22e99fe1d2..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/utils/CustomiseGlossaryTermPage/CustomizeGlossaryTermPage.ts +++ /dev/null @@ -1,397 +0,0 @@ -/* - * Copyright 2023 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 { - CustomizeTabWidget, - CustomizeTabWidgetProps, -} from '../../components/Glossary/CustomiseWidgets/CustomizeTabWidget/CustomizeTabWidget'; -import { GenericWidget } from '../../components/Glossary/CustomiseWidgets/SynonymsWidget/GenericWidget'; -import GlossaryHeader from '../../components/Glossary/GlossaryHeader/GlossaryHeader.component'; -import { GlossaryHeaderProps } from '../../components/Glossary/GlossaryHeader/GlossaryHeader.interface'; -import { GlossaryHeaderWidget } from '../../components/Glossary/GlossaryHeader/GlossaryHeaderWidget'; -import { - CommonWidgetType, - CUSTOM_PROPERTIES_WIDGET, - DESCRIPTION_WIDGET, -} from '../../constants/CustomizeWidgets.constants'; -import { GlossaryTermDetailPageWidgetKeys } from '../../enums/CustomizeDetailPage.enum'; -import { EntityTabs } from '../../enums/entity.enum'; -import { - WidgetCommonProps, - WidgetConfig, -} from '../../pages/CustomizablePage/CustomizablePage.interface'; - -type ComponentMap = { - [GlossaryTermDetailPageWidgetKeys.HEADER]: { - component: typeof GlossaryHeader; - props: GlossaryHeaderProps & WidgetCommonProps; - }; - [GlossaryTermDetailPageWidgetKeys.TABS]: { - component: typeof CustomizeTabWidget; - props: WidgetCommonProps; - }; - [GlossaryTermDetailPageWidgetKeys.DESCRIPTION]: { - component: typeof GenericWidget; - props: WidgetCommonProps; - }; - [GlossaryTermDetailPageWidgetKeys.TAGS]: { - component: typeof GenericWidget; - props: WidgetCommonProps; - }; - [GlossaryTermDetailPageWidgetKeys.DOMAIN]: { - component: typeof GenericWidget; - props: WidgetCommonProps; - }; - [GlossaryTermDetailPageWidgetKeys.CUSTOM_PROPERTIES]: { - component: typeof GenericWidget; - props: WidgetCommonProps; - }; - [GlossaryTermDetailPageWidgetKeys.SYNONYMS]: { - component: typeof GenericWidget; - props: WidgetCommonProps; - }; - [GlossaryTermDetailPageWidgetKeys.RELATED_TERMS]: { - component: typeof GenericWidget; - props: WidgetCommonProps; - }; - [GlossaryTermDetailPageWidgetKeys.REFERENCES]: { - component: typeof GenericWidget; - props: WidgetCommonProps; - }; - [GlossaryTermDetailPageWidgetKeys.OWNER]: { - component: typeof GenericWidget; - props: WidgetCommonProps; - }; - [GlossaryTermDetailPageWidgetKeys.REVIEWER]: { - component: typeof GenericWidget; - props: WidgetCommonProps; - }; - [GlossaryTermDetailPageWidgetKeys.EMPTY_WIDGET_PLACEHOLDER]: { - component: typeof GenericWidget; - props: WidgetCommonProps; - }; -}; - -class CustomizeGlossaryTermPageClassBase { - defaultWidgetHeight = 2; - detailPageWidgetMargin = 16; - detailPageRowHeight = 100; - detailPageMaxGridSize = 4; - defaultLayout: Array = []; - detailPageWidgetDefaultHeights: Record< - keyof typeof GlossaryTermDetailPageWidgetKeys, - number - >; - widgets: ComponentMap; - - constructor() { - this.detailPageWidgetDefaultHeights = { - HEADER: 1, - DESCRIPTION: 2, - TAGS: 2, - DOMAIN: 1, - CUSTOM_PROPERTIES: 3, - TABS: 10, - SYNONYMS: 1, - RELATED_TERMS: 1, - REFERENCES: 2, - OWNER: 1, - REVIEWER: 1, - TERMS_TABLE: 1, - EMPTY_WIDGET_PLACEHOLDER: 3, - }; - - this.defaultLayout = [ - { - h: this.detailPageWidgetDefaultHeights.HEADER, - i: GlossaryTermDetailPageWidgetKeys.HEADER, - w: 8, - x: 0, - y: 0, - static: true, - }, - { - h: this.detailPageWidgetDefaultHeights.TABS, - i: GlossaryTermDetailPageWidgetKeys.TABS, - w: 8, - x: 0, - y: 1, - static: true, - }, - ]; - - this.widgets = { - [GlossaryTermDetailPageWidgetKeys.HEADER]: { - component: GlossaryHeader, - props: {} as GlossaryHeaderProps & WidgetCommonProps, - }, - [GlossaryTermDetailPageWidgetKeys.TABS]: { - component: CustomizeTabWidget, - props: {} as CustomizeTabWidgetProps, - }, - [GlossaryTermDetailPageWidgetKeys.DESCRIPTION]: { - component: GenericWidget, - props: {} as WidgetCommonProps, - }, - [GlossaryTermDetailPageWidgetKeys.TAGS]: { - component: GenericWidget, - props: {} as WidgetCommonProps, - }, - [GlossaryTermDetailPageWidgetKeys.DOMAIN]: { - component: GenericWidget, - props: {} as WidgetCommonProps, - }, - [GlossaryTermDetailPageWidgetKeys.CUSTOM_PROPERTIES]: { - component: GenericWidget, - props: {} as WidgetCommonProps, - }, - [GlossaryTermDetailPageWidgetKeys.SYNONYMS]: { - component: GenericWidget, - props: {} as WidgetCommonProps, - }, - [GlossaryTermDetailPageWidgetKeys.RELATED_TERMS]: { - component: GenericWidget, - props: {} as WidgetCommonProps, - }, - [GlossaryTermDetailPageWidgetKeys.REFERENCES]: { - component: GenericWidget, - props: {} as WidgetCommonProps, - }, - [GlossaryTermDetailPageWidgetKeys.OWNER]: { - component: GenericWidget, - props: {} as WidgetCommonProps, - }, - [GlossaryTermDetailPageWidgetKeys.REVIEWER]: { - component: GenericWidget, - props: {} as WidgetCommonProps, - }, - [GlossaryTermDetailPageWidgetKeys.EMPTY_WIDGET_PLACEHOLDER]: { - component: GenericWidget, - props: {} as WidgetCommonProps, - }, - }; - } - - protected updateDefaultLayoutLayout(layout: Array) { - this.defaultLayout = layout; - } - - protected updateLandingPageWidgetDefaultHeights(obj: Record) { - this.detailPageWidgetDefaultHeights = obj; - } - - public getKeyFromWidgetName( - widgetName: string - ): GlossaryTermDetailPageWidgetKeys { - switch (widgetName) { - case 'HEADER': - return GlossaryTermDetailPageWidgetKeys.HEADER; - case 'DESCRIPTION': - return GlossaryTermDetailPageWidgetKeys.DESCRIPTION; - case 'TAGS': - return GlossaryTermDetailPageWidgetKeys.TAGS; - case 'DOMAIN': - return GlossaryTermDetailPageWidgetKeys.DOMAIN; - case 'CUSTOM_PROPERTIES': - return GlossaryTermDetailPageWidgetKeys.CUSTOM_PROPERTIES; - case 'TABS': - return GlossaryTermDetailPageWidgetKeys.TABS; - case 'SYNONYMS': - return GlossaryTermDetailPageWidgetKeys.SYNONYMS; - case 'RELATED_TERMS': - return GlossaryTermDetailPageWidgetKeys.RELATED_TERMS; - case 'REFERENCES': - return GlossaryTermDetailPageWidgetKeys.REFERENCES; - case 'OWNER': - return GlossaryTermDetailPageWidgetKeys.OWNER; - case 'REVIEWER': - return GlossaryTermDetailPageWidgetKeys.REVIEWER; - default: - return GlossaryTermDetailPageWidgetKeys.EMPTY_WIDGET_PLACEHOLDER; - } - } - - /** - * - * @param string widgetKey - * @returns React.FC< - { - isEditView?: boolean; - widgetKey: string; - handleRemoveWidget?: (widgetKey: string) => void; - announcements: Thread[]; - followedData: EntityReference[]; - followedDataCount: number; - isLoadingOwnedData: boolean; - } - > - */ - public getWidgetsFromKey( - widgetKey: T - ) { - if (widgetKey.startsWith(GlossaryTermDetailPageWidgetKeys.HEADER)) { - return GlossaryHeaderWidget; - } else if (widgetKey.startsWith(GlossaryTermDetailPageWidgetKeys.TABS)) { - return CustomizeTabWidget; - } else { - return GenericWidget; - } - } - - public getWidgetHeight(widgetName: string) { - switch (widgetName) { - case 'HEADER': - return this.detailPageWidgetDefaultHeights.HEADER; - case 'DESCRIPTION': - return this.detailPageWidgetDefaultHeights.DESCRIPTION; - case 'TAGS': - return this.detailPageWidgetDefaultHeights.TAGS; - case 'DOMAIN': - return this.detailPageWidgetDefaultHeights.DOMAIN; - case 'CUSTOM_PROPERTIES': - return this.detailPageWidgetDefaultHeights.CUSTOM_PROPERTIES; - case 'TABS': - return this.detailPageWidgetDefaultHeights.TABS; - case 'SYNONYMS': - return this.detailPageWidgetDefaultHeights.SYNONYMS; - case 'RELATED_TERMS': - return this.detailPageWidgetDefaultHeights.RELATED_TERMS; - case 'REFERENCES': - return this.detailPageWidgetDefaultHeights.REFERENCES; - case 'OWNER': - return this.detailPageWidgetDefaultHeights.OWNER; - case 'REVIEWER': - return this.detailPageWidgetDefaultHeights.REVIEWER; - - default: - return this.defaultWidgetHeight; - } - } - - public getDefaultWidgetForTab(tab: EntityTabs) { - if (tab === EntityTabs.OVERVIEW) { - return [ - { - h: this.detailPageWidgetDefaultHeights.DESCRIPTION, - i: GlossaryTermDetailPageWidgetKeys.DESCRIPTION, - w: 6, - x: 0, - y: 0, - static: false, - }, - { - h: this.detailPageWidgetDefaultHeights.CUSTOM_PROPERTIES, - i: GlossaryTermDetailPageWidgetKeys.CUSTOM_PROPERTIES, - w: 2, - x: 6, - y: 7, - static: false, - }, - { - h: this.detailPageWidgetDefaultHeights.DOMAIN, - i: GlossaryTermDetailPageWidgetKeys.DOMAIN, - w: 2, - x: 6, - y: 0, - static: false, - }, - { - h: this.detailPageWidgetDefaultHeights.SYNONYMS, - i: GlossaryTermDetailPageWidgetKeys.SYNONYMS, - w: 3, - x: 0, - y: 2, - static: false, - }, - { - h: this.detailPageWidgetDefaultHeights.RELATED_TERMS, - i: GlossaryTermDetailPageWidgetKeys.RELATED_TERMS, - w: 3, - x: 3, - y: 2, - static: false, - }, - { - h: this.detailPageWidgetDefaultHeights.REFERENCES, - i: GlossaryTermDetailPageWidgetKeys.REFERENCES, - w: 3, - x: 0, - y: 3, - static: false, - }, - - { - h: this.detailPageWidgetDefaultHeights.TAGS, - i: GlossaryTermDetailPageWidgetKeys.TAGS, - w: 3, - x: 3, - y: 3, - static: false, - }, - { - h: this.detailPageWidgetDefaultHeights.OWNER, - i: GlossaryTermDetailPageWidgetKeys.OWNER, - w: 2, - x: 6, - y: 1, - static: false, - }, - { - h: this.detailPageWidgetDefaultHeights.REVIEWER, - i: GlossaryTermDetailPageWidgetKeys.REVIEWER, - w: 2, - x: 6, - y: 4, - static: false, - }, - ]; - } - - return []; - } - - public getCommonWidgetList(): CommonWidgetType[] { - return [ - DESCRIPTION_WIDGET, - { - fullyQualifiedName: GlossaryTermDetailPageWidgetKeys.SYNONYMS, - name: 'Synonyms', - data: { - gridSizes: ['small'], - }, - }, - { - fullyQualifiedName: GlossaryTermDetailPageWidgetKeys.RELATED_TERMS, - name: 'Related Terms', - data: { gridSizes: ['small'] }, - }, - { - fullyQualifiedName: GlossaryTermDetailPageWidgetKeys.REFERENCES, - name: 'References', - data: { gridSizes: ['small'] }, - }, - { - fullyQualifiedName: GlossaryTermDetailPageWidgetKeys.REVIEWER, - name: 'Reviewer', - data: { gridSizes: ['small'] }, - }, - CUSTOM_PROPERTIES_WIDGET, - ]; - } -} - -const customizeGlossaryTermPageClassBase = - new CustomizeGlossaryTermPageClassBase(); - -export default customizeGlossaryTermPageClassBase; -export { CustomizeGlossaryTermPageClassBase }; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/CustomizaNavigation/CustomizeNavigation.ts b/openmetadata-ui/src/main/resources/ui/src/utils/CustomizaNavigation/CustomizeNavigation.ts deleted file mode 100644 index a0862d51b781..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/utils/CustomizaNavigation/CustomizeNavigation.ts +++ /dev/null @@ -1,104 +0,0 @@ -/* - * 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 { DataNode } from 'antd/lib/tree'; -import { NavigationItem } from '../../generated/system/ui/uiCustomization'; - -export const filterAndArrangeTreeByKeys = < - T extends { key: string | number; children?: T[] } ->( - tree: T[], - keys: Array -): T[] => { - // Sort nodes according to the keys order - function sortByKeys(nodeArray: T[]) { - return nodeArray.sort((a, b) => keys.indexOf(a.key) - keys.indexOf(b.key)); - } - - // Helper function to recursively filter and arrange the tree - function filterAndArrange(node: T) { - // If the current node's key is in the keys array, process it - if (keys.includes(node.key)) { - // If the node has children, we recursively filter and arrange them - if (node.children && node.children.length > 0) { - node.children = node.children - .map(filterAndArrange) // Recursively filter and arrange children - .filter((t): t is T => t !== null); // Remove any undefined children - - // Sort the children according to the order of the keys array - node.children = sortByKeys(node.children); - } - - return node; // Return the node if it has the required key - } - - return null; // Return null if the key doesn't match - } - - // Apply the filter and arrange function to the entire tree - let filteredTree = tree - .map(filterAndArrange) - .filter((t): t is T => t !== null); - - // Sort the filtered tree based on the order of keys at the root level - filteredTree = sortByKeys(filteredTree); - - return filteredTree; -}; - -export const getNestedKeys = < - T extends { key: string | number; children?: T[] } ->( - data: T[] -): string[] => - data.reduce((acc: string[], item: T): string[] => { - if (item.children) { - return [ - ...acc, - item.key as string, - ...getNestedKeys(item.children ?? []), - ]; - } - - return [...acc, item.key as string]; - }, [] as string[]); - -export const getNavigationItems = (items: DataNode[]): NavigationItem[] => - items - .map((item) => - item.children - ? ({ - id: item.key, - title: item.title, - pageId: item.key, - children: getNavigationItems(item.children), - } as NavigationItem) - : ({ - id: item.key, - title: item.title, - pageId: item.key, - } as NavigationItem) - ) - .filter(Boolean); - -export const getNestedKeysFromNavigationItems = (data: NavigationItem[]) => - data.reduce((acc: string[], item: NavigationItem): string[] => { - if (item.children) { - return [ - ...acc, - item.id, - ...getNestedKeysFromNavigationItems(item.children), - ]; - } - - return [...acc, item.id]; - }, [] as string[]); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/CustomizableLandingPageUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/CustomizableLandingPageUtils.tsx index 50a4abffbc0b..5adc4fe6769f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/CustomizableLandingPageUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/CustomizableLandingPageUtils.tsx @@ -32,7 +32,7 @@ import { Document } from '../generated/entity/docStore/document'; import { Thread } from '../generated/entity/feed/thread'; import { EntityReference } from '../generated/entity/type'; import { WidgetConfig } from '../pages/CustomizablePage/CustomizablePage.interface'; -import customizeMyDataPageClassBase from './CustomizeMyDataPageClassBase'; +import customizePageClassBase from './CustomizePageClassBase'; const getNewWidgetPlacement = ( currentLayout: WidgetConfig[], @@ -58,7 +58,7 @@ const getNewWidgetPlacement = ( // Check if there's enough space to place the new widget on the same row if ( - customizeMyDataPageClassBase.landingPageMaxGridSize - + customizePageClassBase.landingPageMaxGridSize - (lowestWidgetLayout.x + lowestWidgetLayout.w) >= widgetWidth ) { @@ -84,7 +84,7 @@ export const getAddWidgetHandler = ) => (currentLayout: Array) => { const widgetFQN = uniqueId(`${newWidgetData.fullyQualifiedName}-`); - const widgetHeight = customizeMyDataPageClassBase.getWidgetHeight( + const widgetHeight = customizePageClassBase.getWidgetHeight( newWidgetData.name ); @@ -265,7 +265,7 @@ export const getWidgetFromKey = ({ ); } - const Widget = customizeMyDataPageClassBase.getWidgetsFromKey(widgetConfig.i); + const Widget = customizePageClassBase.getWidgetsFromKey(widgetConfig.i); return ( = { - header: 1, - description: 6, - tableSchema: 3, - topicSchema: 3, - announcement: 3, - frequentlyJoinedTables: 3, - dataProduct: 3, - tags: 3, - glossaryTerms: 3, - customProperty: 3, - tabs: 1, - announcements: 3, - }; - - announcementWidget: WidgetConfig = { - h: this.detailPageWidgetDefaultHeights.announcements, - i: DetailPageWidgetKeys.ANNOUNCEMENTS, - w: 1, - x: 3, - y: 0, - static: false, // Making announcement widget fixed on top right position - }; - - defaultLayout: Array = [ - { - h: this.detailPageWidgetDefaultHeights.header, - i: DetailPageWidgetKeys.HEADER, - w: 4, - x: 0, - y: 0, - static: true, - }, - { - h: this.detailPageWidgetDefaultHeights.tabs, - i: DetailPageWidgetKeys.TABS, - w: 4, - x: 0, - y: 1, - static: false, - }, - { - h: this.detailPageWidgetDefaultHeights.tableSchema, - i: DetailPageWidgetKeys.TABLE_SCHEMA, - w: 1, - x: 3, - y: 6, - static: false, - }, - { - h: this.detailPageWidgetDefaultHeights.dataProduct, - i: DetailPageWidgetKeys.DATA_PRODUCTS, - w: 2, - x: 0, - y: 9, - static: false, - }, - { - h: this.detailPageWidgetDefaultHeights.tags, - i: DetailPageWidgetKeys.TAGS, - w: 3, - x: 0, - y: 6, - static: false, - }, - { - h: this.detailPageWidgetDefaultHeights.glossaryTerms, - i: DetailPageWidgetKeys.GLOSSARY_TERMS, - w: 1, - x: 3, - y: 1.5, - static: false, - }, - { - h: this.detailPageWidgetDefaultHeights.frequentlyJoinedTables, - i: DetailPageWidgetKeys.GLOSSARY_TERMS, - w: 1, - x: 3, - y: 3, - static: false, - }, - { - h: this.detailPageWidgetDefaultHeights.customProperty, - i: DetailPageWidgetKeys.CUSTOM_PROPERTIES, - w: 1, - x: 3, - y: 4.5, - static: false, - }, - { - h: this.detailPageWidgetDefaultHeights.announcement, - i: DetailPageWidgetKeys.ANNOUNCEMENTS, - w: 1, - x: 3, - y: 0, - static: true, - }, - ]; - - protected updateDefaultLayoutLayout(layout: Array) { - this.defaultLayout = layout; - } - - protected updateLandingPageWidgetDefaultHeights(obj: Record) { - this.detailPageWidgetDefaultHeights = obj; - } - - /** - * - * @param string widgetKey - * @returns React.FC< - { - isEditView?: boolean; - widgetKey: string; - handleRemoveWidget?: (widgetKey: string) => void; - announcements: Thread[]; - followedData: EntityReference[]; - followedDataCount: number; - isLoadingOwnedData: boolean; - } - > - */ - public getWidgetsFromKey(_widgetKey: string): FC { - return GenericWidget; - } - - public getWidgetHeight(widgetName: string) { - switch (widgetName) { - case 'ActivityFeed': - return this.detailPageWidgetDefaultHeights.activityFeed; - case 'DataAssets': - return this.detailPageWidgetDefaultHeights.DataAssets; - case 'Announcements': - return this.detailPageWidgetDefaultHeights.announcements; - case 'Following': - return this.detailPageWidgetDefaultHeights.following; - case 'RecentlyViewed': - return this.detailPageWidgetDefaultHeights.recentlyViewed; - case 'MyData': - return this.detailPageWidgetDefaultHeights.myData; - case 'KPI': - return this.detailPageWidgetDefaultHeights.kpi; - case 'TotalAssets': - return this.detailPageWidgetDefaultHeights.totalAssets; - default: - return this.defaultWidgetHeight; - } - } - - public getCommonWidgetList() { - return [ - DESCRIPTION_WIDGET, - TAGS_WIDGET, - DOMAIN_WIDGET, - OWNER_WIDGET, - CUSTOM_PROPERTIES_WIDGET, - ]; - } -} - -const customizeDetailPageClassBase = new CustomizeDetailPageClassBase(); - -export default customizeDetailPageClassBase; -export { CustomizeDetailPageClassBase }; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/CustomizeGlossaryPage/CustomizeGlossaryPage.ts b/openmetadata-ui/src/main/resources/ui/src/utils/CustomizeGlossaryPage/CustomizeGlossaryPage.ts deleted file mode 100644 index ced51bdae083..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/utils/CustomizeGlossaryPage/CustomizeGlossaryPage.ts +++ /dev/null @@ -1,333 +0,0 @@ -/* - * Copyright 2023 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 { - CustomizeTabWidget, - CustomizeTabWidgetProps, -} from '../../components/Glossary/CustomiseWidgets/CustomizeTabWidget/CustomizeTabWidget'; -import { GenericWidget } from '../../components/Glossary/CustomiseWidgets/SynonymsWidget/GenericWidget'; -import GlossaryHeader from '../../components/Glossary/GlossaryHeader/GlossaryHeader.component'; -import { GlossaryHeaderProps } from '../../components/Glossary/GlossaryHeader/GlossaryHeader.interface'; -import { GlossaryHeaderWidget } from '../../components/Glossary/GlossaryHeader/GlossaryHeaderWidget'; -import { GlossaryTermDetailPageWidgetKeys } from '../../enums/CustomizeDetailPage.enum'; -import { EntityTabs } from '../../enums/entity.enum'; -import { - WidgetCommonProps, - WidgetConfig, -} from '../../pages/CustomizablePage/CustomizablePage.interface'; - -type ComponentMap = { - [GlossaryTermDetailPageWidgetKeys.HEADER]: { - component: typeof GlossaryHeader; - props: GlossaryHeaderProps & WidgetCommonProps; - }; - [GlossaryTermDetailPageWidgetKeys.TABS]: { - component: typeof CustomizeTabWidget; - props: WidgetCommonProps; - }; - [GlossaryTermDetailPageWidgetKeys.DESCRIPTION]: { - component: typeof GenericWidget; - props: WidgetCommonProps; - }; - [GlossaryTermDetailPageWidgetKeys.TAGS]: { - component: typeof GenericWidget; - props: WidgetCommonProps; - }; - [GlossaryTermDetailPageWidgetKeys.DOMAIN]: { - component: typeof GenericWidget; - props: WidgetCommonProps; - }; - [GlossaryTermDetailPageWidgetKeys.CUSTOM_PROPERTIES]: { - component: typeof GenericWidget; - props: WidgetCommonProps; - }; - [GlossaryTermDetailPageWidgetKeys.SYNONYMS]: { - component: typeof GenericWidget; - props: WidgetCommonProps; - }; - [GlossaryTermDetailPageWidgetKeys.RELATED_TERMS]: { - component: typeof GenericWidget; - props: WidgetCommonProps; - }; - [GlossaryTermDetailPageWidgetKeys.REFERENCES]: { - component: typeof GenericWidget; - props: WidgetCommonProps; - }; - [GlossaryTermDetailPageWidgetKeys.OWNER]: { - component: typeof GenericWidget; - props: WidgetCommonProps; - }; - [GlossaryTermDetailPageWidgetKeys.REVIEWER]: { - component: typeof GenericWidget; - props: WidgetCommonProps; - }; - [GlossaryTermDetailPageWidgetKeys.EMPTY_WIDGET_PLACEHOLDER]: { - component: typeof GenericWidget; - props: WidgetCommonProps; - }; -}; - -class CustomizeGlossaryPageClassBase { - defaultWidgetHeight = 2; - detailWidgetMargin = 16; - rowHeight = 100; - maxGridSize = 4; - defaultLayout: Array = []; - defaultHeights: Record; - widgets: ComponentMap; - - constructor() { - this.defaultHeights = { - HEADER: 1, - DESCRIPTION: 2, - TAGS: 2, - DOMAIN: 1, - CUSTOM_PROPERTIES: 3, - TABS: 10, - SYNONYMS: 1, - RELATED_TERMS: 1, - REFERENCES: 1, - OWNER: 1, - REVIEWER: 1, - TERMS_TABLE: 6, - EMPTY_WIDGET_PLACEHOLDER: 3, - }; - - this.defaultLayout = [ - { - h: this.defaultHeights.HEADER, - i: GlossaryTermDetailPageWidgetKeys.HEADER, - w: 8, - x: 0, - y: 0, - static: true, - }, - { - h: this.defaultHeights.TABS, - i: GlossaryTermDetailPageWidgetKeys.TABS, - w: 8, - x: 0, - y: 1, - static: true, - }, - ]; - - this.widgets = { - [GlossaryTermDetailPageWidgetKeys.HEADER]: { - component: GlossaryHeader, - props: {} as GlossaryHeaderProps & WidgetCommonProps, - }, - [GlossaryTermDetailPageWidgetKeys.TABS]: { - component: CustomizeTabWidget, - props: {} as CustomizeTabWidgetProps, - }, - [GlossaryTermDetailPageWidgetKeys.DESCRIPTION]: { - component: GenericWidget, - props: {} as WidgetCommonProps, - }, - [GlossaryTermDetailPageWidgetKeys.TAGS]: { - component: GenericWidget, - props: {} as WidgetCommonProps, - }, - [GlossaryTermDetailPageWidgetKeys.DOMAIN]: { - component: GenericWidget, - props: {} as WidgetCommonProps, - }, - [GlossaryTermDetailPageWidgetKeys.CUSTOM_PROPERTIES]: { - component: GenericWidget, - props: {} as WidgetCommonProps, - }, - [GlossaryTermDetailPageWidgetKeys.SYNONYMS]: { - component: GenericWidget, - props: {} as WidgetCommonProps, - }, - [GlossaryTermDetailPageWidgetKeys.RELATED_TERMS]: { - component: GenericWidget, - props: {} as WidgetCommonProps, - }, - [GlossaryTermDetailPageWidgetKeys.REFERENCES]: { - component: GenericWidget, - props: {} as WidgetCommonProps, - }, - [GlossaryTermDetailPageWidgetKeys.OWNER]: { - component: GenericWidget, - props: {} as WidgetCommonProps, - }, - [GlossaryTermDetailPageWidgetKeys.REVIEWER]: { - component: GenericWidget, - props: {} as WidgetCommonProps, - }, - [GlossaryTermDetailPageWidgetKeys.EMPTY_WIDGET_PLACEHOLDER]: { - component: GenericWidget, - props: {} as WidgetCommonProps, - }, - }; - } - - protected updateDefaultLayoutLayout(layout: Array) { - this.defaultLayout = layout; - } - - protected updateLandingPageWidgetDefaultHeights(obj: Record) { - this.defaultHeights = obj; - } - - public getKeyFromWidgetName( - widgetName: string - ): GlossaryTermDetailPageWidgetKeys { - switch (widgetName) { - case 'HEADER': - return GlossaryTermDetailPageWidgetKeys.HEADER; - case 'DESCRIPTION': - return GlossaryTermDetailPageWidgetKeys.DESCRIPTION; - case 'TAGS': - return GlossaryTermDetailPageWidgetKeys.TAGS; - case 'DOMAIN': - return GlossaryTermDetailPageWidgetKeys.DOMAIN; - case 'CUSTOM_PROPERTIES': - return GlossaryTermDetailPageWidgetKeys.CUSTOM_PROPERTIES; - case 'TABS': - return GlossaryTermDetailPageWidgetKeys.TABS; - case 'SYNONYMS': - return GlossaryTermDetailPageWidgetKeys.SYNONYMS; - case 'RELATED_TERMS': - return GlossaryTermDetailPageWidgetKeys.RELATED_TERMS; - case 'REFERENCES': - return GlossaryTermDetailPageWidgetKeys.REFERENCES; - case 'OWNER': - return GlossaryTermDetailPageWidgetKeys.OWNER; - case 'REVIEWER': - return GlossaryTermDetailPageWidgetKeys.REVIEWER; - default: - return GlossaryTermDetailPageWidgetKeys.EMPTY_WIDGET_PLACEHOLDER; - } - } - - /** - * - * @param string widgetKey - * @returns React.FC< - { - isEditView?: boolean; - widgetKey: string; - handleRemoveWidget?: (widgetKey: string) => void; - announcements: Thread[]; - followedData: EntityReference[]; - followedDataCount: number; - isLoadingOwnedData: boolean; - } - > - */ - public getWidgetsFromKey( - widgetKey: T - ) { - if (widgetKey.startsWith(GlossaryTermDetailPageWidgetKeys.HEADER)) { - return GlossaryHeaderWidget; - } else if (widgetKey.startsWith(GlossaryTermDetailPageWidgetKeys.TABS)) { - return CustomizeTabWidget; - } else { - return GenericWidget; - } - } - - public getWidgetHeight(widgetName: string) { - switch (widgetName) { - case 'HEADER': - return this.defaultHeights.HEADER; - case 'DESCRIPTION': - return this.defaultHeights.DESCRIPTION; - case 'TAGS': - return this.defaultHeights.TAGS; - case 'DOMAIN': - return this.defaultHeights.DOMAIN; - case 'CUSTOM_PROPERTIES': - return this.defaultHeights.CUSTOM_PROPERTIES; - case 'TABS': - return this.defaultHeights.TABS; - case 'SYNONYMS': - return this.defaultHeights.SYNONYMS; - case 'RELATED_TERMS': - return this.defaultHeights.RELATED_TERMS; - case 'REFERENCES': - return this.defaultHeights.REFERENCES; - case 'OWNER': - return this.defaultHeights.OWNER; - case 'REVIEWER': - return this.defaultHeights.REVIEWER; - default: - return this.defaultWidgetHeight; - } - } - - public getDefaultWidgetForTab(tab: EntityTabs) { - if (tab === EntityTabs.TERMS) { - return [ - { - h: this.defaultHeights.DESCRIPTION, - i: GlossaryTermDetailPageWidgetKeys.DESCRIPTION, - w: 6, - x: 0, - y: 0, - static: false, - }, - { - h: this.defaultHeights.TERMS_TABLE, - i: GlossaryTermDetailPageWidgetKeys.TERMS_TABLE, - w: 6, - x: 0, - y: 0, - static: false, - }, - { - h: this.defaultHeights.DOMAIN, - i: GlossaryTermDetailPageWidgetKeys.DOMAIN, - w: 2, - x: 6, - y: 0, - }, - { - h: this.defaultHeights.OWNER, - i: GlossaryTermDetailPageWidgetKeys.OWNER, - w: 2, - x: 6, - y: 1, - static: false, - }, - - { - h: this.defaultHeights.REVIEWER, - i: GlossaryTermDetailPageWidgetKeys.REVIEWER, - w: 2, - x: 6, - y: 2, - static: false, - }, - { - h: this.defaultHeights.TAGS, - i: GlossaryTermDetailPageWidgetKeys.TAGS, - w: 2, - x: 6, - y: 3, - static: false, - }, - ]; - } - - return []; - } -} - -const customizeGlossaryPageClassBase = new CustomizeGlossaryPageClassBase(); - -export default customizeGlossaryPageClassBase; -export { CustomizeGlossaryPageClassBase }; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/CustomizePage/CustomizePageUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/CustomizePage/CustomizePageUtils.ts deleted file mode 100644 index 31b4498082e0..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/utils/CustomizePage/CustomizePageUtils.ts +++ /dev/null @@ -1,257 +0,0 @@ -/* - * 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 { TabsProps } from 'antd'; -import { - CommonWidgetType, - CUSTOM_PROPERTIES_WIDGET, - DESCRIPTION_WIDGET, - DOMAIN_WIDGET, - GLOSSARY_TERMS_WIDGET, - TAGS_WIDGET, -} from '../../constants/CustomizeWidgets.constants'; -import { EntityTabs } from '../../enums/entity.enum'; -import { PageType } from '../../generated/system/ui/page'; -import customizeGlossaryTermPageClassBase from '../CustomiseGlossaryTermPage/CustomizeGlossaryTermPage'; -import customizeDetailPageClassBase from '../CustomizeDetailPage/CustomizeDetailPage'; -import customizeGlossaryPageClassBase from '../CustomizeGlossaryPage/CustomizeGlossaryPage'; -import customizeMyDataPageClassBase from '../CustomizeMyDataPageClassBase'; -import i18n from '../i18next/LocalUtil'; -import tableClassBase from '../TableClassBase'; - -export const getDefaultLayout = (pageType: string) => { - switch (pageType) { - case PageType.GlossaryTerm: - return customizeGlossaryTermPageClassBase.defaultLayout; - case PageType.Table: - return customizeDetailPageClassBase.defaultLayout; - case PageType.LandingPage: - default: - return customizeMyDataPageClassBase.defaultLayout; - } -}; - -export const getGlossaryTermDefaultTabs = () => { - return [ - { - id: EntityTabs.OVERVIEW, - displayName: 'Overview', - layout: customizeGlossaryTermPageClassBase.getDefaultWidgetForTab( - EntityTabs.OVERVIEW - ), - name: EntityTabs.OVERVIEW, - editable: true, - }, - { - id: EntityTabs.GLOSSARY_TERMS, - displayName: 'Glossary Terms', - layout: customizeGlossaryTermPageClassBase.getDefaultWidgetForTab( - EntityTabs.GLOSSARY_TERMS - ), - name: EntityTabs.GLOSSARY_TERMS, - editable: false, - }, - { - id: EntityTabs.ASSETS, - displayName: 'Assets', - layout: customizeGlossaryTermPageClassBase.getDefaultWidgetForTab( - EntityTabs.ASSETS - ), - name: EntityTabs.ASSETS, - editable: false, - }, - { - displayName: 'Activity Feeds & Tasks', - name: EntityTabs.ACTIVITY_FEED, - id: EntityTabs.ACTIVITY_FEED, - layout: customizeGlossaryTermPageClassBase.getDefaultWidgetForTab( - EntityTabs.ACTIVITY_FEED - ), - editable: false, - }, - { - id: EntityTabs.CUSTOM_PROPERTIES, - name: EntityTabs.CUSTOM_PROPERTIES, - displayName: 'Custom Property', - layout: customizeGlossaryTermPageClassBase.getDefaultWidgetForTab( - EntityTabs.CUSTOM_PROPERTIES - ), - editable: false, - }, - ]; -}; - -export const getGlossaryDefaultTabs = () => { - return [ - { - id: EntityTabs.TERMS, - name: EntityTabs.TERMS, - displayName: 'Terms', - layout: customizeGlossaryPageClassBase.getDefaultWidgetForTab( - EntityTabs.TERMS - ), - editable: true, - }, - { - displayName: 'Activity Feeds & Tasks', - name: EntityTabs.ACTIVITY_FEED, - id: EntityTabs.ACTIVITY_FEED, - layout: customizeGlossaryTermPageClassBase.getDefaultWidgetForTab( - EntityTabs.ACTIVITY_FEED - ), - editable: false, - }, - ]; -}; - -export const getTabLabelFromId = (tab: EntityTabs) => { - switch (tab) { - case EntityTabs.OVERVIEW: - return i18n.t('label.overview'); - case EntityTabs.GLOSSARY_TERMS: - return i18n.t('label.glossary-terms'); - case EntityTabs.ASSETS: - return i18n.t('label.assets'); - case EntityTabs.ACTIVITY_FEED: - return i18n.t('label.activity-feed-and-task-plural'); - case EntityTabs.CUSTOM_PROPERTIES: - return i18n.t('label.custom-property-plural'); - case EntityTabs.TERMS: - return i18n.t('label.terms'); - case EntityTabs.SCHEMA: - return i18n.t('label.schema'); - case EntityTabs.SAMPLE_DATA: - return i18n.t('label.sample-data'); - case EntityTabs.TABLE_QUERIES: - return i18n.t('label.query-plural'); - case EntityTabs.PROFILER: - return i18n.t('label.profiler-amp-data-quality'); - case EntityTabs.INCIDENTS: - return i18n.t('label.incident-plural'); - case EntityTabs.LINEAGE: - return i18n.t('label.lineage'); - case EntityTabs.VIEW_DEFINITION: - return i18n.t('label.view-definition'); - case EntityTabs.DBT: - return i18n.t('label.dbt-lowercase'); - default: - return ''; - } -}; - -const getCustomizeTabObject = (tab: EntityTabs) => ({ - id: tab, - name: tab, - displayName: getTabLabelFromId(tab), - layout: tableClassBase.getDefaultLayout(tab), - editable: [EntityTabs.SCHEMA, EntityTabs.OVERVIEW, EntityTabs.TERMS].includes( - tab - ), -}); - -export const getTableDefaultTabs = () => { - const tabs = tableClassBase - .getTableDetailPageTabsIds() - .map(getCustomizeTabObject); - - return tabs; -}; - -export const getDefaultTabs = (pageType?: string) => { - switch (pageType) { - case PageType.GlossaryTerm: - return getGlossaryTermDefaultTabs(); - case PageType.Glossary: - return getGlossaryDefaultTabs(); - case PageType.Table: - return getTableDefaultTabs(); - case PageType.Container: - default: - return [ - { - id: EntityTabs.CUSTOM_PROPERTIES, - name: EntityTabs.CUSTOM_PROPERTIES, - displayName: 'Custom Property', - layout: customizeGlossaryTermPageClassBase.getDefaultWidgetForTab( - EntityTabs.CUSTOM_PROPERTIES - ), - }, - ]; - } -}; - -export const getDefaultWidgetForTab = (pageType: PageType, tab: EntityTabs) => { - switch (pageType) { - case PageType.GlossaryTerm: - case PageType.Glossary: - return customizeGlossaryTermPageClassBase.getDefaultWidgetForTab(tab); - case PageType.Table: - return tableClassBase.getDefaultLayout(tab); - default: - return []; - } -}; - -export const sortTabs = (tabs: TabsProps['items'], order: string[]) => { - return [...(tabs ?? [])].sort((a, b) => { - const orderA = order.indexOf(a.key); - const orderB = order.indexOf(b.key); - - if (orderA !== -1 && orderB !== -1) { - return orderA - orderB; - } - if (orderA !== -1) { - return -1; - } - if (orderB !== -1) { - return 1; - } - - const ia = tabs?.indexOf(a) ?? 0; - const ib = tabs?.indexOf(b) ?? 0; - - return ia - ib; - }); -}; - -export const getCustomizableWidgetByPage = ( - pageType: PageType -): CommonWidgetType[] => { - switch (pageType) { - case PageType.GlossaryTerm: - case PageType.Glossary: - return customizeGlossaryTermPageClassBase.getCommonWidgetList(); - - case PageType.Table: - return [ - DESCRIPTION_WIDGET, - CUSTOM_PROPERTIES_WIDGET, - DOMAIN_WIDGET, - TAGS_WIDGET, - GLOSSARY_TERMS_WIDGET, - ]; - case PageType.LandingPage: - default: - return []; - } -}; - -export const getDummyDataByPage = (pageType: PageType) => { - switch (pageType) { - case PageType.Table: - return tableClassBase.getDummyData(); - - case PageType.LandingPage: - default: - return {}; - } -}; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/CustomizeMyDataPageClassBase.ts b/openmetadata-ui/src/main/resources/ui/src/utils/CustomizePageClassBase.ts similarity index 97% rename from openmetadata-ui/src/main/resources/ui/src/utils/CustomizeMyDataPageClassBase.ts rename to openmetadata-ui/src/main/resources/ui/src/utils/CustomizePageClassBase.ts index b0920e95f900..528257c0bda5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/CustomizeMyDataPageClassBase.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/CustomizePageClassBase.ts @@ -43,7 +43,7 @@ import { WidgetConfig, } from '../pages/CustomizablePage/CustomizablePage.interface'; -class CustomizeMyDataPageClassBase { +class CustomizePageClassBase { defaultWidgetHeight = 3; landingPageWidgetMargin = 16; landingPageRowHeight = 100; @@ -246,7 +246,7 @@ class CustomizeMyDataPageClassBase { } } -const customizeMyDataPageClassBase = new CustomizeMyDataPageClassBase(); +const customizePageClassBase = new CustomizePageClassBase(); -export default customizeMyDataPageClassBase; -export { CustomizeMyDataPageClassBase }; +export default customizePageClassBase; +export { CustomizePageClassBase }; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/DashboardDetailsUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/DashboardDetailsUtils.ts index 587248779288..e7971723ffff 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/DashboardDetailsUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/DashboardDetailsUtils.ts @@ -12,8 +12,7 @@ */ import { AxiosError } from 'axios'; -import { DetailPageWidgetKeys } from '../enums/CustomizeDetailPage.enum'; -import { EntityTabs, TabSpecificField } from '../enums/entity.enum'; +import { TabSpecificField } from '../enums/entity.enum'; import { Dashboard } from '../generated/entity/data/dashboard'; import { ChartType } from '../pages/DashboardDetailsPage/DashboardDetailsPage.component'; import { getChartById } from '../rest/chartAPI'; @@ -51,139 +50,3 @@ export const fetchCharts = async (charts: Dashboard['charts']) => { return chartsData; }; - -export const getDashboardDetailsPageDefaultLayout = (tab: EntityTabs) => { - switch (tab) { - case EntityTabs.SCHEMA: - return [ - { - h: 2, - i: DetailPageWidgetKeys.DESCRIPTION, - w: 6, - x: 0, - y: 0, - static: false, - }, - { - h: 8, - i: DetailPageWidgetKeys.TABLE_SCHEMA, - w: 6, - x: 0, - y: 0, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.FREQUENTLY_JOINED_TABLES, - w: 2, - x: 6, - y: 0, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.DATA_PRODUCTS, - w: 2, - x: 6, - y: 1, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.TAGS, - w: 2, - x: 6, - y: 2, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.GLOSSARY_TERMS, - w: 2, - x: 6, - y: 3, - static: false, - }, - { - h: 3, - i: DetailPageWidgetKeys.CUSTOM_PROPERTIES, - w: 2, - x: 6, - y: 4, - static: false, - }, - ]; - - default: - return []; - } -}; - -export const getDashboardDataModelDetailsPageDefaultLayout = ( - tab: EntityTabs -) => { - switch (tab) { - case EntityTabs.SCHEMA: - return [ - { - h: 2, - i: DetailPageWidgetKeys.DESCRIPTION, - w: 6, - x: 0, - y: 0, - static: false, - }, - { - h: 8, - i: DetailPageWidgetKeys.TABLE_SCHEMA, - w: 6, - x: 0, - y: 0, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.FREQUENTLY_JOINED_TABLES, - w: 2, - x: 6, - y: 0, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.DATA_PRODUCTS, - w: 2, - x: 6, - y: 1, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.TAGS, - w: 2, - x: 6, - y: 2, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.GLOSSARY_TERMS, - w: 2, - x: 6, - y: 3, - static: false, - }, - { - h: 3, - i: DetailPageWidgetKeys.CUSTOM_PROPERTIES, - w: 2, - x: 6, - y: 4, - static: false, - }, - ]; - - default: - return []; - } -}; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/Database/Database.util.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/Database/Database.util.tsx index c7edadfcc32f..f298ff562506 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/Database/Database.util.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/Database/Database.util.tsx @@ -22,12 +22,7 @@ import { getEntityDetailsPath, NO_DATA_PLACEHOLDER, } from '../../constants/constants'; -import { DetailPageWidgetKeys } from '../../enums/CustomizeDetailPage.enum'; -import { - EntityTabs, - EntityType, - TabSpecificField, -} from '../../enums/entity.enum'; +import { EntityType, TabSpecificField } from '../../enums/entity.enum'; import { DatabaseSchema } from '../../generated/entity/data/databaseSchema'; import { EntityReference } from '../../generated/entity/type'; import { UsageDetails } from '../../generated/type/entityUsage'; @@ -120,70 +115,3 @@ export const schemaTableColumns: ColumnsType = [ getUsagePercentile(text?.weeklyStats?.percentileRank ?? 0), }, ]; - -export const getDatabaseDetailsPageDefaultLayout = (tab: EntityTabs) => { - switch (tab) { - case EntityTabs.SCHEMA: - return [ - { - h: 2, - i: DetailPageWidgetKeys.DESCRIPTION, - w: 6, - x: 0, - y: 0, - static: false, - }, - { - h: 8, - i: DetailPageWidgetKeys.TABLE_SCHEMA, - w: 6, - x: 0, - y: 0, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.FREQUENTLY_JOINED_TABLES, - w: 2, - x: 6, - y: 0, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.DATA_PRODUCTS, - w: 2, - x: 6, - y: 1, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.TAGS, - w: 2, - x: 6, - y: 2, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.GLOSSARY_TERMS, - w: 2, - x: 6, - y: 3, - static: false, - }, - { - h: 3, - i: DetailPageWidgetKeys.CUSTOM_PROPERTIES, - w: 2, - x: 6, - y: 4, - static: false, - }, - ]; - - default: - return []; - } -}; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/DatabaseSchemaClassBase.ts b/openmetadata-ui/src/main/resources/ui/src/utils/DatabaseSchemaClassBase.ts index 5223f5697bf2..d85dae100fb9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/DatabaseSchemaClassBase.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/DatabaseSchemaClassBase.ts @@ -14,7 +14,6 @@ import { EntityTags } from 'Models'; import { PagingHandlerParams } from '../components/common/NextPrevious/NextPrevious.interface'; import { TabProps } from '../components/common/TabsLabel/TabsLabel.interface'; import { OperationPermission } from '../context/PermissionProvider/PermissionProvider.interface'; -import { DetailPageWidgetKeys } from '../enums/CustomizeDetailPage.enum'; import { EntityTabs } from '../enums/entity.enum'; import { DatabaseSchema } from '../generated/entity/data/databaseSchema'; import { Table } from '../generated/entity/data/table'; @@ -65,81 +64,6 @@ class DatabaseSchemaClassBase { ): TabProps[] { return getDataBaseSchemaPageBaseTabs(databaseSchemaTabData); } - - public getDatabaseSchemaPageTabsIds(): EntityTabs[] { - return [ - EntityTabs.SCHEMA, - EntityTabs.ACTIVITY_FEED, - EntityTabs.CUSTOM_PROPERTIES, - ]; - } - - public getDatabaseSchemaPageDefaultLayout = (tab: EntityTabs) => { - switch (tab) { - case EntityTabs.SCHEMA: - return [ - { - h: 2, - i: DetailPageWidgetKeys.DESCRIPTION, - w: 6, - x: 0, - y: 0, - static: false, - }, - { - h: 8, - i: DetailPageWidgetKeys.TABLE_SCHEMA, - w: 6, - x: 0, - y: 0, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.FREQUENTLY_JOINED_TABLES, - w: 2, - x: 6, - y: 0, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.DATA_PRODUCTS, - w: 2, - x: 6, - y: 1, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.TAGS, - w: 2, - x: 6, - y: 2, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.GLOSSARY_TERMS, - w: 2, - x: 6, - y: 3, - static: false, - }, - { - h: 3, - i: DetailPageWidgetKeys.CUSTOM_PROPERTIES, - w: 2, - x: 6, - y: 4, - static: false, - }, - ]; - - default: - return []; - } - }; } const databaseSchemaClassBase = new DatabaseSchemaClassBase(); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/EntityVersionUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/EntityVersionUtils.tsx index 24a7a6f7eced..e67551d66224 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/EntityVersionUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/EntityVersionUtils.tsx @@ -46,6 +46,8 @@ import { EntityType, TabSpecificField } from '../enums/entity.enum'; import { EntityChangeOperations } from '../enums/VersionPage.enum'; import { Column as ContainerColumn } from '../generated/entity/data/container'; import { Column as DataModelColumn } from '../generated/entity/data/dashboardDataModel'; +import { Glossary } from '../generated/entity/data/glossary'; +import { GlossaryTerm } from '../generated/entity/data/glossaryTerm'; import { Column as TableColumn } from '../generated/entity/data/table'; import { Field } from '../generated/entity/data/topic'; import { @@ -606,6 +608,9 @@ export const getCommonExtraInfoForVersionDetails = ( tier?: TagLabel, domain?: EntityReference ) => { + // const { entityRef: ownerRef, entityDisplayName: ownerDisplayName } = + // getEntityReferenceDiffFromFieldName('owners', changeDescription, owners); + const { owners: ownerRef, ownerDisplayName } = getOwnerDiff( owners ?? [], changeDescription @@ -1029,10 +1034,7 @@ export const getOwnerDiff = ( }; export const getOwnerVersionLabel = ( - entity: { - [TabSpecificField.OWNERS]?: EntityReference[]; - changeDescription?: ChangeDescription; - }, + entity: Glossary | GlossaryTerm, isVersionView: boolean, ownerField = TabSpecificField.OWNERS, // Can be owners, experts, reviewers all are OwnerLabels hasPermission = true diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/GlobalSettingsClassBase.ts b/openmetadata-ui/src/main/resources/ui/src/utils/GlobalSettingsClassBase.ts index be29d9324f39..6cc2bb5c013b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/GlobalSettingsClassBase.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/GlobalSettingsClassBase.ts @@ -17,6 +17,7 @@ import { ReactComponent as AdminIcon } from '../assets/svg/admin-colored.svg'; import { ReactComponent as ApplicationIcon } from '../assets/svg/application-colored.svg'; import { ReactComponent as BotIcon } from '../assets/svg/bot-colored.svg'; import { ReactComponent as AppearanceIcon } from '../assets/svg/custom-logo-colored.svg'; +import { ReactComponent as CustomDashboardLogoIcon } from '../assets/svg/customize-landing-page-colored.svg'; import { ReactComponent as DashboardIcon } from '../assets/svg/dashboard-colored.svg'; import { ReactComponent as DatabaseIcon } from '../assets/svg/database-colored.svg'; import { ReactComponent as EmailIcon } from '../assets/svg/email-colored.svg'; @@ -95,10 +96,6 @@ class GlobalSettingsClassBase { name: t('label.application-plural'), url: GlobalSettingsMenuCategory.APPLICATIONS, }, - [GlobalSettingsMenuCategory.PERSONA]: { - name: t('label.persona'), - url: GlobalSettingsMenuCategory.PERSONA, - }, }; protected updateSettingCategories( @@ -273,6 +270,14 @@ class GlobalSettingsClassBase { key: `${GlobalSettingsMenuCategory.MEMBERS}.${GlobalSettingOptions.ADMINS}`, icon: AdminIcon, }, + + { + label: t('label.persona-plural'), + description: t('message.page-sub-header-for-persona'), + isProtected: Boolean(isAdminUser), + key: `${GlobalSettingsMenuCategory.MEMBERS}.${GlobalSettingOptions.PERSONA}`, + icon: PersonasIcon, + }, ], }, { @@ -310,6 +315,17 @@ class GlobalSettingsClassBase { key: `${GlobalSettingsMenuCategory.PREFERENCES}.${GlobalSettingOptions.APPEARANCE}`, icon: AppearanceIcon, }, + { + label: t('label.customize-entity', { + entity: t('label.landing-page'), + }), + description: t( + 'message.page-sub-header-for-customize-landing-page' + ), + isProtected: Boolean(isAdminUser), + key: `${GlobalSettingsMenuCategory.PREFERENCES}.${GlobalSettingOptions.CUSTOMIZE_LANDING_PAGE}`, + icon: CustomDashboardLogoIcon, + }, { label: t('label.email'), description: t('message.email-configuration-message'), @@ -519,13 +535,6 @@ class GlobalSettingsClassBase { key: GlobalSettingOptions.BOTS, icon: BotIcon, }, - { - category: t('label.persona-plural'), - description: t('message.page-sub-header-for-persona'), - isProtected: Boolean(isAdminUser), - key: GlobalSettingOptions.PERSONA, - icon: PersonasIcon, - }, ]; } } diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryTerm/GlossaryTermUtil.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryTerm/GlossaryTermUtil.tsx deleted file mode 100644 index e88986669211..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryTerm/GlossaryTermUtil.tsx +++ /dev/null @@ -1,214 +0,0 @@ -/* - * 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 { TabsProps } from 'antd'; -import { isUndefined, uniqueId } from 'lodash'; -import React from 'react'; -import EmptyWidgetPlaceholder from '../../components/MyData/CustomizableComponents/EmptyWidgetPlaceholder/EmptyWidgetPlaceholder'; -import { SIZE } from '../../enums/common.enum'; -import { LandingPageWidgetKeys } from '../../enums/CustomizablePage.enum'; -import { GlossaryTermDetailPageWidgetKeys } from '../../enums/CustomizeDetailPage.enum'; -import { EntityTabs } from '../../enums/entity.enum'; -import { Document } from '../../generated/entity/docStore/document'; -import { Tab } from '../../generated/system/ui/uiCustomization'; -import { WidgetConfig } from '../../pages/CustomizablePage/CustomizablePage.interface'; -import customizeGlossaryTermPageClassBase from '../CustomiseGlossaryTermPage/CustomizeGlossaryTermPage'; -import { moveEmptyWidgetToTheEnd } from '../CustomizableLandingPageUtils'; -import customizeMyDataPageClassBase from '../CustomizeMyDataPageClassBase'; -import { getEntityName } from '../EntityUtils'; - -export const getWidgetFromKey = ({ - widgetConfig, - handleOpenAddWidgetModal, - handlePlaceholderWidgetKey, - handleRemoveWidget, - isEditView, - iconHeight, - iconWidth, -}: { - widgetConfig: WidgetConfig; - handleOpenAddWidgetModal?: () => void; - handlePlaceholderWidgetKey?: (key: string) => void; - handleRemoveWidget?: (key: string) => void; - iconHeight?: SIZE; - iconWidth?: SIZE; - isEditView?: boolean; -}) => { - if ( - widgetConfig.i.endsWith('.EmptyWidgetPlaceholder') && - !isUndefined(handleOpenAddWidgetModal) && - !isUndefined(handlePlaceholderWidgetKey) && - !isUndefined(handleRemoveWidget) - ) { - return ( - - ); - } - - const widgetKey = customizeGlossaryTermPageClassBase.getKeyFromWidgetName( - widgetConfig.i - ); - - const Widget = customizeGlossaryTermPageClassBase.getWidgetsFromKey< - typeof widgetKey - >(widgetConfig.i as GlossaryTermDetailPageWidgetKeys); - - return ( - - ); -}; - -const getNewWidgetPlacement = ( - currentLayout: WidgetConfig[], - widgetWidth: number -) => { - const lowestWidgetLayout = currentLayout.reduce( - (acc, widget) => { - if ( - widget.y >= acc.y && - widget.i !== LandingPageWidgetKeys.EMPTY_WIDGET_PLACEHOLDER - ) { - if (widget.y === acc.y && widget.x < acc.x) { - return acc; - } - - return widget; - } - - return acc; - }, - { y: 0, x: 0, w: 0 } - ); - - // Check if there's enough space to place the new widget on the same row - if ( - customizeMyDataPageClassBase.landingPageMaxGridSize - - (lowestWidgetLayout.x + lowestWidgetLayout.w) >= - widgetWidth - ) { - return { - x: lowestWidgetLayout.x + lowestWidgetLayout.w, - y: lowestWidgetLayout.y, - }; - } - - // Otherwise, move to the next row - return { - x: 0, - y: lowestWidgetLayout.y + 1, - }; -}; - -export const getAddWidgetHandler = - ( - newWidgetData: Document, - placeholderWidgetKey: string, - widgetWidth: number, - maxGridSize: number - ) => - (currentLayout: Array) => { - const widgetFQN = uniqueId(`${newWidgetData.fullyQualifiedName}-`); - const widgetHeight = customizeMyDataPageClassBase.getWidgetHeight( - newWidgetData.name - ); - - // The widget with key "ExtraWidget.EmptyWidgetPlaceholder" will always remain in the bottom - // and is not meant to be replaced hence - // if placeholderWidgetKey is "ExtraWidget.EmptyWidgetPlaceholder" - // append the new widget in the array - // else replace the new widget with other placeholder widgets - if ( - placeholderWidgetKey === LandingPageWidgetKeys.EMPTY_WIDGET_PLACEHOLDER - ) { - return [ - ...moveEmptyWidgetToTheEnd(currentLayout), - { - w: widgetWidth, - h: widgetHeight, - i: widgetFQN, - static: false, - ...getNewWidgetPlacement(currentLayout, widgetWidth), - }, - ]; - } else { - return currentLayout.map((widget: WidgetConfig) => { - const widgetX = - widget.x + widgetWidth <= maxGridSize - ? widget.x - : maxGridSize - widgetWidth; - - return widget.i === placeholderWidgetKey - ? { - ...widget, - i: widgetFQN, - h: widgetHeight, - w: widgetWidth, - x: widgetX, - } - : widget; - }); - } - }; - -export const getGlossaryTermDetailTabs = ( - defaultTabs: TabsProps['items'], - customizedTabs?: Tab[], - defaultTabId: EntityTabs = EntityTabs.OVERVIEW -) => { - if (!customizedTabs) { - return defaultTabs; - } - const overviewTab = defaultTabs?.find((t) => t.key === defaultTabId); - - const newTabs = - customizedTabs?.map((t) => { - const tabItemDetails = defaultTabs?.find((i) => i.key === t.id); - - return ( - tabItemDetails ?? { - label: getEntityName(t), - key: t.id, - children: overviewTab?.children, - } - ); - }) ?? defaultTabs; - - return newTabs; -}; - -export const getTabLabelMap = (tabs?: Tab[]): Record => { - const labelMap = {} as Record; - - return ( - tabs?.reduce((acc: Record, item) => { - if (item.id && item.displayName) { - const tab = item.id as EntityTabs; - acc[tab] = item.displayName; - } - - return acc; - }, labelMap) ?? labelMap - ); -}; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.tsx index c2bfda868435..b95492e8bf39 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.tsx @@ -11,32 +11,18 @@ * limitations under the License. */ -import Icon from '@ant-design/icons'; -import { Tag, Tooltip, Typography } from 'antd'; +import { Typography } from 'antd'; import { DefaultOptionType } from 'antd/lib/select'; -import classNames from 'classnames'; import { isEmpty, isUndefined } from 'lodash'; import React from 'react'; -import { ReactComponent as ExternalLinkIcon } from '../assets/svg/external-links.svg'; import { StatusType } from '../components/common/StatusBadge/StatusBadge.interface'; import { ModifiedGlossaryTerm } from '../components/Glossary/GlossaryTermTab/GlossaryTermTab.interface'; import { ModifiedGlossary } from '../components/Glossary/useGlossary.store'; import { FQN_SEPARATOR_CHAR } from '../constants/char.constants'; -import { - ICON_DIMENSION, - SUCCESS_COLOR, - TEXT_BODY_COLOR, - TEXT_GREY_MUTED, -} from '../constants/constants'; import { EntityType } from '../enums/entity.enum'; import { Glossary } from '../generated/entity/data/glossary'; -import { - GlossaryTerm, - Status, - TermReference, -} from '../generated/entity/data/glossaryTerm'; +import { GlossaryTerm, Status } from '../generated/entity/data/glossaryTerm'; import { getEntityName } from './EntityUtils'; -import { VersionStatus } from './EntityVersionUtils.interface'; import Fqn from './Fqn'; import { getGlossaryPath } from './RouterUtils'; @@ -348,49 +334,3 @@ export const filterTreeNodeOptions = ( return filterNodes(options as ModifiedGlossaryTerm[]); }; - -export const renderReferenceElement = ( - ref: TermReference, - versionStatus?: VersionStatus -) => { - let iconColor: string; - let textClassName: string; - if (versionStatus?.added) { - iconColor = SUCCESS_COLOR; - textClassName = 'text-success'; - } else if (versionStatus?.removed) { - iconColor = TEXT_GREY_MUTED; - textClassName = 'text-grey-muted'; - } else { - iconColor = TEXT_BODY_COLOR; - textClassName = 'text-body'; - } - - return ( - - - -
- - {ref.name} -
-
-
-
- ); -}; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/LeftSidebarClassBase.ts b/openmetadata-ui/src/main/resources/ui/src/utils/LeftSidebarClassBase.ts index 77a6b0f26934..e610d190cc80 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/LeftSidebarClassBase.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/LeftSidebarClassBase.ts @@ -14,21 +14,11 @@ import { LeftSidebarItem } from '../components/MyData/LeftSidebar/LeftSidebar.in import { SIDEBAR_LIST } from '../constants/LeftSidebar.constants'; class LeftSidebarClassBase { - sidebarItems: Array; - - constructor() { - this.sidebarItems = SIDEBAR_LIST; - } - /** * getSidebarItems */ public getSidebarItems(): Array { - return this.sidebarItems; - } - - public setSidebarItems(items: Array): void { - this.sidebarItems = items; + return SIDEBAR_LIST; } } diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/Persona/PersonaUtils.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/Persona/PersonaUtils.test.ts deleted file mode 100644 index 7ada07359838..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/utils/Persona/PersonaUtils.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -/* - * 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 { PageType } from '../../generated/system/ui/uiCustomization'; -import { - getCustomizePageCategories, - getCustomizePageOptions, -} from './PersonaUtils'; - -describe('PersonaUtils', () => { - describe('getCustomizePageCategories', () => { - it('should return the correct categories', () => { - const categories = getCustomizePageCategories(); - - expect(categories).toEqual([ - { - key: 'navigation', - label: 'label.navigation', - description: 'Navigation', - icon: 'svg-mock', - }, - { - key: PageType.LandingPage, - label: 'label.homepage', - description: 'Homepage', - icon: 'svg-mock', - }, - { - key: 'governance', - label: 'label.governance', - description: 'Governance', - icon: 'svg-mock', - }, - { - key: 'data-assets', - label: 'label.data-asset-plural', - description: 'Data assets', - icon: 'svg-mock', - }, - ]); - }); - }); - - describe('getCustomizePageOptions', () => { - it('should return the correct options for governance category', () => { - const options = getCustomizePageOptions('governance'); - - expect(options).toEqual([ - { - key: PageType.Domain, - label: 'Domain', - description: PageType.Domain, - icon: 'svg-mock', - }, - { - key: PageType.Glossary, - label: 'Glossary', - description: PageType.Glossary, - icon: 'svg-mock', - }, - { - key: PageType.GlossaryTerm, - label: 'Glossary Term', - description: PageType.GlossaryTerm, - icon: 'svg-mock', - }, - ]); - }); - - it('should return the correct options for data-assets category', () => { - const options = getCustomizePageOptions('data-assets'); - - expect(options).toEqual( - expect.arrayContaining([ - { - key: PageType.Dashboard, - label: 'Dashboard', - description: PageType.Dashboard, - icon: 'svg-mock', - }, - { - key: PageType.Database, - label: 'Database', - description: PageType.Database, - icon: 'svg-mock', - }, - { - key: PageType.Pipeline, - label: 'Pipeline', - description: PageType.Pipeline, - icon: 'svg-mock', - }, - { - key: PageType.Table, - label: 'Table', - description: PageType.Table, - icon: 'svg-mock', - }, - { - key: PageType.Container, - label: 'Container', - description: PageType.Container, - icon: 'svg-mock', - }, - ]) - ); - }); - - it('should return an empty array for an unknown category', () => { - const options = getCustomizePageOptions('unknown-category'); - - expect(options).toEqual([]); - }); - }); -}); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/Persona/PersonaUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/Persona/PersonaUtils.ts deleted file mode 100644 index c795e521d1ad..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/utils/Persona/PersonaUtils.ts +++ /dev/null @@ -1,129 +0,0 @@ -/* - * 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 { camelCase, map, startCase } from 'lodash'; -import { ReactComponent as DashboardIcon } from '../../assets/svg/dashboard-colored.svg'; -import { ReactComponent as DataAssetsIcon } from '../../assets/svg/data-assets.svg'; -import { ReactComponent as DatabaseIcon } from '../../assets/svg/database-colored.svg'; -import { ReactComponent as GlossaryIcon } from '../../assets/svg/glossary-colored.svg'; -import { ReactComponent as GovernIcon } from '../../assets/svg/governance.svg'; -import { ReactComponent as HomepageIcon } from '../../assets/svg/homepage.svg'; -import { ReactComponent as DashboardDataModelIcon } from '../../assets/svg/ic-dashboard-data-model-colored.svg'; -import { ReactComponent as SchemaIcon } from '../../assets/svg/ic-database-schema-colored.svg'; -import { ReactComponent as MessagingIcon } from '../../assets/svg/messaging-colored.svg'; -import { ReactComponent as NavigationIcon } from '../../assets/svg/navigation.svg'; -import { ReactComponent as PipelineIcon } from '../../assets/svg/pipeline-colored.svg'; -import { ReactComponent as SearchIcon } from '../../assets/svg/search-colored.svg'; -import { ReactComponent as StorageIcon } from '../../assets/svg/storage-colored.svg'; -import { ReactComponent as StoredProcedureIcon } from '../../assets/svg/stored-procedure-colored.svg'; -import { ReactComponent as TableIcon } from '../../assets/svg/table-colored.svg'; - -import { EntityType } from '../../enums/entity.enum'; -import { PageType } from '../../generated/system/ui/uiCustomization'; -import { SettingMenuItem } from '../GlobalSettingsUtils'; -import i18n from '../i18next/LocalUtil'; - -const ENTITY_ICONS: Record = { - [EntityType.TABLE]: TableIcon, - [EntityType.CONTAINER]: StorageIcon, - [EntityType.DASHBOARD]: DashboardIcon, - [EntityType.DASHBOARD_DATA_MODEL]: DashboardDataModelIcon, - [EntityType.DATABASE]: DatabaseIcon, - [EntityType.DATABASE_SCHEMA]: SchemaIcon, - [EntityType.DOMAIN]: SchemaIcon, - [EntityType.GLOSSARY]: GlossaryIcon, - [EntityType.GLOSSARY_TERM]: GlossaryIcon, - [EntityType.PIPELINE]: PipelineIcon, - [EntityType.SEARCH_INDEX]: SearchIcon, - [EntityType.STORED_PROCEDURE]: StoredProcedureIcon, - [EntityType.TOPIC]: MessagingIcon, - [EntityType.GOVERN]: GovernIcon, - ['dataAssets']: DataAssetsIcon, - ['homepage']: HomepageIcon, - ['navigation']: NavigationIcon, - ['governance']: GovernIcon, - [PageType.LandingPage]: MessagingIcon, -}; - -export const getCustomizePageCategories = (): SettingMenuItem[] => { - return [ - { - key: 'navigation', - label: i18n.t('label.navigation'), - description: 'Navigation', - icon: ENTITY_ICONS[camelCase('Navigation')], - }, - { - key: PageType.LandingPage, - label: i18n.t('label.homepage'), - description: 'Homepage', - icon: ENTITY_ICONS[camelCase('Homepage')], - }, - { - key: 'governance', - label: i18n.t('label.governance'), - description: 'Governance', - icon: ENTITY_ICONS[camelCase('GOVERN')], - }, - { - key: 'data-assets', - label: i18n.t('label.data-asset-plural'), - description: 'Data assets', - icon: ENTITY_ICONS[camelCase('data-assets')], - }, - ]; -}; - -const generateSettingItems = (pageType: PageType): SettingMenuItem => ({ - key: pageType, - label: startCase(pageType), - description: pageType, - icon: ENTITY_ICONS[camelCase(pageType)], -}); - -export const getCustomizePageOptions = ( - category: string -): SettingMenuItem[] => { - const list = map(PageType); - - switch (category) { - case 'governance': - return list.reduce((acc, item) => { - if ( - [PageType.Glossary, PageType.GlossaryTerm, PageType.Domain].includes( - item - ) - ) { - acc.push(generateSettingItems(item)); - } - - return acc; - }, [] as SettingMenuItem[]); - case 'data-assets': - return list.reduce((acc, item) => { - if ( - ![ - PageType.Glossary, - PageType.GlossaryTerm, - PageType.Domain, - PageType.LandingPage, - ].includes(item) - ) { - acc.push(generateSettingItems(item)); - } - - return acc; - }, [] as SettingMenuItem[]); - default: - return []; - } -}; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/PipelineDetailsUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/PipelineDetailsUtils.ts index 93006ab3c062..e48ee97f58c4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/PipelineDetailsUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/PipelineDetailsUtils.ts @@ -15,8 +15,7 @@ import { isUndefined } from 'lodash'; import { ReactComponent as IconFailBadge } from '../assets/svg/fail-badge.svg'; import { ReactComponent as IconSkippedBadge } from '../assets/svg/skipped-badge.svg'; import { ReactComponent as IconSuccessBadge } from '../assets/svg/success-badge.svg'; -import { DetailPageWidgetKeys } from '../enums/CustomizeDetailPage.enum'; -import { EntityTabs, TabSpecificField } from '../enums/entity.enum'; +import { TabSpecificField } from '../enums/entity.enum'; import { Pipeline, StatusType, @@ -63,70 +62,3 @@ export const getFormattedPipelineDetails = ( return pipelineDetails; } }; - -export const getPipelineDetailsPageDefaultLayout = (tab: EntityTabs) => { - switch (tab) { - case EntityTabs.SCHEMA: - return [ - { - h: 2, - i: DetailPageWidgetKeys.DESCRIPTION, - w: 6, - x: 0, - y: 0, - static: false, - }, - { - h: 8, - i: DetailPageWidgetKeys.TABLE_SCHEMA, - w: 6, - x: 0, - y: 0, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.FREQUENTLY_JOINED_TABLES, - w: 2, - x: 6, - y: 0, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.DATA_PRODUCTS, - w: 2, - x: 6, - y: 1, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.TAGS, - w: 2, - x: 6, - y: 2, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.GLOSSARY_TERMS, - w: 2, - x: 6, - y: 3, - static: false, - }, - { - h: 3, - i: DetailPageWidgetKeys.CUSTOM_PROPERTIES, - w: 2, - x: 6, - y: 4, - static: false, - }, - ]; - - default: - return []; - } -}; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/RouterUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/RouterUtils.ts index 4226f62c3f27..0de983d84c2f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/RouterUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/RouterUtils.ts @@ -581,10 +581,11 @@ export const getClassificationVersionsPath = ( }; export const getPersonaDetailsPath = (fqn: string) => { - let path = ROUTES.SETTINGS_WITH_CATEGORY_FQN; + let path = ROUTES.SETTINGS_WITH_TAB_FQN; path = path - .replace(PLACEHOLDER_SETTING_CATEGORY, GlobalSettingOptions.PERSONA) + .replace(PLACEHOLDER_SETTING_CATEGORY, GlobalSettingsMenuCategory.MEMBERS) + .replace(PLACEHOLDER_ROUTE_TAB, GlobalSettingOptions.PERSONA) .replace(PLACEHOLDER_ROUTE_FQN, getEncodedFqn(fqn)); return path; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/SearchIndexUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/SearchIndexUtils.tsx index fd85984bd5d8..40c1335146da 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/SearchIndexUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/SearchIndexUtils.tsx @@ -12,8 +12,7 @@ */ import { uniqueId } from 'lodash'; -import { DetailPageWidgetKeys } from '../enums/CustomizeDetailPage.enum'; -import { EntityTabs, TabSpecificField } from '../enums/entity.enum'; +import { TabSpecificField } from '../enums/entity.enum'; import { SearchIndexField } from '../generated/entity/data/searchIndex'; import { sortTagsCaseInsensitive } from './CommonUtils'; @@ -41,70 +40,3 @@ export const makeData = ( children: column.children ? makeData(column.children) : undefined, })); }; - -export const getSearchIndexDetailsPageDefaultLayout = (tab: EntityTabs) => { - switch (tab) { - case EntityTabs.SCHEMA: - return [ - { - h: 2, - i: DetailPageWidgetKeys.DESCRIPTION, - w: 6, - x: 0, - y: 0, - static: false, - }, - { - h: 8, - i: DetailPageWidgetKeys.TABLE_SCHEMA, - w: 6, - x: 0, - y: 0, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.FREQUENTLY_JOINED_TABLES, - w: 2, - x: 6, - y: 0, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.DATA_PRODUCTS, - w: 2, - x: 6, - y: 1, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.TAGS, - w: 2, - x: 6, - y: 2, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.GLOSSARY_TERMS, - w: 2, - x: 6, - y: 3, - static: false, - }, - { - h: 3, - i: DetailPageWidgetKeys.CUSTOM_PROPERTIES, - w: 2, - x: 6, - y: 4, - static: false, - }, - ]; - - default: - return []; - } -}; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/StoredProceduresUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/StoredProceduresUtils.tsx index b4deb38f412c..d1d1e05e9e74 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/StoredProceduresUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/StoredProceduresUtils.tsx @@ -10,75 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { DetailPageWidgetKeys } from '../enums/CustomizeDetailPage.enum'; -import { EntityTabs, TabSpecificField } from '../enums/entity.enum'; +import { TabSpecificField } from '../enums/entity.enum'; // eslint-disable-next-line max-len export const STORED_PROCEDURE_DEFAULT_FIELDS = `${TabSpecificField.OWNERS}, ${TabSpecificField.FOLLOWERS},${TabSpecificField.TAGS}, ${TabSpecificField.DOMAIN},${TabSpecificField.DATA_PRODUCTS}, ${TabSpecificField.VOTES},${TabSpecificField.EXTENSION}`; - -export const getStoredProceduresPageDefaultLayout = (tab: EntityTabs) => { - switch (tab) { - case EntityTabs.SCHEMA: - return [ - { - h: 2, - i: DetailPageWidgetKeys.DESCRIPTION, - w: 6, - x: 0, - y: 0, - static: false, - }, - { - h: 8, - i: DetailPageWidgetKeys.TABLE_SCHEMA, - w: 6, - x: 0, - y: 0, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.FREQUENTLY_JOINED_TABLES, - w: 2, - x: 6, - y: 0, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.DATA_PRODUCTS, - w: 2, - x: 6, - y: 1, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.TAGS, - w: 2, - x: 6, - y: 2, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.GLOSSARY_TERMS, - w: 2, - x: 6, - y: 3, - static: false, - }, - { - h: 3, - i: DetailPageWidgetKeys.CUSTOM_PROPERTIES, - w: 2, - x: 6, - y: 4, - static: false, - }, - ]; - - default: - return []; - } -}; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/TableClassBase.ts b/openmetadata-ui/src/main/resources/ui/src/utils/TableClassBase.ts index 91e354e073ab..64e9b0e9d2ff 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/TableClassBase.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/TableClassBase.ts @@ -12,15 +12,8 @@ */ import { TabProps } from '../components/common/TabsLabel/TabsLabel.interface'; import { OperationPermission } from '../context/PermissionProvider/PermissionProvider.interface'; -import { DetailPageWidgetKeys } from '../enums/CustomizeDetailPage.enum'; import { EntityTabs } from '../enums/entity.enum'; -import { - Constraint, - DatabaseServiceType, - DataType, - Table, - TableType, -} from '../generated/entity/data/table'; +import { Table } from '../generated/entity/data/table'; import { TestSummary } from '../generated/tests/testCase'; import { FeedCounts } from '../interface/feed.interface'; import { getTableDetailPageBaseTabs } from './TableUtils'; @@ -55,210 +48,9 @@ class TableClassBase { return getTableDetailPageBaseTabs(tableDetailsPageProps); } - public getTableDetailPageTabsIds(): EntityTabs[] { - return [ - EntityTabs.SCHEMA, - EntityTabs.ACTIVITY_FEED, - EntityTabs.SAMPLE_DATA, - EntityTabs.TABLE_QUERIES, - EntityTabs.PROFILER, - EntityTabs.INCIDENTS, - EntityTabs.LINEAGE, - EntityTabs.VIEW_DEFINITION, - EntityTabs.CUSTOM_PROPERTIES, - ]; - } - - public getDefaultLayout(tab: EntityTabs) { - switch (tab) { - case EntityTabs.SCHEMA: - return [ - { - h: 2, - i: DetailPageWidgetKeys.DESCRIPTION, - w: 6, - x: 0, - y: 0, - static: false, - }, - { - h: 11, - i: DetailPageWidgetKeys.TABLE_SCHEMA, - w: 6, - x: 0, - y: 0, - static: false, - }, - { - h: 2, - i: DetailPageWidgetKeys.FREQUENTLY_JOINED_TABLES, - w: 2, - x: 6, - y: 0, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.DATA_PRODUCTS, - w: 2, - x: 6, - y: 1, - static: false, - }, - { - h: 2, - i: DetailPageWidgetKeys.TAGS, - w: 2, - x: 6, - y: 2, - static: false, - }, - { - h: 2, - i: DetailPageWidgetKeys.GLOSSARY_TERMS, - w: 2, - x: 6, - y: 3, - static: false, - }, - { - h: 4, - i: DetailPageWidgetKeys.CUSTOM_PROPERTIES, - w: 2, - x: 6, - y: 4, - static: false, - }, - ]; - - default: - return []; - } - } - public getAlertEnableStatus() { return false; } - - public getDummyData(): Table { - return { - id: 'ab4f893b-c303-43d9-9375-3e620a670b02', - name: 'raw_product_catalog', - fullyQualifiedName: - 'sample_data.ecommerce_db.shopify.raw_product_catalog', - description: - 'This is a raw product catalog table contains the product listing, price, seller etc.. represented in our online DB. ', - version: 0.2, - updatedAt: 1688442727895, - updatedBy: 'admin', - tableType: TableType.Regular, - dataProducts: [ - { - id: 'c9b891b1-5d60-4171-9af0-7fd6d74f8f2b', - type: 'dataProduct', - name: 'Design Data product ', - fullyQualifiedName: 'Design Data product ', - description: - "Here's the description for the Design Data product Name.", - displayName: 'Design Data product Name', - href: '#', - }, - ], - joins: { - startDate: new Date(), - dayCount: 30, - columnJoins: [ - { - columnName: 'address_id', - joinedWith: [ - { - fullyQualifiedName: - 'sample_data.ecommerce_db.shopify.dim_address_clean.address_id', - joinCount: 0, - }, - { - fullyQualifiedName: - 'sample_data.ecommerce_db.shopify.dim_address.address_id', - joinCount: 0, - }, - ], - }, - ], - directTableJoins: [ - { - fullyQualifiedName: 'sample_data.ecommerce_db.shopify.dim_address', - joinCount: 0, - }, - ], - }, - columns: [ - { - name: 'shop_id', - displayName: 'Shop Id Customer', - dataType: DataType.Number, - dataTypeDisplay: 'numeric', - description: - 'Unique identifier for the store. This column is the primary key for this table.', - fullyQualifiedName: - 'sample_data.ecommerce_db.shopify."dim.shop".shop_id', - tags: [], - constraint: Constraint.PrimaryKey, - ordinalPosition: 1, - }, - ], - owners: [ - { - id: '38be030f-f817-4712-bc3b-ff7b9b9b805e', - type: 'user', - name: 'aaron_johnson0', - fullyQualifiedName: 'aaron_johnson0', - displayName: 'Aaron Johnson', - deleted: false, - }, - ], - databaseSchema: { - id: '3f0d9c39-0926-4028-8070-65b0c03556cb', - type: 'databaseSchema', - name: 'shopify', - fullyQualifiedName: 'sample_data.ecommerce_db.shopify', - description: - 'This **mock** database contains schema related to shopify sales and orders with related dimension tables.', - deleted: false, - }, - database: { - id: 'f085e133-e184-47c8-ada5-d7e005d3153b', - type: 'database', - name: 'ecommerce_db', - fullyQualifiedName: 'sample_data.ecommerce_db', - description: - 'This **mock** database contains schemas related to shopify sales and orders with related dimension tables.', - deleted: false, - }, - service: { - id: 'e61069a9-29e3-49fa-a7f4-f5227ae50b72', - type: 'databaseService', - name: 'sample_data', - fullyQualifiedName: 'sample_data', - deleted: false, - }, - serviceType: DatabaseServiceType.BigQuery, - tags: [], - followers: [], - changeDescription: { - fieldsAdded: [ - { - name: 'owner', - newValue: - '{"id":"38be030f-f817-4712-bc3b-ff7b9b9b805e","type":"user","name":"aaron_johnson0","fullyQualifiedName":"aaron_johnson0","displayName":"Aaron Johnson","deleted":false}', - }, - ], - fieldsUpdated: [], - fieldsDeleted: [], - previousVersion: 0.1, - }, - deleted: false, - }; - } } const tableClassBase = new TableClassBase(); 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 decb520cc99d..7a13f29d299d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/TagClassBase.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/TagClassBase.ts @@ -18,7 +18,7 @@ import { escapeESReservedCharacters, getEncodedFqn } from './StringsUtils'; class TagClassBase { public async getTags(searchText: string, page: number) { - // this is to escape and encode any chars which is known by ES search internally + // this is to esacpe and encode any chars which is known by ES search internally const encodedValue = getEncodedFqn(escapeESReservedCharacters(searchText)); const res = await searchQuery({ query: `*${encodedValue}*`, diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/TopicDetailsUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/TopicDetailsUtils.ts index 804965019ee6..20959667e6e1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/TopicDetailsUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/TopicDetailsUtils.ts @@ -11,72 +11,18 @@ * limitations under the License. */ -import { DetailPageWidgetKeys } from '../enums/CustomizeDetailPage.enum'; -import { EntityTabs } from '../enums/entity.enum'; +import { TopicConfigObjectInterface } from '../components/Topic/TopicDetails/TopicDetails.interface'; +import { Topic } from '../generated/entity/data/topic'; -export const getTopicDetailsPageDefaultLayout = (tab: EntityTabs) => { - switch (tab) { - case EntityTabs.SCHEMA: - return [ - { - h: 2, - i: DetailPageWidgetKeys.DESCRIPTION, - w: 6, - x: 0, - y: 0, - static: false, - }, - { - h: 8, - i: DetailPageWidgetKeys.TABLE_SCHEMA, - w: 6, - x: 0, - y: 0, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.FREQUENTLY_JOINED_TABLES, - w: 2, - x: 6, - y: 0, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.DATA_PRODUCTS, - w: 2, - x: 6, - y: 1, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.TAGS, - w: 2, - x: 6, - y: 2, - static: false, - }, - { - h: 1, - i: DetailPageWidgetKeys.GLOSSARY_TERMS, - w: 2, - x: 6, - y: 3, - static: false, - }, - { - h: 3, - i: DetailPageWidgetKeys.CUSTOM_PROPERTIES, - w: 2, - x: 6, - y: 4, - static: false, - }, - ]; - - default: - return []; - } +export const getConfigObject = ( + topicDetails: Topic +): TopicConfigObjectInterface => { + return { + Partitions: topicDetails.partitions, + 'Replication Factor': topicDetails.replicationFactor, + 'Retention Size': topicDetails.retentionSize, + 'CleanUp Policies': topicDetails.cleanupPolicies, + 'Max Message Size': topicDetails.maximumMessageSize, + 'Schema Type': topicDetails.messageSchema?.schemaType, + }; }; From d41dc0b776e218ddce4e7314db98118ac3a1489e Mon Sep 17 00:00:00 2001 From: Mohit Yadav <105265192+mohityadav766@users.noreply.github.com> Date: Tue, 10 Dec 2024 21:10:58 +0530 Subject: [PATCH 003/294] Fix ES Tag Index failing (#18991) * Fix ES Tag Index failing * add tags loading test --------- Co-authored-by: karanh37 (cherry picked from commit 08cdc69f30316261ed50c4f4ae0dc93a67b1203a) --- .../resources/elasticsearch/indexMapping.json | 4 +-- .../playwright/e2e/Pages/ExploreTree.spec.ts | 25 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/openmetadata-service/src/main/resources/elasticsearch/indexMapping.json b/openmetadata-service/src/main/resources/elasticsearch/indexMapping.json index 20a17107791e..a5b348e7452e 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/indexMapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/indexMapping.json @@ -150,8 +150,8 @@ "indexName": "tag_search_index", "indexMappingFile": "/elasticsearch/%s/tag_index_mapping.json", "alias": "tag", - "parentAliases": ["classification"], - "childAliases": ["all", "dataAsset"] + "parentAliases": ["classification", "all", "dataAsset"], + "childAliases": [] }, "classification": { "indexName": "classification_search_index", diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/ExploreTree.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/ExploreTree.spec.ts index 9db6cfb2d1c9..346131401181 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/ExploreTree.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/ExploreTree.spec.ts @@ -190,3 +190,28 @@ test.describe('Explore Tree scenarios ', () => { await afterAction(); }); }); + +test.describe('Explore page', () => { + test('Check the listing of tags', async ({ page }) => { + await page + .locator('div') + .filter({ hasText: /^Governance$/ }) + .locator('svg') + .first() + .click(); + + await expect(page.getByRole('tree')).toContainText('Glossaries'); + await expect(page.getByRole('tree')).toContainText('Tags'); + + const res = page.waitForResponse( + '/api/v1/search/query?q=&index=dataAsset*' + ); + // click on tags + await page.getByTestId('explore-tree-title-Tags').click(); + + const response = await res; + const jsonResponse = await response.json(); + + expect(jsonResponse.hits.hits.length).toBeGreaterThan(0); + }); +}); From f22442054a9c0692a69272e882aabda8760f4f09 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 10 Dec 2024 16:13:05 +0000 Subject: [PATCH 004/294] chore(release): Prepare Branch for `1.6.1` --- common/pom.xml | 2 +- .../docker-compose-ingestion/docker-compose-ingestion.yml | 2 +- .../docker-compose-openmetadata.yml | 4 ++-- docker/docker-compose-quickstart/Dockerfile | 4 ++-- .../docker-compose-quickstart/docker-compose-postgres.yml | 8 ++++---- docker/docker-compose-quickstart/docker-compose.yml | 8 ++++---- ingestion/Dockerfile | 2 +- ingestion/operators/docker/Dockerfile | 2 +- ingestion/pyproject.toml | 2 +- openmetadata-airflow-apis/pyproject.toml | 2 +- openmetadata-clients/openmetadata-java-client/pom.xml | 2 +- openmetadata-clients/pom.xml | 2 +- openmetadata-dist/pom.xml | 2 +- openmetadata-service/pom.xml | 2 +- openmetadata-shaded-deps/elasticsearch-dep/pom.xml | 2 +- openmetadata-shaded-deps/opensearch-dep/pom.xml | 2 +- openmetadata-shaded-deps/pom.xml | 2 +- openmetadata-spec/pom.xml | 2 +- openmetadata-ui/pom.xml | 2 +- pom.xml | 2 +- 20 files changed, 28 insertions(+), 28 deletions(-) diff --git a/common/pom.xml b/common/pom.xml index 8419d12de5b8..62c059357e0a 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -18,7 +18,7 @@ platform org.open-metadata - 1.6.0 + 1.6.1 4.0.0 diff --git a/docker/docker-compose-ingestion/docker-compose-ingestion.yml b/docker/docker-compose-ingestion/docker-compose-ingestion.yml index 85d09867d56c..a717b5c3ebfa 100644 --- a/docker/docker-compose-ingestion/docker-compose-ingestion.yml +++ b/docker/docker-compose-ingestion/docker-compose-ingestion.yml @@ -18,7 +18,7 @@ volumes: services: ingestion: container_name: openmetadata_ingestion - image: docker.getcollate.io/openmetadata/ingestion:1.6.0 + image: docker.getcollate.io/openmetadata/ingestion:1.6.1 environment: AIRFLOW__API__AUTH_BACKENDS: "airflow.api.auth.backend.basic_auth,airflow.api.auth.backend.session" AIRFLOW__CORE__EXECUTOR: LocalExecutor diff --git a/docker/docker-compose-openmetadata/docker-compose-openmetadata.yml b/docker/docker-compose-openmetadata/docker-compose-openmetadata.yml index 3daae332b5f8..82e48f8fa7de 100644 --- a/docker/docker-compose-openmetadata/docker-compose-openmetadata.yml +++ b/docker/docker-compose-openmetadata/docker-compose-openmetadata.yml @@ -14,7 +14,7 @@ services: execute-migrate-all: container_name: execute_migrate_all command: "./bootstrap/openmetadata-ops.sh migrate" - image: docker.getcollate.io/openmetadata/server:1.6.0 + image: docker.getcollate.io/openmetadata/server:1.6.1 environment: OPENMETADATA_CLUSTER_NAME: ${OPENMETADATA_CLUSTER_NAME:-openmetadata} SERVER_PORT: ${SERVER_PORT:-8585} @@ -227,7 +227,7 @@ services: openmetadata-server: container_name: openmetadata_server restart: always - image: docker.getcollate.io/openmetadata/server:1.6.0 + image: docker.getcollate.io/openmetadata/server:1.6.1 environment: OPENMETADATA_CLUSTER_NAME: ${OPENMETADATA_CLUSTER_NAME:-openmetadata} SERVER_PORT: ${SERVER_PORT:-8585} diff --git a/docker/docker-compose-quickstart/Dockerfile b/docker/docker-compose-quickstart/Dockerfile index 9f63b12bbf13..f27cf543bf47 100644 --- a/docker/docker-compose-quickstart/Dockerfile +++ b/docker/docker-compose-quickstart/Dockerfile @@ -11,7 +11,7 @@ # Build stage FROM alpine:3 AS build -ARG RI_VERSION="1.6.0" +ARG RI_VERSION="1.6.1" ENV RELEASE_URL="https://github.com/open-metadata/OpenMetadata/releases/download/${RI_VERSION}-release/openmetadata-${RI_VERSION}.tar.gz" RUN mkdir -p /opt/openmetadata && \ @@ -21,7 +21,7 @@ RUN mkdir -p /opt/openmetadata && \ # Final stage FROM alpine:3 -ARG RI_VERSION="1.6.0" +ARG RI_VERSION="1.6.1" ARG BUILD_DATE ARG COMMIT_ID LABEL maintainer="OpenMetadata" diff --git a/docker/docker-compose-quickstart/docker-compose-postgres.yml b/docker/docker-compose-quickstart/docker-compose-postgres.yml index 8faebd441ec9..aeeccef1e2d3 100644 --- a/docker/docker-compose-quickstart/docker-compose-postgres.yml +++ b/docker/docker-compose-quickstart/docker-compose-postgres.yml @@ -18,7 +18,7 @@ volumes: services: postgresql: container_name: openmetadata_postgresql - image: docker.getcollate.io/openmetadata/postgresql:1.6.0 + image: docker.getcollate.io/openmetadata/postgresql:1.6.1 restart: always command: "--work_mem=10MB" environment: @@ -61,7 +61,7 @@ services: execute-migrate-all: container_name: execute_migrate_all - image: docker.getcollate.io/openmetadata/server:1.6.0 + image: docker.getcollate.io/openmetadata/server:1.6.1 command: "./bootstrap/openmetadata-ops.sh migrate" environment: OPENMETADATA_CLUSTER_NAME: ${OPENMETADATA_CLUSTER_NAME:-openmetadata} @@ -275,7 +275,7 @@ services: openmetadata-server: container_name: openmetadata_server restart: always - image: docker.getcollate.io/openmetadata/server:1.6.0 + image: docker.getcollate.io/openmetadata/server:1.6.1 environment: OPENMETADATA_CLUSTER_NAME: ${OPENMETADATA_CLUSTER_NAME:-openmetadata} SERVER_PORT: ${SERVER_PORT:-8585} @@ -483,7 +483,7 @@ services: ingestion: container_name: openmetadata_ingestion - image: docker.getcollate.io/openmetadata/ingestion:1.6.0 + image: docker.getcollate.io/openmetadata/ingestion:1.6.1 depends_on: elasticsearch: condition: service_started diff --git a/docker/docker-compose-quickstart/docker-compose.yml b/docker/docker-compose-quickstart/docker-compose.yml index 42c346921e7b..92b7db1f9f25 100644 --- a/docker/docker-compose-quickstart/docker-compose.yml +++ b/docker/docker-compose-quickstart/docker-compose.yml @@ -18,7 +18,7 @@ volumes: services: mysql: container_name: openmetadata_mysql - image: docker.getcollate.io/openmetadata/db:1.6.0 + image: docker.getcollate.io/openmetadata/db:1.6.1 command: "--sort_buffer_size=10M" restart: always environment: @@ -59,7 +59,7 @@ services: execute-migrate-all: container_name: execute_migrate_all - image: docker.getcollate.io/openmetadata/server:1.6.0 + image: docker.getcollate.io/openmetadata/server:1.6.1 command: "./bootstrap/openmetadata-ops.sh migrate" environment: OPENMETADATA_CLUSTER_NAME: ${OPENMETADATA_CLUSTER_NAME:-openmetadata} @@ -273,7 +273,7 @@ services: openmetadata-server: container_name: openmetadata_server restart: always - image: docker.getcollate.io/openmetadata/server:1.6.0 + image: docker.getcollate.io/openmetadata/server:1.6.1 environment: OPENMETADATA_CLUSTER_NAME: ${OPENMETADATA_CLUSTER_NAME:-openmetadata} SERVER_PORT: ${SERVER_PORT:-8585} @@ -482,7 +482,7 @@ services: ingestion: container_name: openmetadata_ingestion - image: docker.getcollate.io/openmetadata/ingestion:1.6.0 + image: docker.getcollate.io/openmetadata/ingestion:1.6.1 depends_on: elasticsearch: condition: service_started diff --git a/ingestion/Dockerfile b/ingestion/Dockerfile index 7a726138fefc..e212415c0f1c 100644 --- a/ingestion/Dockerfile +++ b/ingestion/Dockerfile @@ -76,7 +76,7 @@ ARG INGESTION_DEPENDENCY="all" ENV PIP_NO_CACHE_DIR=1 # Make pip silent ENV PIP_QUIET=1 -ARG RI_VERSION="1.6.0.0" +ARG RI_VERSION="1.6.1.0" RUN pip install --upgrade pip RUN pip install "openmetadata-managed-apis~=${RI_VERSION}" --constraint "https://raw.githubusercontent.com/apache/airflow/constraints-2.9.1/constraints-3.10.txt" RUN pip install "openmetadata-ingestion[${INGESTION_DEPENDENCY}]~=${RI_VERSION}" diff --git a/ingestion/operators/docker/Dockerfile b/ingestion/operators/docker/Dockerfile index 01826eb2c658..fec7b09ea066 100644 --- a/ingestion/operators/docker/Dockerfile +++ b/ingestion/operators/docker/Dockerfile @@ -81,7 +81,7 @@ ENV PIP_QUIET=1 RUN pip install --upgrade pip ARG INGESTION_DEPENDENCY="all" -ARG RI_VERSION="1.6.0.0" +ARG RI_VERSION="1.6.1.0" RUN pip install --upgrade pip RUN pip install "openmetadata-ingestion[airflow]~=${RI_VERSION}" RUN pip install "openmetadata-ingestion[${INGESTION_DEPENDENCY}]~=${RI_VERSION}" diff --git a/ingestion/pyproject.toml b/ingestion/pyproject.toml index 1bd5f937d27c..f63a5269c5b6 100644 --- a/ingestion/pyproject.toml +++ b/ingestion/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" # since it helps us organize and isolate version management [project] name = "openmetadata-ingestion" -version = "1.6.0.0" +version = "1.6.1.0" dynamic = ["readme", "dependencies", "optional-dependencies"] authors = [ { name = "OpenMetadata Committers" } diff --git a/openmetadata-airflow-apis/pyproject.toml b/openmetadata-airflow-apis/pyproject.toml index 6006f4c3bfa0..bcabf86bcf9a 100644 --- a/openmetadata-airflow-apis/pyproject.toml +++ b/openmetadata-airflow-apis/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" # since it helps us organize and isolate version management [project] name = "openmetadata_managed_apis" -version = "1.6.0.0" +version = "1.6.1.0" readme = "README.md" authors = [ {name = "OpenMetadata Committers"} diff --git a/openmetadata-clients/openmetadata-java-client/pom.xml b/openmetadata-clients/openmetadata-java-client/pom.xml index c114c7d49c2b..2bc9816a523f 100644 --- a/openmetadata-clients/openmetadata-java-client/pom.xml +++ b/openmetadata-clients/openmetadata-java-client/pom.xml @@ -5,7 +5,7 @@ openmetadata-clients org.open-metadata - 1.6.0 + 1.6.1 4.0.0 diff --git a/openmetadata-clients/pom.xml b/openmetadata-clients/pom.xml index 28cedd433658..a272b3993431 100644 --- a/openmetadata-clients/pom.xml +++ b/openmetadata-clients/pom.xml @@ -5,7 +5,7 @@ platform org.open-metadata - 1.6.0 + 1.6.1 4.0.0 diff --git a/openmetadata-dist/pom.xml b/openmetadata-dist/pom.xml index ac849ed3857d..adc5346cbd05 100644 --- a/openmetadata-dist/pom.xml +++ b/openmetadata-dist/pom.xml @@ -20,7 +20,7 @@ platform org.open-metadata - 1.6.0 + 1.6.1 openmetadata-dist diff --git a/openmetadata-service/pom.xml b/openmetadata-service/pom.xml index a29fca1a969c..096dbd6e2ea8 100644 --- a/openmetadata-service/pom.xml +++ b/openmetadata-service/pom.xml @@ -5,7 +5,7 @@ platform org.open-metadata - 1.6.0 + 1.6.1 4.0.0 openmetadata-service diff --git a/openmetadata-shaded-deps/elasticsearch-dep/pom.xml b/openmetadata-shaded-deps/elasticsearch-dep/pom.xml index d0a994a0e1ed..c0c94fdc8d83 100644 --- a/openmetadata-shaded-deps/elasticsearch-dep/pom.xml +++ b/openmetadata-shaded-deps/elasticsearch-dep/pom.xml @@ -5,7 +5,7 @@ openmetadata-shaded-deps org.open-metadata - 1.6.0 + 1.6.1 4.0.0 elasticsearch-deps diff --git a/openmetadata-shaded-deps/opensearch-dep/pom.xml b/openmetadata-shaded-deps/opensearch-dep/pom.xml index 784cf285bc8d..dc8de6aca1a9 100644 --- a/openmetadata-shaded-deps/opensearch-dep/pom.xml +++ b/openmetadata-shaded-deps/opensearch-dep/pom.xml @@ -5,7 +5,7 @@ openmetadata-shaded-deps org.open-metadata - 1.6.0 + 1.6.1 4.0.0 opensearch-deps diff --git a/openmetadata-shaded-deps/pom.xml b/openmetadata-shaded-deps/pom.xml index bd309b280cc5..b22dc2d1f99f 100644 --- a/openmetadata-shaded-deps/pom.xml +++ b/openmetadata-shaded-deps/pom.xml @@ -5,7 +5,7 @@ platform org.open-metadata - 1.6.0 + 1.6.1 4.0.0 openmetadata-shaded-deps diff --git a/openmetadata-spec/pom.xml b/openmetadata-spec/pom.xml index 7c2f99c83352..fea0cc31461d 100644 --- a/openmetadata-spec/pom.xml +++ b/openmetadata-spec/pom.xml @@ -5,7 +5,7 @@ platform org.open-metadata - 1.6.0 + 1.6.1 4.0.0 diff --git a/openmetadata-ui/pom.xml b/openmetadata-ui/pom.xml index ff9cd83ff40c..67e8125b1e21 100644 --- a/openmetadata-ui/pom.xml +++ b/openmetadata-ui/pom.xml @@ -5,7 +5,7 @@ platform org.open-metadata - 1.6.0 + 1.6.1 4.0.0 diff --git a/pom.xml b/pom.xml index 0c7fe79092b0..bda8ae30018b 100644 --- a/pom.xml +++ b/pom.xml @@ -26,7 +26,7 @@ based on Open Metadata Standards/APIs, supporting connectors to a wide range of data services, OpenMetadata enables end-to-end metadata management, giving you the freedom to unlock the value of your data assets. - 1.6.0 + 1.6.1 https://github.com/open-metadata/OpenMetadata openmetadata-spec From 30a5f1aab2990027893dce5ab77b8a0eccb09cdf Mon Sep 17 00:00:00 2001 From: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> Date: Tue, 10 Dec 2024 22:53:48 +0530 Subject: [PATCH 005/294] chore(ui): update what's new for 1.6.1 (#18994) (cherry picked from commit caf6b2f43258f69a74ef186023fb7e063e67bc92) --- .../Modals/WhatsNewModal/whatsNewData.ts | 148 +++++++++++++++++- 1 file changed, 146 insertions(+), 2 deletions(-) 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 18562023fcbf..7eb63e10b6c6 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_12'; // To be changed with each release. +export const COOKIE_VERSION = 'VERSION_1_6_1'; // 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 @@ -1170,7 +1170,151 @@ To continue pursuing this objective, the application was completely refactored t { id: 55, version: 'v1.6.0', - description: 'Released on 5th December 2024.', + description: 'Released on 10th December 2024.', + features: [ + { + title: `Visualizing Your Data Landscape with Entity Relationship (ER) Diagrams (Collate)`, + description: `Understanding complex database schemas can be challenging without clear visualization. While OpenMetadata's best-in-class Lineage UI helps track data flow, there are better options for viewing structural relationships between tables. Collate 1.6 introduces ER diagrams as a new feature to let you: + +1. Visualize table connections through primary and foreign key constraints + +2. Navigate between data assets to discover relationships + +3. Modify connections using the built-in UI editor + +ER diagrams help you better understand and manage your data architecture by showing how your database tables relate to each other.`, + isImage: false, + path: 'https://www.youtube.com/embed/3m2xHpIsYuM', + }, + { + title: + 'Establishing Smooth Data Governance with Automated Glossary Approval Workflows (Collate)', + description: `Organizations often struggle with data governance due to rigid, pre-defined manual workflows. OpenMetadata 1.6 introduces a new, automated data governance framework designed to be customized to each organization's needs. + +In Collate 1.6, the Glossary Approval Workflow has been migrated to this new framework. Now, you can create custom approval processes with specific conditions and rules and easily visualize them through intuitive workflow diagrams. You can also create smart approval processes for glossary terms with real-time state changes and task creation to save time and streamline work. `, + isImage: false, + path: 'https://www.youtube.com/embed/yKJNWUb_ucA', + }, + { + title: + 'Data Certification Workflows for Automated Bronze, Silver, & Gold Data Standardization (Collate)', + description: `Collate 1.6 also leverages the new data governance framework for a new Data Certification Workflow, allowing you to define your organization's rules to certify your data as Bronze, Silver, or Gold. Certified assets are a great way to help users discover the right data and inform them which data has been properly curated. + +Our vision is to expand our governance framework to allow our users to create their own Custom Governance workflows. We want to enable data teams to implement and automate data governance processes that perfectly fit your organization, promoting data quality and compliance.`, + isImage: false, + path: 'https://www.youtube.com/embed/hqxtn6uAvt4', + }, + { + title: + 'Maintaining a Healthy Data Platform with Observability Dashboards (Collate)', + description: `Monitoring data quality and incident management across platforms can be challenging. OpenMetadata has been a pillar for data quality implementations, with its ability to create tests from the UI, native observability alerts, and Incident Manager. It offers data quality insights on a per-table level. . + +In Collate 1.6, we’re introducing platform-wide observability dashboards that allow you to track overall data quality coverage trends and analyze incident response performance across your entire data estate. Quickly identify root causes through enhanced asset and lineage views and enable proactive data quality management across your entire data ecosystem.`, + isImage: false, + path: 'https://www.youtube.com/embed/DQ-abGXOsHE', + }, + { + title: 'Elevating Metric Management with Dedicated Metric Entities', + description: `Metrics are essential for data-driven organizations, but OpenMetadata previously lacked dedicated metric management, forcing users to use glossary terms as a workaround. The new "Metric" entity in OpenMetadata 1.6 provides a purpose-built solution to:. + +1. Document detailed metric calculations and descriptions + +2. Record calculation formulas and implementation code (Python, Java, SQL, LaTeX) + +3. Visualize metric lineage from source data to insights + +This new addition helps teams better manage, understand, and calculate their business KPIs, for improved data literacy and consistency across data teams. `, + isImage: false, + path: 'https://www.youtube.com/embed/Nf97_oWNAmM', + }, + { + title: 'Reinforcing Data Security with Search RBAC', + description: `OpenMetadata's Roles and Policies enable granular permission control, ensuring appropriate access to metadata across different domains and teams. Some data teams may wish to enable data discovery to search for other tables while still enforcing controls with access requests. Other data teams in more restrictive environments may also wish to control the search experience. + +OpenMetadata 1.6 extends Role-Based Access Control (RBAC) to search functionality, allowing administrators to tailor user search experience. This provides personalized search results, with users only seeing assets they have permission to access, as well as stronger data governance by ensuring users only interact with data within their defined roles and responsibilities.`, + isImage: false, + path: 'https://www.youtube.com/embed/03ke9uv0PG0', + }, + { + title: 'Streamlining Data Management with Additional Enhancements', + description: `Release 1.6 comes with several other notable improvements: + +- **Asynchronous Export APIs** : Enjoy increased efficiency when exporting and importing large datasets with new asynchronous APIs. + +- **Faster Search Re-indexing**: Experience significantly improved performance in search re-indexing, making data discovery even smoother. + +- **Improved Data Insights Custom Dashboards UI (Collate)**: To make it even easier to write your own insights dashboards in Collate. + +- **Slack Integration (Collate)**: Collate is releasing a new Application that lets your users find and share assets directly within your Slack workspace! + +- **Alert Debuggability**: Allowing users to test the destinations and see whenever the alert was triggered.`, + isImage: false, + path: 'https://www.youtube.com/embed/7pUF9ZK2iK4', + }, + { + title: 'Expanded Connector Ecosystem and Diversity', + description: `OpenMetadata’s ingestion framework contains 80+ native connectors. These connectors are the foundation of the platform and bring in all the metadata your team needs: technical metadata, lineage, usage, profiling, etc. + +We bring new connectors in each release, continuously expanding our coverage. This time, release 1.6 comes with seven new connectors: + +1. **OpenAPI**: Extract rich metadata from OpenAPI specifications, including endpoints and schemas. + +2. **Sigma**: Bringing in your BI dashboard information. + +3. **Exasol**: Gain insights into your Exasol database, now supported thanks to Nicola Coretti’s OSS contribution! + +And in Collate, we are bringing four ETL, dashboarding and ML tools: **Matillion, Azure Data Factory, Stitch, PowerBI Server** and **Vertex AI!**`, + isImage: false, + path: '', + }, + ], + changeLogs: { + ['Backward Incompatible Changes']: ` + +**Ingestion Workflow Status:** + +We are updating how we compute the success percentage. Previously, we took into account for partial success the results of the Source (e.g., the tables we were able to properly retrieve from Snowflake, Redshift, etc.). This means that we had an error threshold in there were if up to 90% of the tables were successfully ingested, we would still consider the workflow as successful. However, any errors when sending the information to OpenMetadata would be considered as a failure. +Now, we're changing this behavior to consider the success rate of all the steps involved in the workflow. The UI will then show more Partial Success statuses rather than Failed, properly reflecting the real state of the workflow. + +**Profiler & Auto Classification Workflow:** + +We are creating a new Auto Classification workflow that will take care of managing the sample data and PII classification, which was previously done by the Profiler workflow. This change will allow us to have a more modular and scalable system. +The Profiler workflow will now only focus on the profiling part of the data, while the Auto Classification will take care of the rest. + +This means that we are removing these properties from the DatabaseServiceProfilerPipeline schema: + +- generateSampleData +- processPiiSensitive +- confidence which will be moved to the new +- Adding Glossary Term view is improved. Now we show glossary terms hierarchically enabling a better understanding of how the terms are setup while adding it to a table or dashboard. +- DatabaseServiceAutoClassificationPipeline schema. + +What you will need to do: + +- If you are using the EXTERNAL ingestion for the profiler (YAML configuration), you will need to update your configuration, 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. +`, + + ['RBAC Policy Updates for EditTags']: `We have given more granularity to the EditTags policy. Previously, it was a single policy that allowed the user to manage any kind of tagging to the assets, including adding tags, glossary terms, and Tiers. + +Now, we have split this policy to give further control on which kind of tagging the user can manage. The EditTags policy has been +split into: + +- **EditTags**: to add tags. +- **EditGlossaryTerms**: to add Glossary Terms. +- **EditTier**: to add Tier tags.`, + + [`Metadata Actions for ML Tagging - Deprecation Notice ${CollateIconWithLinkMD}`]: ` +Since we are introducing the Auto Classification workflow, we are going to remove in 1.7 the ML Tagging action from the Metadata Actions. That feature will be covered already by the Auto Classification workflow, which even brings more flexibility allow the on-the-fly usage of the sample data for classification purposes without having to store it in the database.`, + + [`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). Introducing the "connector specifcication class (ServiceSpec)". 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 for an implementation example.`, + }, + }, + { + id: 56, + version: 'v1.6.1', + description: 'Released on 10th December 2024.', + note: "In 1.6.1, Fixes tags listing for explore page on top of 1.6.0 release. Don't miss out the release highlights!", features: [ { title: `Visualizing Your Data Landscape with Entity Relationship (ER) Diagrams (Collate)`, From e6d975add23648e8c0a9547ef4329f2f3481c30e Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 12 Dec 2024 05:51:36 +0000 Subject: [PATCH 006/294] chore(release): Prepare Branch for `1.6.2` --- common/pom.xml | 2 +- .../docker-compose-ingestion/docker-compose-ingestion.yml | 2 +- .../docker-compose-openmetadata.yml | 4 ++-- docker/docker-compose-quickstart/Dockerfile | 4 ++-- .../docker-compose-quickstart/docker-compose-postgres.yml | 8 ++++---- docker/docker-compose-quickstart/docker-compose.yml | 8 ++++---- ingestion/Dockerfile | 2 +- ingestion/operators/docker/Dockerfile | 2 +- ingestion/pyproject.toml | 2 +- openmetadata-airflow-apis/pyproject.toml | 2 +- openmetadata-clients/openmetadata-java-client/pom.xml | 2 +- openmetadata-clients/pom.xml | 2 +- openmetadata-dist/pom.xml | 2 +- openmetadata-service/pom.xml | 2 +- openmetadata-shaded-deps/elasticsearch-dep/pom.xml | 2 +- openmetadata-shaded-deps/opensearch-dep/pom.xml | 2 +- openmetadata-shaded-deps/pom.xml | 2 +- openmetadata-spec/pom.xml | 2 +- openmetadata-ui/pom.xml | 2 +- pom.xml | 2 +- 20 files changed, 28 insertions(+), 28 deletions(-) diff --git a/common/pom.xml b/common/pom.xml index 62c059357e0a..8571f63cc54c 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -18,7 +18,7 @@ platform org.open-metadata - 1.6.1 + 1.6.2 4.0.0 diff --git a/docker/docker-compose-ingestion/docker-compose-ingestion.yml b/docker/docker-compose-ingestion/docker-compose-ingestion.yml index a717b5c3ebfa..68abdf3f430f 100644 --- a/docker/docker-compose-ingestion/docker-compose-ingestion.yml +++ b/docker/docker-compose-ingestion/docker-compose-ingestion.yml @@ -18,7 +18,7 @@ volumes: services: ingestion: container_name: openmetadata_ingestion - image: docker.getcollate.io/openmetadata/ingestion:1.6.1 + image: docker.getcollate.io/openmetadata/ingestion:1.6.2 environment: AIRFLOW__API__AUTH_BACKENDS: "airflow.api.auth.backend.basic_auth,airflow.api.auth.backend.session" AIRFLOW__CORE__EXECUTOR: LocalExecutor diff --git a/docker/docker-compose-openmetadata/docker-compose-openmetadata.yml b/docker/docker-compose-openmetadata/docker-compose-openmetadata.yml index 82e48f8fa7de..5546ef294c27 100644 --- a/docker/docker-compose-openmetadata/docker-compose-openmetadata.yml +++ b/docker/docker-compose-openmetadata/docker-compose-openmetadata.yml @@ -14,7 +14,7 @@ services: execute-migrate-all: container_name: execute_migrate_all command: "./bootstrap/openmetadata-ops.sh migrate" - image: docker.getcollate.io/openmetadata/server:1.6.1 + image: docker.getcollate.io/openmetadata/server:1.6.2 environment: OPENMETADATA_CLUSTER_NAME: ${OPENMETADATA_CLUSTER_NAME:-openmetadata} SERVER_PORT: ${SERVER_PORT:-8585} @@ -227,7 +227,7 @@ services: openmetadata-server: container_name: openmetadata_server restart: always - image: docker.getcollate.io/openmetadata/server:1.6.1 + image: docker.getcollate.io/openmetadata/server:1.6.2 environment: OPENMETADATA_CLUSTER_NAME: ${OPENMETADATA_CLUSTER_NAME:-openmetadata} SERVER_PORT: ${SERVER_PORT:-8585} diff --git a/docker/docker-compose-quickstart/Dockerfile b/docker/docker-compose-quickstart/Dockerfile index f27cf543bf47..c55fada178f4 100644 --- a/docker/docker-compose-quickstart/Dockerfile +++ b/docker/docker-compose-quickstart/Dockerfile @@ -11,7 +11,7 @@ # Build stage FROM alpine:3 AS build -ARG RI_VERSION="1.6.1" +ARG RI_VERSION="1.6.2" ENV RELEASE_URL="https://github.com/open-metadata/OpenMetadata/releases/download/${RI_VERSION}-release/openmetadata-${RI_VERSION}.tar.gz" RUN mkdir -p /opt/openmetadata && \ @@ -21,7 +21,7 @@ RUN mkdir -p /opt/openmetadata && \ # Final stage FROM alpine:3 -ARG RI_VERSION="1.6.1" +ARG RI_VERSION="1.6.2" ARG BUILD_DATE ARG COMMIT_ID LABEL maintainer="OpenMetadata" diff --git a/docker/docker-compose-quickstart/docker-compose-postgres.yml b/docker/docker-compose-quickstart/docker-compose-postgres.yml index aeeccef1e2d3..df898438ef23 100644 --- a/docker/docker-compose-quickstart/docker-compose-postgres.yml +++ b/docker/docker-compose-quickstart/docker-compose-postgres.yml @@ -18,7 +18,7 @@ volumes: services: postgresql: container_name: openmetadata_postgresql - image: docker.getcollate.io/openmetadata/postgresql:1.6.1 + image: docker.getcollate.io/openmetadata/postgresql:1.6.2 restart: always command: "--work_mem=10MB" environment: @@ -61,7 +61,7 @@ services: execute-migrate-all: container_name: execute_migrate_all - image: docker.getcollate.io/openmetadata/server:1.6.1 + image: docker.getcollate.io/openmetadata/server:1.6.2 command: "./bootstrap/openmetadata-ops.sh migrate" environment: OPENMETADATA_CLUSTER_NAME: ${OPENMETADATA_CLUSTER_NAME:-openmetadata} @@ -275,7 +275,7 @@ services: openmetadata-server: container_name: openmetadata_server restart: always - image: docker.getcollate.io/openmetadata/server:1.6.1 + image: docker.getcollate.io/openmetadata/server:1.6.2 environment: OPENMETADATA_CLUSTER_NAME: ${OPENMETADATA_CLUSTER_NAME:-openmetadata} SERVER_PORT: ${SERVER_PORT:-8585} @@ -483,7 +483,7 @@ services: ingestion: container_name: openmetadata_ingestion - image: docker.getcollate.io/openmetadata/ingestion:1.6.1 + image: docker.getcollate.io/openmetadata/ingestion:1.6.2 depends_on: elasticsearch: condition: service_started diff --git a/docker/docker-compose-quickstart/docker-compose.yml b/docker/docker-compose-quickstart/docker-compose.yml index 92b7db1f9f25..9ea74153d3f2 100644 --- a/docker/docker-compose-quickstart/docker-compose.yml +++ b/docker/docker-compose-quickstart/docker-compose.yml @@ -18,7 +18,7 @@ volumes: services: mysql: container_name: openmetadata_mysql - image: docker.getcollate.io/openmetadata/db:1.6.1 + image: docker.getcollate.io/openmetadata/db:1.6.2 command: "--sort_buffer_size=10M" restart: always environment: @@ -59,7 +59,7 @@ services: execute-migrate-all: container_name: execute_migrate_all - image: docker.getcollate.io/openmetadata/server:1.6.1 + image: docker.getcollate.io/openmetadata/server:1.6.2 command: "./bootstrap/openmetadata-ops.sh migrate" environment: OPENMETADATA_CLUSTER_NAME: ${OPENMETADATA_CLUSTER_NAME:-openmetadata} @@ -273,7 +273,7 @@ services: openmetadata-server: container_name: openmetadata_server restart: always - image: docker.getcollate.io/openmetadata/server:1.6.1 + image: docker.getcollate.io/openmetadata/server:1.6.2 environment: OPENMETADATA_CLUSTER_NAME: ${OPENMETADATA_CLUSTER_NAME:-openmetadata} SERVER_PORT: ${SERVER_PORT:-8585} @@ -482,7 +482,7 @@ services: ingestion: container_name: openmetadata_ingestion - image: docker.getcollate.io/openmetadata/ingestion:1.6.1 + image: docker.getcollate.io/openmetadata/ingestion:1.6.2 depends_on: elasticsearch: condition: service_started diff --git a/ingestion/Dockerfile b/ingestion/Dockerfile index e212415c0f1c..f220e0cba106 100644 --- a/ingestion/Dockerfile +++ b/ingestion/Dockerfile @@ -76,7 +76,7 @@ ARG INGESTION_DEPENDENCY="all" ENV PIP_NO_CACHE_DIR=1 # Make pip silent ENV PIP_QUIET=1 -ARG RI_VERSION="1.6.1.0" +ARG RI_VERSION="1.6.2.0" RUN pip install --upgrade pip RUN pip install "openmetadata-managed-apis~=${RI_VERSION}" --constraint "https://raw.githubusercontent.com/apache/airflow/constraints-2.9.1/constraints-3.10.txt" RUN pip install "openmetadata-ingestion[${INGESTION_DEPENDENCY}]~=${RI_VERSION}" diff --git a/ingestion/operators/docker/Dockerfile b/ingestion/operators/docker/Dockerfile index fec7b09ea066..b197365c2a52 100644 --- a/ingestion/operators/docker/Dockerfile +++ b/ingestion/operators/docker/Dockerfile @@ -81,7 +81,7 @@ ENV PIP_QUIET=1 RUN pip install --upgrade pip ARG INGESTION_DEPENDENCY="all" -ARG RI_VERSION="1.6.1.0" +ARG RI_VERSION="1.6.2.0" RUN pip install --upgrade pip RUN pip install "openmetadata-ingestion[airflow]~=${RI_VERSION}" RUN pip install "openmetadata-ingestion[${INGESTION_DEPENDENCY}]~=${RI_VERSION}" diff --git a/ingestion/pyproject.toml b/ingestion/pyproject.toml index f63a5269c5b6..5f9623b78ff6 100644 --- a/ingestion/pyproject.toml +++ b/ingestion/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" # since it helps us organize and isolate version management [project] name = "openmetadata-ingestion" -version = "1.6.1.0" +version = "1.6.2.0" dynamic = ["readme", "dependencies", "optional-dependencies"] authors = [ { name = "OpenMetadata Committers" } diff --git a/openmetadata-airflow-apis/pyproject.toml b/openmetadata-airflow-apis/pyproject.toml index bcabf86bcf9a..06001a474709 100644 --- a/openmetadata-airflow-apis/pyproject.toml +++ b/openmetadata-airflow-apis/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" # since it helps us organize and isolate version management [project] name = "openmetadata_managed_apis" -version = "1.6.1.0" +version = "1.6.2.0" readme = "README.md" authors = [ {name = "OpenMetadata Committers"} diff --git a/openmetadata-clients/openmetadata-java-client/pom.xml b/openmetadata-clients/openmetadata-java-client/pom.xml index 2bc9816a523f..a52b7370c011 100644 --- a/openmetadata-clients/openmetadata-java-client/pom.xml +++ b/openmetadata-clients/openmetadata-java-client/pom.xml @@ -5,7 +5,7 @@ openmetadata-clients org.open-metadata - 1.6.1 + 1.6.2 4.0.0 diff --git a/openmetadata-clients/pom.xml b/openmetadata-clients/pom.xml index a272b3993431..0b0ff6be9ac7 100644 --- a/openmetadata-clients/pom.xml +++ b/openmetadata-clients/pom.xml @@ -5,7 +5,7 @@ platform org.open-metadata - 1.6.1 + 1.6.2 4.0.0 diff --git a/openmetadata-dist/pom.xml b/openmetadata-dist/pom.xml index adc5346cbd05..6df8762cafde 100644 --- a/openmetadata-dist/pom.xml +++ b/openmetadata-dist/pom.xml @@ -20,7 +20,7 @@ platform org.open-metadata - 1.6.1 + 1.6.2 openmetadata-dist diff --git a/openmetadata-service/pom.xml b/openmetadata-service/pom.xml index 096dbd6e2ea8..5299851b9021 100644 --- a/openmetadata-service/pom.xml +++ b/openmetadata-service/pom.xml @@ -5,7 +5,7 @@ platform org.open-metadata - 1.6.1 + 1.6.2 4.0.0 openmetadata-service diff --git a/openmetadata-shaded-deps/elasticsearch-dep/pom.xml b/openmetadata-shaded-deps/elasticsearch-dep/pom.xml index c0c94fdc8d83..23ea37785246 100644 --- a/openmetadata-shaded-deps/elasticsearch-dep/pom.xml +++ b/openmetadata-shaded-deps/elasticsearch-dep/pom.xml @@ -5,7 +5,7 @@ openmetadata-shaded-deps org.open-metadata - 1.6.1 + 1.6.2 4.0.0 elasticsearch-deps diff --git a/openmetadata-shaded-deps/opensearch-dep/pom.xml b/openmetadata-shaded-deps/opensearch-dep/pom.xml index dc8de6aca1a9..78247a1d7bbc 100644 --- a/openmetadata-shaded-deps/opensearch-dep/pom.xml +++ b/openmetadata-shaded-deps/opensearch-dep/pom.xml @@ -5,7 +5,7 @@ openmetadata-shaded-deps org.open-metadata - 1.6.1 + 1.6.2 4.0.0 opensearch-deps diff --git a/openmetadata-shaded-deps/pom.xml b/openmetadata-shaded-deps/pom.xml index b22dc2d1f99f..1e86bb9844c9 100644 --- a/openmetadata-shaded-deps/pom.xml +++ b/openmetadata-shaded-deps/pom.xml @@ -5,7 +5,7 @@ platform org.open-metadata - 1.6.1 + 1.6.2 4.0.0 openmetadata-shaded-deps diff --git a/openmetadata-spec/pom.xml b/openmetadata-spec/pom.xml index fea0cc31461d..a4c9249d1bf6 100644 --- a/openmetadata-spec/pom.xml +++ b/openmetadata-spec/pom.xml @@ -5,7 +5,7 @@ platform org.open-metadata - 1.6.1 + 1.6.2 4.0.0 diff --git a/openmetadata-ui/pom.xml b/openmetadata-ui/pom.xml index 67e8125b1e21..5623c41b7810 100644 --- a/openmetadata-ui/pom.xml +++ b/openmetadata-ui/pom.xml @@ -5,7 +5,7 @@ platform org.open-metadata - 1.6.1 + 1.6.2 4.0.0 diff --git a/pom.xml b/pom.xml index bda8ae30018b..cf005a62d8be 100644 --- a/pom.xml +++ b/pom.xml @@ -26,7 +26,7 @@ based on Open Metadata Standards/APIs, supporting connectors to a wide range of data services, OpenMetadata enables end-to-end metadata management, giving you the freedom to unlock the value of your data assets. - 1.6.1 + 1.6.2 https://github.com/open-metadata/OpenMetadata openmetadata-spec From eca21b384522b338f9ce12c770af54b0a39a6cb7 Mon Sep 17 00:00:00 2001 From: IceS2 Date: Thu, 12 Dec 2024 10:01:39 +0100 Subject: [PATCH 007/294] Parametrizing Flowable configurations and adding a way to handle history cleaning (#18990) --- .../governance/workflows/WorkflowHandler.java | 72 +++++++++++++++---- .../service/jdbi3/CollectionDAO.java | 2 + .../service/jdbi3/SystemRepository.java | 19 +++++ .../resources/settings/SettingsCache.java | 17 +++++ .../configuration/workflowSettings.json | 59 +++++++++++++++ .../json/schema/settings/settings.json | 6 +- 6 files changed, 162 insertions(+), 13 deletions(-) create mode 100644 openmetadata-spec/src/main/resources/json/schema/configuration/workflowSettings.json 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 ae3ecab0ac34..899c4b7f9c10 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 @@ -2,6 +2,7 @@ import static org.openmetadata.service.governance.workflows.elements.TriggerFactory.getTriggerWorkflowId; +import java.time.Duration; import java.util.Arrays; import java.util.List; import java.util.Map; @@ -14,6 +15,7 @@ import org.flowable.engine.HistoryService; import org.flowable.engine.ProcessEngine; import org.flowable.engine.ProcessEngineConfiguration; +import org.flowable.engine.ProcessEngines; import org.flowable.engine.RepositoryService; import org.flowable.engine.RuntimeService; import org.flowable.engine.TaskService; @@ -23,44 +25,82 @@ import org.flowable.engine.runtime.Execution; import org.flowable.engine.runtime.ProcessInstance; import org.flowable.task.api.Task; +import org.openmetadata.schema.configuration.WorkflowSettings; +import org.openmetadata.service.Entity; import org.openmetadata.service.OpenMetadataApplicationConfig; import org.openmetadata.service.exception.UnhandledServerException; +import org.openmetadata.service.jdbi3.SystemRepository; import org.openmetadata.service.jdbi3.locator.ConnectionType; @Slf4j public class WorkflowHandler { - private final RepositoryService repositoryService; - private final RuntimeService runtimeService; - private final TaskService taskService; - private final HistoryService historyService; + private ProcessEngine processEngine; + private RepositoryService repositoryService; + private RuntimeService runtimeService; + private TaskService taskService; + private HistoryService historyService; private static WorkflowHandler instance; private static volatile boolean initialized = false; private WorkflowHandler(OpenMetadataApplicationConfig config) { ProcessEngineConfiguration processEngineConfiguration = new StandaloneProcessEngineConfiguration() - .setAsyncExecutorActivate(true) - .setAsyncExecutorCorePoolSize(50) - .setAsyncExecutorMaxPoolSize(100) - .setAsyncExecutorThreadPoolQueueSize(1000) - .setAsyncExecutorMaxAsyncJobsDuePerAcquisition(20) .setJdbcUrl(config.getDataSourceFactory().getUrl()) .setJdbcUsername(config.getDataSourceFactory().getUser()) .setJdbcPassword(config.getDataSourceFactory().getPassword()) .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 { processEngineConfiguration.setDatabaseType(ProcessEngineConfiguration.DATABASE_TYPE_POSTGRES); } + initializeNewProcessEngine(processEngineConfiguration); + } + + public void initializeNewProcessEngine( + ProcessEngineConfiguration currentProcessEngineConfiguration) { + ProcessEngines.destroy(); + SystemRepository systemRepository = Entity.getSystemRepository(); + WorkflowSettings workflowSettings = systemRepository.getWorkflowSettings(); + + StandaloneProcessEngineConfiguration processEngineConfiguration = + new StandaloneProcessEngineConfiguration(); + + // Setting Database Configuration + processEngineConfiguration + .setJdbcUrl(currentProcessEngineConfiguration.getJdbcUrl()) + .setJdbcUsername(currentProcessEngineConfiguration.getJdbcUsername()) + .setJdbcPassword(currentProcessEngineConfiguration.getJdbcPassword()) + .setJdbcDriver(currentProcessEngineConfiguration.getJdbcDriver()) + .setDatabaseType(currentProcessEngineConfiguration.getDatabaseType()) + .setDatabaseSchemaUpdate(ProcessEngineConfiguration.DB_SCHEMA_UPDATE_FALSE); + + // Setting Async Executor Configuration + processEngineConfiguration + .setAsyncExecutorActivate(true) + .setAsyncExecutorCorePoolSize(workflowSettings.getExecutorConfiguration().getCorePoolSize()) + .setAsyncExecutorMaxPoolSize(workflowSettings.getExecutorConfiguration().getMaxPoolSize()) + .setAsyncExecutorThreadPoolQueueSize( + workflowSettings.getExecutorConfiguration().getQueueSize()) + .setAsyncExecutorMaxAsyncJobsDuePerAcquisition( + workflowSettings.getExecutorConfiguration().getTasksDuePerAcquisition()); + + // Setting History CleanUp + processEngineConfiguration + .setEnableHistoryCleaning(true) + .setCleanInstancesEndedAfter( + Duration.ofDays( + workflowSettings.getHistoryCleanUpConfiguration().getCleanAfterNumberOfDays())); + + // Add Global Failure Listener + processEngineConfiguration.setEventListeners(List.of(new WorkflowFailureListener())); + ProcessEngine processEngine = processEngineConfiguration.buildProcessEngine(); + this.processEngine = processEngine; this.repositoryService = processEngine.getRepositoryService(); this.runtimeService = processEngine.getRuntimeService(); this.taskService = processEngine.getTaskService(); @@ -81,6 +121,14 @@ public static WorkflowHandler getInstance() { throw new UnhandledServerException("WorkflowHandler is not initialized."); } + public ProcessEngineConfiguration getProcessEngineConfiguration() { + if (processEngine != null) { + return processEngine.getProcessEngineConfiguration(); + } else { + return null; + } + } + public void deploy(Workflow workflow) { BpmnXMLConverter bpmnXMLConverter = new BpmnXMLConverter(); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java index a5938ea9f4a2..809a53e5d6b4 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java @@ -67,6 +67,7 @@ import org.openmetadata.schema.auth.RefreshToken; import org.openmetadata.schema.auth.TokenType; import org.openmetadata.schema.configuration.AssetCertificationSettings; +import org.openmetadata.schema.configuration.WorkflowSettings; import org.openmetadata.schema.dataInsight.DataInsightChart; import org.openmetadata.schema.dataInsight.custom.DataInsightCustomChart; import org.openmetadata.schema.dataInsight.kpi.Kpi; @@ -5156,6 +5157,7 @@ public static Settings getSettings(SettingsType configType, String json) { case SEARCH_SETTINGS -> JsonUtils.readValue(json, SearchSettings.class); case ASSET_CERTIFICATION_SETTINGS -> JsonUtils.readValue( json, AssetCertificationSettings.class); + case WORKFLOW_SETTINGS -> JsonUtils.readValue(json, WorkflowSettings.class); case LINEAGE_SETTINGS -> JsonUtils.readValue(json, LineageSettings.class); default -> throw new IllegalArgumentException("Invalid Settings Type " + configType); }; diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SystemRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SystemRepository.java index d08b2ca6c336..b854c39a6ee5 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SystemRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SystemRepository.java @@ -16,6 +16,7 @@ import org.jdbi.v3.sqlobject.transaction.Transaction; import org.openmetadata.api.configuration.UiThemePreference; import org.openmetadata.schema.configuration.AssetCertificationSettings; +import org.openmetadata.schema.configuration.WorkflowSettings; import org.openmetadata.schema.email.SmtpSettings; import org.openmetadata.schema.entity.services.ingestionPipelines.PipelineServiceClientResponse; import org.openmetadata.schema.security.client.OpenMetadataJWTClientConfig; @@ -32,6 +33,7 @@ import org.openmetadata.service.OpenMetadataApplicationConfig; import org.openmetadata.service.exception.CustomExceptionMessage; import org.openmetadata.service.fernet.Fernet; +import org.openmetadata.service.governance.workflows.WorkflowHandler; import org.openmetadata.service.jdbi3.CollectionDAO.SystemDAO; import org.openmetadata.service.migration.MigrationValidationClient; import org.openmetadata.service.resources.settings.SettingsCache; @@ -119,6 +121,15 @@ public AssetCertificationSettings getAssetCertificationSettings() { .orElse(null); } + public WorkflowSettings getWorkflowSettings() { + Optional oWorkflowSettings = + Optional.ofNullable(getConfigWithKey(SettingsType.WORKFLOW_SETTINGS.value())); + + return oWorkflowSettings + .map(settings -> (WorkflowSettings) settings.getConfigValue()) + .orElse(null); + } + public Settings getEmailConfigInternal() { try { Settings setting = dao.getConfigWithKey(SettingsType.EMAIL_CONFIGURATION.value()); @@ -209,6 +220,13 @@ public Response patchSetting(String settingName, JsonPatch patch) { return (new RestUtil.PutResponse<>(Response.Status.OK, original, ENTITY_UPDATED)).toResponse(); } + private void postUpdate(SettingsType settingsType) { + if (settingsType == SettingsType.WORKFLOW_SETTINGS) { + WorkflowHandler workflowHandler = WorkflowHandler.getInstance(); + workflowHandler.initializeNewProcessEngine(workflowHandler.getProcessEngineConfiguration()); + } + } + public void updateSetting(Settings setting) { try { if (setting.getConfigType() == SettingsType.EMAIL_CONFIGURATION) { @@ -235,6 +253,7 @@ public void updateSetting(Settings setting) { setting.getConfigType().toString(), JsonUtils.pojoToJson(setting.getConfigValue())); // Invalidate Cache SettingsCache.invalidateSettings(setting.getConfigType().value()); + postUpdate(setting.getConfigType()); } catch (Exception ex) { LOG.error("Failing in Updating Setting.", ex); throw new CustomExceptionMessage( diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/settings/SettingsCache.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/settings/SettingsCache.java index a6785ddb4616..16532c563ce7 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/settings/SettingsCache.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/settings/SettingsCache.java @@ -19,6 +19,7 @@ import static org.openmetadata.schema.settings.SettingsType.LINEAGE_SETTINGS; import static org.openmetadata.schema.settings.SettingsType.LOGIN_CONFIGURATION; import static org.openmetadata.schema.settings.SettingsType.SEARCH_SETTINGS; +import static org.openmetadata.schema.settings.SettingsType.WORKFLOW_SETTINGS; import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; @@ -35,6 +36,9 @@ import org.openmetadata.schema.api.lineage.LineageSettings; import org.openmetadata.schema.api.search.SearchSettings; import org.openmetadata.schema.configuration.AssetCertificationSettings; +import org.openmetadata.schema.configuration.ExecutorConfiguration; +import org.openmetadata.schema.configuration.HistoryCleanUpConfiguration; +import org.openmetadata.schema.configuration.WorkflowSettings; import org.openmetadata.schema.email.SmtpSettings; import org.openmetadata.schema.settings.Settings; import org.openmetadata.schema.settings.SettingsType; @@ -144,6 +148,19 @@ private static void createDefaultConfiguration(OpenMetadataApplicationConfig app systemRepository.createNewSetting(setting); } + // Initialise Workflow Settings + Settings workflowSettings = systemRepository.getConfigWithKey(WORKFLOW_SETTINGS.toString()); + if (workflowSettings == null) { + Settings setting = + new Settings() + .withConfigType(WORKFLOW_SETTINGS) + .withConfigValue( + new WorkflowSettings() + .withExecutorConfiguration(new ExecutorConfiguration()) + .withHistoryCleanUpConfiguration(new HistoryCleanUpConfiguration())); + systemRepository.createNewSetting(setting); + } + Settings lineageSettings = systemRepository.getConfigWithKey(LINEAGE_SETTINGS.toString()); if (lineageSettings == null) { // Only in case a config doesn't exist in DB we insert it diff --git a/openmetadata-spec/src/main/resources/json/schema/configuration/workflowSettings.json b/openmetadata-spec/src/main/resources/json/schema/configuration/workflowSettings.json new file mode 100644 index 000000000000..fe2969cb88d5 --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/configuration/workflowSettings.json @@ -0,0 +1,59 @@ +{ + "$id": "https://open-metadata.org/schema/configuration/workflowSettings.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "WorkflowSettings", + "description": "This schema defines the Workflow Settings.", + "type": "object", + "javaType": "org.openmetadata.schema.configuration.WorkflowSettings", + "definitions": { + "executorConfiguration": { + "type": "object", + "properties": { + "corePoolSize": { + "type": "integer", + "default": 50, + "description": "Default worker Pool Size. The Workflow Executor by default has this amount of workers." + }, + "maxPoolSize": { + "type": "integer", + "default": 100, + "description": "Maximum worker Pool Size. The Workflow Executor could grow up to this number of workers." + }, + "queueSize": { + "type": "integer", + "default": 1000, + "description": "Amount of Tasks that can be queued to be picked up by the Workflow Executor." + }, + "tasksDuePerAcquisition": { + "type": "integer", + "default": 20, + "description": "The amount of Tasks that the Workflow Executor is able to pick up each time it looks for more." + } + }, + "additionalProperties": false + }, + "historyCleanUpConfiguration": { + "type": "object", + "properties": { + "cleanAfterNumberOfDays": { + "type": "integer", + "default": 7, + "description": "Cleans the Workflow Task that were finished, after given number of days." + } + }, + "additionalProperties": false + } + }, + "properties": { + "executorConfiguration": { + "$ref": "#/definitions/executorConfiguration", + "description": "Used to set up the Workflow Executor Settings." + }, + "historyCleanUpConfiguration": { + "$ref": "#/definitions/historyCleanUpConfiguration", + "description": "Used to set up the History CleanUp Settings." + } + }, + "required": ["allowedClassification", "validityPeriod"], + "additionalProperties": false +} \ No newline at end of file diff --git a/openmetadata-spec/src/main/resources/json/schema/settings/settings.json b/openmetadata-spec/src/main/resources/json/schema/settings/settings.json index 0811f5e8fc8f..5a98c9d9552a 100644 --- a/openmetadata-spec/src/main/resources/json/schema/settings/settings.json +++ b/openmetadata-spec/src/main/resources/json/schema/settings/settings.json @@ -32,7 +32,8 @@ "profilerConfiguration", "searchSettings", "assetCertificationSettings", - "lineageSettings" + "lineageSettings", + "workflowSettings" ] } }, @@ -84,6 +85,9 @@ }, { "$ref": "../configuration/lineageSettings.json" + }, + { + "$ref": "../configuration/workflowSettings.json" } ] } From 42320d73c9a692ba44cab29e6df8747ab7655080 Mon Sep 17 00:00:00 2001 From: Pere Miquel Brull Date: Thu, 12 Dec 2024 10:40:03 +0100 Subject: [PATCH 008/294] MINOR - Add APIs to Ref Map (#19019) * MINOR - Add APIs to Ref Map * MINOR - Add APIs to Ref Map * MINOR - Add APIs to Ref Map * format --- ingestion/src/metadata/utils/constants.py | 4 ++++ ingestion/src/metadata/utils/elasticsearch.py | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/ingestion/src/metadata/utils/constants.py b/ingestion/src/metadata/utils/constants.py index 49a0e7c5eef5..e9a20bcce42d 100644 --- a/ingestion/src/metadata/utils/constants.py +++ b/ingestion/src/metadata/utils/constants.py @@ -12,6 +12,8 @@ """ Define constants useful for the metadata ingestion """ +from metadata.generated.schema.entity.data.apiCollection import APICollection +from metadata.generated.schema.entity.data.apiEndpoint import APIEndpoint from metadata.generated.schema.entity.data.chart import Chart from metadata.generated.schema.entity.data.container import Container from metadata.generated.schema.entity.data.dashboard import Dashboard @@ -131,6 +133,8 @@ "metadataService": MetadataService, "searchService": SearchService, # Data Asset Entities + "apiCollection": APICollection, + "apiEndpoint": APIEndpoint, "table": Table, "storedProcedure": StoredProcedure, "database": Database, diff --git a/ingestion/src/metadata/utils/elasticsearch.py b/ingestion/src/metadata/utils/elasticsearch.py index 0f0ab014b057..14bbe94ab02c 100644 --- a/ingestion/src/metadata/utils/elasticsearch.py +++ b/ingestion/src/metadata/utils/elasticsearch.py @@ -18,6 +18,8 @@ from metadata.generated.schema.analytics.reportData import ReportData from metadata.generated.schema.entity.classification.tag import Tag +from metadata.generated.schema.entity.data.apiCollection import APICollection +from metadata.generated.schema.entity.data.apiEndpoint import APIEndpoint from metadata.generated.schema.entity.data.chart import Chart from metadata.generated.schema.entity.data.container import Container from metadata.generated.schema.entity.data.dashboard import Dashboard @@ -33,6 +35,7 @@ from metadata.generated.schema.entity.data.storedProcedure import StoredProcedure from metadata.generated.schema.entity.data.table import Table from metadata.generated.schema.entity.data.topic import Topic +from metadata.generated.schema.entity.services.apiService import ApiService from metadata.generated.schema.entity.services.databaseService import DatabaseService from metadata.generated.schema.entity.teams.team import Team from metadata.generated.schema.entity.teams.user import User @@ -42,6 +45,9 @@ T = TypeVar("T", bound=BaseModel) ES_INDEX_MAP = { + ApiService.__name__: "api_service_search_index", + APICollection.__name__: "api_collection_search_index", + APIEndpoint.__name__: "api_endpoint_search_index", Table.__name__: "table_search_index", Database.__name__: "database_search_index", DatabaseSchema.__name__: "database_schema_search_index", From d2d7212bc673e2cd1f359e6cb5666728efa35c35 Mon Sep 17 00:00:00 2001 From: Mohit Yadav <105265192+mohityadav766@users.noreply.github.com> Date: Sun, 15 Dec 2024 01:16:55 +0530 Subject: [PATCH 009/294] Fix Settings Cache (#19053) (cherry picked from commit 01646431f676190840c6206019859c2589705af6) --- .../service/OpenMetadataApplication.java | 4 ++ .../governance/workflows/WorkflowHandler.java | 2 +- .../service/jdbi3/EntityRepository.java | 2 +- .../service/jdbi3/SystemRepository.java | 24 +++++++++++ .../WorkflowDefinitionResource.java | 1 - .../resources/system/SystemResourceTest.java | 43 +++++++++++++++++++ 6 files changed, 73 insertions(+), 3 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/OpenMetadataApplication.java b/openmetadata-service/src/main/java/org/openmetadata/service/OpenMetadataApplication.java index 9fedc0c89534..fdb07d0bcba9 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/OpenMetadataApplication.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/OpenMetadataApplication.java @@ -79,6 +79,7 @@ import org.openmetadata.service.exception.JsonMappingExceptionMapper; import org.openmetadata.service.exception.OMErrorPageHandler; import org.openmetadata.service.fernet.Fernet; +import org.openmetadata.service.governance.workflows.WorkflowHandler; import org.openmetadata.service.jdbi3.CollectionDAO; import org.openmetadata.service.jdbi3.EntityRepository; import org.openmetadata.service.jdbi3.MigrationDAO; @@ -173,6 +174,9 @@ public void run(OpenMetadataApplicationConfig catalogConfig, Environment environ // Configure the Fernet instance Fernet.getInstance().setFernetKey(catalogConfig); + // Initialize Workflow Handler + WorkflowHandler.initialize(catalogConfig); + // Init Settings Cache after repositories SettingsCache.initialize(catalogConfig); 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 899c4b7f9c10..3e28d2d39885 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 @@ -64,7 +64,7 @@ public void initializeNewProcessEngine( ProcessEngineConfiguration currentProcessEngineConfiguration) { ProcessEngines.destroy(); SystemRepository systemRepository = Entity.getSystemRepository(); - WorkflowSettings workflowSettings = systemRepository.getWorkflowSettings(); + WorkflowSettings workflowSettings = systemRepository.getWorkflowSettingsOrDefault(); StandaloneProcessEngineConfiguration processEngineConfiguration = new StandaloneProcessEngineConfiguration(); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java index 221da3625c89..d4206190e298 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java @@ -3023,7 +3023,7 @@ private void updateCertification() { SystemRepository systemRepository = Entity.getSystemRepository(); AssetCertificationSettings assetCertificationSettings = - systemRepository.getAssetCertificationSettings(); + systemRepository.getAssetCertificationSettingOrDefault(); String certificationLabel = updatedCertification.getTagLabel().getTagFQN(); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SystemRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SystemRepository.java index b854c39a6ee5..302882eb0c7d 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SystemRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SystemRepository.java @@ -16,6 +16,8 @@ import org.jdbi.v3.sqlobject.transaction.Transaction; import org.openmetadata.api.configuration.UiThemePreference; import org.openmetadata.schema.configuration.AssetCertificationSettings; +import org.openmetadata.schema.configuration.ExecutorConfiguration; +import org.openmetadata.schema.configuration.HistoryCleanUpConfiguration; import org.openmetadata.schema.configuration.WorkflowSettings; import org.openmetadata.schema.email.SmtpSettings; import org.openmetadata.schema.entity.services.ingestionPipelines.PipelineServiceClientResponse; @@ -121,6 +123,17 @@ public AssetCertificationSettings getAssetCertificationSettings() { .orElse(null); } + public AssetCertificationSettings getAssetCertificationSettingOrDefault() { + AssetCertificationSettings assetCertificationSettings = getAssetCertificationSettings(); + if (assetCertificationSettings == null) { + assetCertificationSettings = + new AssetCertificationSettings() + .withAllowedClassification("Certification") + .withValidityPeriod("P30D"); + } + return assetCertificationSettings; + } + public WorkflowSettings getWorkflowSettings() { Optional oWorkflowSettings = Optional.ofNullable(getConfigWithKey(SettingsType.WORKFLOW_SETTINGS.value())); @@ -130,6 +143,17 @@ public WorkflowSettings getWorkflowSettings() { .orElse(null); } + public WorkflowSettings getWorkflowSettingsOrDefault() { + WorkflowSettings workflowSettings = getWorkflowSettings(); + if (workflowSettings == null) { + workflowSettings = + new WorkflowSettings() + .withExecutorConfiguration(new ExecutorConfiguration()) + .withHistoryCleanUpConfiguration(new HistoryCleanUpConfiguration()); + } + return workflowSettings; + } + public Settings getEmailConfigInternal() { try { Settings setting = dao.getConfigWithKey(SettingsType.EMAIL_CONFIGURATION.value()); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/governance/WorkflowDefinitionResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/governance/WorkflowDefinitionResource.java index 7cbc8e066a97..f76306509325 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/governance/WorkflowDefinitionResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/governance/WorkflowDefinitionResource.java @@ -70,7 +70,6 @@ public static class WorkflowDefinitionList extends ResultList Date: Sun, 15 Dec 2024 01:18:12 +0530 Subject: [PATCH 010/294] Domain Policy Update to be non-system (#19060) (cherry picked from commit 3578a4b32d605494d44073bfe3fb82742a62b93e) --- .../native/1.5.15/mysql/postDataMigrationSQLScript.sql | 0 .../sql/migrations/native/1.5.15/mysql/schemaChanges.sql | 5 +++++ .../native/1.5.15/postgres/postDataMigrationSQLScript.sql | 0 .../sql/migrations/native/1.5.15/postgres/schemaChanges.sql | 5 +++++ .../main/resources/json/data/policy/DomainAccessPolicy.json | 4 ++-- .../main/resources/json/data/role/DomainOnlyAccessRole.json | 4 ++-- 6 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 bootstrap/sql/migrations/native/1.5.15/mysql/postDataMigrationSQLScript.sql create mode 100644 bootstrap/sql/migrations/native/1.5.15/mysql/schemaChanges.sql create mode 100644 bootstrap/sql/migrations/native/1.5.15/postgres/postDataMigrationSQLScript.sql create mode 100644 bootstrap/sql/migrations/native/1.5.15/postgres/schemaChanges.sql diff --git a/bootstrap/sql/migrations/native/1.5.15/mysql/postDataMigrationSQLScript.sql b/bootstrap/sql/migrations/native/1.5.15/mysql/postDataMigrationSQLScript.sql new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/bootstrap/sql/migrations/native/1.5.15/mysql/schemaChanges.sql b/bootstrap/sql/migrations/native/1.5.15/mysql/schemaChanges.sql new file mode 100644 index 000000000000..19762625a9d8 --- /dev/null +++ b/bootstrap/sql/migrations/native/1.5.15/mysql/schemaChanges.sql @@ -0,0 +1,5 @@ +-- Make domain policy and role non-system +UPDATE policy_entity SET json = JSON_SET(json, '$.provider', 'user') where name = 'DomainOnlyAccessPolicy'; +UPDATE policy_entity SET json = JSON_SET(json, '$.allowDelete', true) where name = 'DomainOnlyAccessPolicy'; +UPDATE role_entity SET json = JSON_SET(json, '$.provider', 'user') where name = 'DomainOnlyAccessRole'; +UPDATE role_entity SET json = JSON_SET(json, '$.allowDelete', true) where name = 'DomainOnlyAccessRole'; \ No newline at end of file diff --git a/bootstrap/sql/migrations/native/1.5.15/postgres/postDataMigrationSQLScript.sql b/bootstrap/sql/migrations/native/1.5.15/postgres/postDataMigrationSQLScript.sql new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/bootstrap/sql/migrations/native/1.5.15/postgres/schemaChanges.sql b/bootstrap/sql/migrations/native/1.5.15/postgres/schemaChanges.sql new file mode 100644 index 000000000000..6f92fbea754c --- /dev/null +++ b/bootstrap/sql/migrations/native/1.5.15/postgres/schemaChanges.sql @@ -0,0 +1,5 @@ +-- Make domain policy and role non-system +UPDATE policy_entity SET json = JSONB_SET(json::jsonb, '{provider}', '"user"', true) where name = 'DomainOnlyAccessPolicy'; +UPDATE policy_entity SET json = JSONB_SET(json::jsonb, '{allowDelete}', 'true', true) WHERE name = 'DomainOnlyAccessPolicy'; +UPDATE role_entity SET json = JSONB_SET(json::jsonb, '{provider}', '"user"', true) where name = 'DomainOnlyAccessRole'; +UPDATE role_entity SET json = JSONB_SET(json::jsonb, '{allowDelete}', 'true', true) WHERE name = 'DomainOnlyAccessRole'; diff --git a/openmetadata-service/src/main/resources/json/data/policy/DomainAccessPolicy.json b/openmetadata-service/src/main/resources/json/data/policy/DomainAccessPolicy.json index 572760b5ef01..d103fff85265 100644 --- a/openmetadata-service/src/main/resources/json/data/policy/DomainAccessPolicy.json +++ b/openmetadata-service/src/main/resources/json/data/policy/DomainAccessPolicy.json @@ -4,8 +4,8 @@ "fullyQualifiedName": "DomainOnlyAccessPolicy", "description": "This Policy adds restrictions so that users will have access to domain related data. If the user has some domain, then he will be able to access data only for that domain. If the user does not have any domain assigned , he will be able to access only assets which also does not have any domain.", "enabled": true, - "allowDelete": false, - "provider": "system", + "allowDelete": true, + "provider": "user", "rules": [ { "name": "DomainOnlyAccessRule", diff --git a/openmetadata-service/src/main/resources/json/data/role/DomainOnlyAccessRole.json b/openmetadata-service/src/main/resources/json/data/role/DomainOnlyAccessRole.json index b18aeae18424..ec770210e4da 100644 --- a/openmetadata-service/src/main/resources/json/data/role/DomainOnlyAccessRole.json +++ b/openmetadata-service/src/main/resources/json/data/role/DomainOnlyAccessRole.json @@ -2,8 +2,8 @@ "name": "DomainOnlyAccessRole", "displayName": "Domain Only Access Role", "description": "Role Corresponding to Domain Access Restriction.", - "allowDelete": false, - "provider": "system", + "allowDelete": true, + "provider": "user", "policies" : [ { "type" : "policy", From c75d453154ab91916ccdf477444a14c3b2cc9f72 Mon Sep 17 00:00:00 2001 From: sonika-shah <58761340+sonika-shah@users.noreply.github.com> Date: Mon, 16 Dec 2024 15:14:29 +0530 Subject: [PATCH 011/294] fix: Resolve conflicts --- .../main/java/org/openmetadata/service/jdbi3/CollectionDAO.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java index 809a53e5d6b4..a423db901cd3 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java @@ -4152,7 +4152,7 @@ List listWithoutEntityFilter( @Bind("eventType") String eventType, @Bind("timestamp") long timestamp); @SqlQuery( - "SELECT json FROM change_event ce where ce.offset > :offset ORDER BY ce.eventTime ASC LIMIT :limit") + "SELECT json FROM change_event ce WHERE ce.offset > :offset ORDER BY ce.offset ASC LIMIT :limit") List list(@Bind("limit") long limit, @Bind("offset") long offset); @ConnectionAwareSqlQuery(value = "SELECT MAX(offset) FROM change_event", connectionType = MYSQL) From db2770fbb48a2da9ec559e9d0ca23baa6c6a79aa Mon Sep 17 00:00:00 2001 From: Siddhant <86899184+Siddhanttimeline@users.noreply.github.com> Date: Mon, 16 Dec 2024 16:07:56 +0530 Subject: [PATCH 012/294] Fixes 19073 : Resolve NullPointerException in MS Teams DQ template handling (#19062) * fix: Resolve NullPointerException in MS Teams DQ template handling * fix: Update DQ template logic to handle test case changes and results Details: Ensures a general template is created for any changes in the test case. Generates a DQ template when the test case result is updated. * refactor: Make testCaseResult as constant named TEST_CASE_RESULT --- .../decorators/MSTeamsMessageDecorator.java | 51 +++++++------------ 1 file changed, 19 insertions(+), 32 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/formatter/decorators/MSTeamsMessageDecorator.java b/openmetadata-service/src/main/java/org/openmetadata/service/formatter/decorators/MSTeamsMessageDecorator.java index f1916c2be585..f16b031f3d4a 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/formatter/decorators/MSTeamsMessageDecorator.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/formatter/decorators/MSTeamsMessageDecorator.java @@ -29,6 +29,7 @@ import org.openmetadata.schema.tests.TestCaseParameterValue; import org.openmetadata.schema.type.ChangeEvent; import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.schema.type.FieldChange; import org.openmetadata.schema.type.TagLabel; import org.openmetadata.service.Entity; import org.openmetadata.service.apps.bundles.changeEvent.msteams.TeamsMessage; @@ -41,6 +42,7 @@ import org.openmetadata.service.exception.UnhandledServerException; public class MSTeamsMessageDecorator implements MessageDecorator { + private static final String TEST_CASE_RESULT = "testCaseResult"; @Override public String getBold() { @@ -115,11 +117,25 @@ private TeamsMessage createMessage( String entityType = event.getEntityType(); return switch (entityType) { - case Entity.TEST_CASE -> createDQMessage(publisherName, event, outgoingMessage); + case Entity.TEST_CASE -> createTestCaseMessage(publisherName, event, outgoingMessage); default -> createGeneralChangeEventMessage(publisherName, event, outgoingMessage); }; } + private TeamsMessage createTestCaseMessage( + String publisherName, ChangeEvent event, OutgoingMessage outgoingMessage) { + List fieldsAdded = event.getChangeDescription().getFieldsAdded(); + List fieldsUpdated = event.getChangeDescription().getFieldsUpdated(); + + boolean hasRelevantChange = + fieldsAdded.stream().anyMatch(field -> TEST_CASE_RESULT.equals(field.getName())) + || fieldsUpdated.stream().anyMatch(field -> TEST_CASE_RESULT.equals(field.getName())); + + return hasRelevantChange + ? createDQMessage(event, outgoingMessage) + : createGeneralChangeEventMessage(publisherName, event, outgoingMessage); + } + private TeamsMessage createGeneralChangeEventMessage( String publisherName, ChangeEvent event, OutgoingMessage outgoingMessage) { @@ -173,11 +189,9 @@ private TeamsMessage createGeneralChangeEventMessage( return TeamsMessage.builder().type("message").attachments(List.of(attachment)).build(); } - private TeamsMessage createDQMessage( - String publisherName, ChangeEvent event, OutgoingMessage outgoingMessage) { - + private TeamsMessage createDQMessage(ChangeEvent event, OutgoingMessage outgoingMessage) { Map, Object>> dqTemplateData = - buildDQTemplateData(publisherName, event, outgoingMessage); + MessageDecorator.buildDQTemplateData(event, outgoingMessage); TextBlock changeEventDetailsTextBlock = createHeader(); @@ -569,33 +583,6 @@ private Map, Object>> buildGeneralTemplate return builder.build(); } - // todo - complete buildDQTemplateData fn - private Map, Object>> buildDQTemplateData( - String publisherName, ChangeEvent event, OutgoingMessage outgoingMessage) { - - TemplateDataBuilder builder = new TemplateDataBuilder<>(); - - // Use DQ_Template_Section directly - builder - .add( - DQ_Template_Section.EVENT_DETAILS, - EventDetailsKeys.EVENT_TYPE, - event.getEventType().value()) - .add(DQ_Template_Section.EVENT_DETAILS, EventDetailsKeys.UPDATED_BY, event.getUserName()) - .add(DQ_Template_Section.EVENT_DETAILS, EventDetailsKeys.ENTITY_TYPE, event.getEntityType()) - .add( - DQ_Template_Section.EVENT_DETAILS, - EventDetailsKeys.ENTITY_FQN, - MessageDecorator.getFQNForChangeEventEntity(event)) - .add( - DQ_Template_Section.EVENT_DETAILS, - EventDetailsKeys.TIME, - new Date(event.getTimestamp()).toString()) - .add(DQ_Template_Section.EVENT_DETAILS, EventDetailsKeys.OUTGOING_MESSAGE, outgoingMessage); - - return builder.build(); - } - private TextBlock createHeader() { return TextBlock.builder() .type("TextBlock") From cf1c737300ac1e399f263d8a1cd91263500bfd57 Mon Sep 17 00:00:00 2001 From: Siddhant <86899184+Siddhanttimeline@users.noreply.github.com> Date: Thu, 12 Dec 2024 15:24:01 +0530 Subject: [PATCH 013/294] Fix: Filter activity feed events for test cases and test suites. (#19004) * fix: Filter activity feed events for test cases and suites. * minor fix around the button label after save task tab --------- Co-authored-by: Ashish Gupta --- .../json/data/eventsubscription/ActivityFeedEvents.json | 2 +- .../components/Entity/Task/TaskTab/TaskTab.component.tsx | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/openmetadata-service/src/main/resources/json/data/eventsubscription/ActivityFeedEvents.json b/openmetadata-service/src/main/resources/json/data/eventsubscription/ActivityFeedEvents.json index ecdaeba28b41..4db1f4b296bf 100644 --- a/openmetadata-service/src/main/resources/json/data/eventsubscription/ActivityFeedEvents.json +++ b/openmetadata-service/src/main/resources/json/data/eventsubscription/ActivityFeedEvents.json @@ -10,7 +10,7 @@ { "name": "matchAnyEventType", "effect": "include", - "condition": "matchAnyEventType({'entityCreated', 'entityDeleted', 'entitySoftDeleted'}) && !matchUpdatedBy({'ingestion-bot'}) && !matchAnySource({'user', 'team', 'owners', 'databaseService', 'messagingService', 'dashboardService', 'pipelineService', 'storageService', 'mlmodelService', 'metadataService', 'searchService', 'apiService', 'ingestionPipeline', 'workflow'})", + "condition": "matchAnyEventType({'entityCreated', 'entityDeleted', 'entitySoftDeleted'}) && !matchUpdatedBy({'ingestion-bot'}) && !matchAnySource({'user', 'team', 'owners', 'databaseService', 'messagingService', 'dashboardService', 'pipelineService', 'storageService', 'mlmodelService', 'metadataService', 'searchService', 'apiService', 'ingestionPipeline', 'workflow', 'testCase', 'testSuite'})", "prefixCondition": "AND" }, { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTab.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTab.component.tsx index 4e6cd1a724cb..0549abd69000 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTab.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTab.component.tsx @@ -431,8 +431,11 @@ export const TaskTab = ({ onTaskResolve(); } setTaskAction( - TASK_ACTION_LIST.find((action) => action.key === info.key) ?? - TASK_ACTION_LIST[0] + [ + ...TASK_ACTION_LIST, + ...GLOSSARY_TASK_ACTION_LIST, + ...INCIDENT_TASK_ACTION_LIST, + ].find((action) => action.key === info.key) ?? TASK_ACTION_LIST[0] ); }; From d150061908eab2b7a82014ca3c65e45ace343f8c Mon Sep 17 00:00:00 2001 From: Mohit Yadav <105265192+mohityadav766@users.noreply.github.com> Date: Tue, 17 Dec 2024 23:43:07 +0530 Subject: [PATCH 014/294] Fix Login Configuration Issue (#19109) * Fix Login Configuration Issue * Fix Tests (cherry picked from commit 4b9948dbfbbfcacea997a72df7442b11430a1bb2) --- .../exception/CatalogExceptionMessage.java | 2 +- .../service/jdbi3/SystemRepository.java | 5 +++++ .../resources/settings/SettingsCache.java | 2 +- .../security/auth/BasicAuthenticator.java | 14 ++++++-------- .../service/security/auth/LdapAuthenticator.java | 8 +++----- .../service/security/auth/LoginAttemptCache.java | 16 +++++++++++++++- .../resources/system/ConfigResourceTest.java | 2 +- .../resources/system/SystemResourceTest.java | 2 +- 8 files changed, 33 insertions(+), 18 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/exception/CatalogExceptionMessage.java b/openmetadata-service/src/main/java/org/openmetadata/service/exception/CatalogExceptionMessage.java index 7935488bd8fd..223f2a518961 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/exception/CatalogExceptionMessage.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/exception/CatalogExceptionMessage.java @@ -35,7 +35,7 @@ public final class CatalogExceptionMessage { public static final String PASSWORD_INVALID_FORMAT = "Password must be of minimum 8 characters, with one special, one Upper, one lower case character, and one Digit."; public static final String MAX_FAILED_LOGIN_ATTEMPT = - "Failed Login Attempts Exceeded. Please try after some time."; + "Failed Login Attempts Exceeded. Use Forgot Password or retry after some time."; public static final String INCORRECT_OLD_PASSWORD = "INCORRECT_OLD_PASSWORD"; diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SystemRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SystemRepository.java index 302882eb0c7d..01b7d76d12f9 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SystemRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SystemRepository.java @@ -43,6 +43,7 @@ import org.openmetadata.service.secrets.SecretsManager; import org.openmetadata.service.secrets.SecretsManagerFactory; import org.openmetadata.service.security.JwtFilter; +import org.openmetadata.service.security.auth.LoginAttemptCache; import org.openmetadata.service.util.JsonUtils; import org.openmetadata.service.util.OpenMetadataConnectionBuilder; import org.openmetadata.service.util.RestUtil; @@ -249,6 +250,10 @@ private void postUpdate(SettingsType settingsType) { WorkflowHandler workflowHandler = WorkflowHandler.getInstance(); workflowHandler.initializeNewProcessEngine(workflowHandler.getProcessEngineConfiguration()); } + + if (settingsType == SettingsType.LOGIN_CONFIGURATION) { + LoginAttemptCache.updateLoginConfiguration(); + } } public void updateSetting(Settings setting) { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/settings/SettingsCache.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/settings/SettingsCache.java index 16532c563ce7..e68c3b891ae7 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/settings/SettingsCache.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/settings/SettingsCache.java @@ -118,7 +118,7 @@ private static void createDefaultConfiguration(OpenMetadataApplicationConfig app .withConfigValue( new LoginConfiguration() .withMaxLoginFailAttempts(3) - .withAccessBlockTime(600) + .withAccessBlockTime(30) .withJwtTokenExpiryTime(3600)); systemRepository.createNewSetting(setting); } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/BasicAuthenticator.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/BasicAuthenticator.java index a99a7eeab87a..9d33d87927d1 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/BasicAuthenticator.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/BasicAuthenticator.java @@ -102,7 +102,6 @@ public class BasicAuthenticator implements AuthenticatorHandler { private static final int HASHING_COST = 12; private UserRepository userRepository; private TokenRepository tokenRepository; - private LoginAttemptCache loginAttemptCache; private AuthorizerConfiguration authorizerConfiguration; private boolean isSelfSignUpAvailable; @@ -111,7 +110,6 @@ public void init(OpenMetadataApplicationConfig config) { this.userRepository = (UserRepository) Entity.getEntityRepository(Entity.USER); this.tokenRepository = Entity.getTokenRepository(); this.authorizerConfiguration = config.getAuthorizerConfiguration(); - this.loginAttemptCache = new LoginAttemptCache(); this.isSelfSignUpAvailable = config.getAuthenticationConfiguration().getEnableSelfSignup(); } @@ -267,7 +265,7 @@ public void resetUserPasswordWithToken(UriInfo uriInfo, PasswordResetRequest req LOG.error("Error in sending Password Change Mail to User. Reason : " + ex.getMessage(), ex); throw new CustomExceptionMessage(424, FAILED_SEND_EMAIL, EMAIL_SENDING_ISSUE); } - loginAttemptCache.recordSuccessfulLogin(request.getUsername()); + LoginAttemptCache.getInstance().recordSuccessfulLogin(request.getUsername()); } @Override @@ -312,7 +310,7 @@ public void changeUserPwdWithOldPwd( storedUser.getAuthenticationMechanism().setConfig(storedBasicAuthMechanism); PutResponse response = userRepository.createOrUpdate(uriInfo, storedUser); // remove login/details from cache - loginAttemptCache.recordSuccessfulLogin(userName); + LoginAttemptCache.getInstance().recordSuccessfulLogin(userName); // in case admin updates , send email to user if (request.getRequestType() == USER && getSmtpSettings().getEnableSmtpServer()) { @@ -476,7 +474,7 @@ public JwtResponse loginUser(LoginRequest loginRequest) throws IOException, Temp @Override public void checkIfLoginBlocked(String email) { - if (loginAttemptCache.isLoginBlocked(email)) { + if (LoginAttemptCache.getInstance().isLoginBlocked(email)) { throw new AuthenticationException(MAX_FAILED_LOGIN_ATTEMPT); } } @@ -484,15 +482,15 @@ public void checkIfLoginBlocked(String email) { @Override public void recordFailedLoginAttempt(String email, String userName) throws TemplateException, IOException { - loginAttemptCache.recordFailedLogin(email); - int failedLoginAttempt = loginAttemptCache.getUserFailedLoginCount(email); + LoginAttemptCache.getInstance().recordFailedLogin(email); + int failedLoginAttempt = LoginAttemptCache.getInstance().getUserFailedLoginCount(email); if (failedLoginAttempt == SecurityUtil.getLoginConfiguration().getMaxLoginFailAttempts()) { sendAccountStatus( userName, email, "Multiple Failed Login Attempts.", String.format( - "Someone is trying to access your account. Login is Blocked for %s minutes. Please change your password.", + "Someone is trying to access your account. Login is Blocked for %s seconds. Please change your password.", SecurityUtil.getLoginConfiguration().getAccessBlockTime())); } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/LdapAuthenticator.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/LdapAuthenticator.java index 9531d76c1f97..d608434d7b2c 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/LdapAuthenticator.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/LdapAuthenticator.java @@ -84,7 +84,6 @@ public class LdapAuthenticator implements AuthenticatorHandler { private RoleRepository roleRepository; private UserRepository userRepository; private TokenRepository tokenRepository; - private LoginAttemptCache loginAttemptCache; private LdapConfiguration ldapConfiguration; private LDAPConnectionPool ldapLookupConnectionPool; private boolean isSelfSignUpEnabled; @@ -102,7 +101,6 @@ public void init(OpenMetadataApplicationConfig config) { this.roleRepository = (RoleRepository) Entity.getEntityRepository(Entity.ROLE); this.tokenRepository = Entity.getTokenRepository(); this.ldapConfiguration = config.getAuthenticationConfiguration().getLdapConfiguration(); - this.loginAttemptCache = new LoginAttemptCache(); this.isSelfSignUpEnabled = config.getAuthenticationConfiguration().getEnableSelfSignup(); } @@ -176,7 +174,7 @@ private User checkAndCreateUser(String userDn, String email, String userName) th @Override public void checkIfLoginBlocked(String email) { - if (loginAttemptCache.isLoginBlocked(email)) { + if (LoginAttemptCache.getInstance().isLoginBlocked(email)) { throw new AuthenticationException(MAX_FAILED_LOGIN_ATTEMPT); } } @@ -184,8 +182,8 @@ public void checkIfLoginBlocked(String email) { @Override public void recordFailedLoginAttempt(String email, String userName) throws TemplateException, IOException { - loginAttemptCache.recordFailedLogin(email); - int failedLoginAttempt = loginAttemptCache.getUserFailedLoginCount(email); + LoginAttemptCache.getInstance().recordFailedLogin(email); + int failedLoginAttempt = LoginAttemptCache.getInstance().getUserFailedLoginCount(email); if (failedLoginAttempt == SecurityUtil.getLoginConfiguration().getMaxLoginFailAttempts()) { EmailUtil.sendAccountStatus( userName, diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/LoginAttemptCache.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/LoginAttemptCache.java index 0ceb672d098c..336b97e473d1 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/LoginAttemptCache.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/LoginAttemptCache.java @@ -3,6 +3,7 @@ import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; import com.google.common.cache.LoadingCache; +import io.dropwizard.logback.shaded.guava.annotations.VisibleForTesting; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import lombok.NonNull; @@ -12,10 +13,11 @@ import org.openmetadata.service.resources.settings.SettingsCache; public class LoginAttemptCache { + private static LoginAttemptCache INSTANCE; private int maxAttempt = 3; private final LoadingCache attemptsCache; - public LoginAttemptCache() { + private LoginAttemptCache() { LoginConfiguration loginConfiguration = SettingsCache.getSetting(SettingsType.LOGIN_CONFIGURATION, LoginConfiguration.class); long accessBlockTime = 600; @@ -35,6 +37,18 @@ public LoginAttemptCache() { }); } + public static LoginAttemptCache getInstance() { + if (INSTANCE == null) { + INSTANCE = new LoginAttemptCache(); + } + return INSTANCE; + } + + public static void updateLoginConfiguration() { + INSTANCE = new LoginAttemptCache(); + } + + @VisibleForTesting public LoginAttemptCache(int maxAttempt, int blockTimeInSec) { this.maxAttempt = maxAttempt; attemptsCache = diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/system/ConfigResourceTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/system/ConfigResourceTest.java index 9cf314ed1755..9a80814133ef 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/resources/system/ConfigResourceTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/system/ConfigResourceTest.java @@ -124,7 +124,7 @@ void get_Login_Configuration_200_OK() throws IOException { LoginConfiguration loginConfiguration = TestUtils.get(target, LoginConfiguration.class, TEST_AUTH_HEADERS); assertEquals(3, loginConfiguration.getMaxLoginFailAttempts()); - assertEquals(600, loginConfiguration.getAccessBlockTime()); + assertEquals(30, loginConfiguration.getAccessBlockTime()); assertEquals(3600, loginConfiguration.getJwtTokenExpiryTime()); } diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/system/SystemResourceTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/system/SystemResourceTest.java index 1cf64c31e5ea..686de7c1bd3a 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/resources/system/SystemResourceTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/system/SystemResourceTest.java @@ -436,7 +436,7 @@ void testLoginConfigurationSettings() throws HttpResponseException { // Assert default values assertEquals(3, loginConfig.getMaxLoginFailAttempts()); - assertEquals(600, loginConfig.getAccessBlockTime()); + assertEquals(30, loginConfig.getAccessBlockTime()); assertEquals(3600, loginConfig.getJwtTokenExpiryTime()); // Update login configuration From 407c0c9547cb2374c56c8a52aba4fc633b52f031 Mon Sep 17 00:00:00 2001 From: Ashish Gupta Date: Fri, 13 Dec 2024 23:41:40 +0530 Subject: [PATCH 015/294] supported editUser permission in user tab for team page (#18987) * supported editUser permission in user tab for team page * remove edit all permission check in teams add/remove user api * added playwright test for the editUser permission * Added playwright test for data consumer user and remove no used field from the advance api call --------- Co-authored-by: sonikashah Co-authored-by: sonika-shah <58761340+sonika-shah@users.noreply.github.com> (cherry picked from commit 9e6078f654ed0a54e9d41b12c3a748449c6079ea) --- .../service/resources/teams/TeamResource.java | 9 - .../ui/playwright/constant/permission.ts | 9 + .../ui/playwright/e2e/Pages/Teams.spec.ts | 235 +++++++++++++++++- .../ui/playwright/support/team/TeamClass.ts | 27 +- .../resources/ui/playwright/utils/team.ts | 37 +++ .../TeamDetails/UserTab/UserTab.component.tsx | 19 +- .../ui/src/pages/TeamsPage/TeamsPage.test.tsx | 1 - .../ui/src/pages/TeamsPage/TeamsPage.tsx | 1 - 8 files changed, 315 insertions(+), 23 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/TeamResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/TeamResource.java index c30c3178bb25..6f0070dff28a 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/TeamResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/TeamResource.java @@ -72,7 +72,6 @@ import org.openmetadata.service.resources.Collection; import org.openmetadata.service.resources.EntityResource; import org.openmetadata.service.security.Authorizer; -import org.openmetadata.service.security.policyevaluator.OperationContext; import org.openmetadata.service.util.CSVExportResponse; import org.openmetadata.service.util.EntityUtil; import org.openmetadata.service.util.JsonUtils; @@ -687,10 +686,6 @@ public Response updateTeamUsers( @Context SecurityContext securityContext, @PathParam("teamId") UUID teamId, List users) { - - OperationContext operationContext = - new OperationContext(entityType, MetadataOperation.EDIT_ALL); - authorizer.authorize(securityContext, operationContext, getResourceContextById(teamId)); return repository .updateTeamUsers(securityContext.getUserPrincipal().getName(), teamId, users) .toResponse(); @@ -721,10 +716,6 @@ public Response deleteTeamUser( @Parameter(description = "Id of the user being removed", schema = @Schema(type = "string")) @PathParam("userId") String userId) { - - OperationContext operationContext = - new OperationContext(entityType, MetadataOperation.EDIT_ALL); - authorizer.authorize(securityContext, operationContext, getResourceContextById(teamId)); return repository .deleteTeamUser( securityContext.getUserPrincipal().getName(), teamId, UUID.fromString(userId)) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/constant/permission.ts b/openmetadata-ui/src/main/resources/ui/playwright/constant/permission.ts index b4dccdce8030..7c810b833c70 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/constant/permission.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/constant/permission.ts @@ -77,6 +77,15 @@ export const DATA_CONSUMER_RULES: PolicyRulesType[] = [ }, ]; +export const EDIT_USER_FOR_TEAM_RULES: PolicyRulesType[] = [ + { + name: 'EditUserTeams-EditRule', + resources: ['team'], + operations: ['EditUsers'], + effect: 'allow', + }, +]; + export const ORGANIZATION_POLICY_RULES: PolicyRulesType[] = [ { name: 'OrganizationPolicy-NoOwner-Rule', diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Teams.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Teams.spec.ts index 2436b924d16c..0e8a26acec81 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Teams.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Teams.spec.ts @@ -10,12 +10,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import test, { expect } from '@playwright/test'; +import { expect, Page, test as base } from '@playwright/test'; +import { EDIT_USER_FOR_TEAM_RULES } from '../../constant/permission'; import { GlobalSettingOptions } from '../../constant/settings'; +import { PolicyClass } from '../../support/access-control/PoliciesClass'; +import { RolesClass } from '../../support/access-control/RolesClass'; import { EntityTypeEndpoint } from '../../support/entity/Entity.interface'; import { TableClass } from '../../support/entity/TableClass'; import { TeamClass } from '../../support/team/TeamClass'; import { UserClass } from '../../support/user/UserClass'; +import { performAdminLogin } from '../../utils/admin'; import { createNewPage, descriptionBox, @@ -28,6 +32,7 @@ import { addMultiOwner } from '../../utils/entity'; import { settingClick } from '../../utils/sidebar'; import { addTeamOwnerToEntity, + addUserInTeam, createTeam, hardDeleteTeam, searchTeam, @@ -35,10 +40,15 @@ import { verifyAssetsInTeamsPage, } from '../../utils/team'; -// use the admin user to login -test.use({ storageState: 'playwright/.auth/admin.json' }); - +const id = uuid(); +const dataConsumerUser = new UserClass(); +const editOnlyUser = new UserClass(); // this user will have only editUser permission in team +let team = new TeamClass(); +const team2 = new TeamClass(); +const policy = new PolicyClass(); +const role = new RolesClass(); const user = new UserClass(); +const user2 = new UserClass(); const userName = user.data.email.split('@')[0]; let teamDetails: { @@ -55,7 +65,28 @@ let teamDetails: { updatedEmail: `pwteamUpdated${uuid()}@example.com`, }; +const test = base.extend<{ + editOnlyUserPage: Page; + dataConsumerPage: Page; +}>({ + editOnlyUserPage: async ({ browser }, use) => { + const page = await browser.newPage(); + await editOnlyUser.login(page); + await use(page); + await page.close(); + }, + dataConsumerPage: async ({ browser }, use) => { + const page = await browser.newPage(); + await dataConsumerUser.login(page); + await use(page); + await page.close(); + }, +}); + test.describe('Teams Page', () => { + // use the admin user to login + test.use({ storageState: 'playwright/.auth/admin.json' }); + test.slow(true); test.beforeAll('Setup pre-requests', async ({ browser }) => { @@ -636,3 +667,199 @@ test.describe('Teams Page', () => { await afterAction(); }); }); + +test.describe('Teams Page with EditUser Permission', () => { + test.slow(true); + + test.beforeAll('Setup pre-requests', async ({ browser }) => { + const { apiContext, afterAction } = await performAdminLogin(browser); + await editOnlyUser.create(apiContext); + + const id = uuid(); + await policy.create(apiContext, EDIT_USER_FOR_TEAM_RULES); + await role.create(apiContext, [policy.responseData.name]); + + team = new TeamClass({ + name: `PW%edit-user-team-${id}`, + displayName: `PW Edit User Team ${id}`, + description: 'playwright edit user team description', + teamType: 'Group', + users: [editOnlyUser.responseData.id], + defaultRoles: role.responseData.id ? [role.responseData.id] : [], + }); + await team.create(apiContext); + await team2.create(apiContext); + await user.create(apiContext); + await user2.create(apiContext); + await afterAction(); + }); + + test.afterAll('Cleanup', async ({ browser }) => { + const { apiContext, afterAction } = await performAdminLogin(browser); + await user.delete(apiContext); + await user2.delete(apiContext); + await editOnlyUser.delete(apiContext); + await team.delete(apiContext); + await team2.delete(apiContext); + await policy.delete(apiContext); + await role.delete(apiContext); + await afterAction(); + }); + + test.beforeEach('Visit Home Page', async ({ editOnlyUserPage }) => { + await redirectToHomePage(editOnlyUserPage); + await team2.visitTeamPage(editOnlyUserPage); + }); + + test('Add and Remove User for Team', async ({ editOnlyUserPage }) => { + await test.step('Add user in Team from the placeholder', async () => { + await addUserInTeam(editOnlyUserPage, user); + }); + + await test.step('Add user in Team for the header manage area', async () => { + await addUserInTeam(editOnlyUserPage, user2); + }); + + await test.step('Remove user from Team', async () => { + await editOnlyUserPage + .getByRole('row', { + name: `${user.data.firstName.slice(0, 1).toUpperCase()} ${ + user.data.firstName + }.`, + }) + .getByTestId('remove-user-btn') + .click(); + + const userResponse = editOnlyUserPage.waitForResponse( + '/api/v1/users?fields=**' + ); + await editOnlyUserPage.getByRole('button', { name: 'Confirm' }).click(); + await userResponse; + + await expect( + editOnlyUserPage.locator(`[data-testid="${userName.toLowerCase()}"]`) + ).not.toBeVisible(); + }); + }); +}); + +test.describe('Teams Page with Data Consumer User', () => { + test.slow(true); + + test.beforeAll('Setup pre-requests', async ({ browser }) => { + const { apiContext, afterAction } = await performAdminLogin(browser); + await dataConsumerUser.create(apiContext); + await user.create(apiContext); + await policy.create(apiContext, EDIT_USER_FOR_TEAM_RULES); + await role.create(apiContext, [policy.responseData.name]); + + team = new TeamClass({ + name: `PW%-data-consumer-team-${id}`, + displayName: `PW Data Consumer Team ${id}`, + description: 'playwright data consumer team description', + teamType: 'Group', + users: [user.responseData.id], + defaultRoles: role.responseData.id ? [role.responseData.id] : [], + }); + await team.create(apiContext); + await team2.create(apiContext); + await afterAction(); + }); + + test.afterAll('Cleanup', async ({ browser }) => { + const { apiContext, afterAction } = await performAdminLogin(browser); + await dataConsumerUser.delete(apiContext); + await user.delete(apiContext); + await team.delete(apiContext); + await team2.delete(apiContext); + await afterAction(); + }); + + test.beforeEach('Visit Home Page', async ({ dataConsumerPage }) => { + await redirectToHomePage(dataConsumerPage); + }); + + test('Should not have edit access on team page with no data available', async ({ + dataConsumerPage, + }) => { + await team2.visitTeamPage(dataConsumerPage); + + await expect( + dataConsumerPage.getByTestId('edit-team-name') + ).not.toBeVisible(); + await expect(dataConsumerPage.getByTestId('add-domain')).not.toBeVisible(); + await expect(dataConsumerPage.getByTestId('edit-owner')).not.toBeVisible(); + await expect(dataConsumerPage.getByTestId('edit-email')).not.toBeVisible(); + await expect( + dataConsumerPage.getByTestId('edit-team-subscription') + ).not.toBeVisible(); + await expect( + dataConsumerPage.getByTestId('manage-button') + ).not.toBeVisible(); + + await expect(dataConsumerPage.getByTestId('join-teams')).toBeVisible(); + + // User Tab + await expect( + dataConsumerPage.getByTestId('add-new-user') + ).not.toBeVisible(); + await expect( + dataConsumerPage.getByTestId('permission-error-placeholder') + ).toBeVisible(); + + // Asset Tab + const assetResponse = dataConsumerPage.waitForResponse( + '/api/v1/search/query?**' + ); + await dataConsumerPage.getByTestId('assets').click(); + await assetResponse; + + await expect( + dataConsumerPage.getByTestId('add-placeholder-button') + ).not.toBeVisible(); + await expect( + dataConsumerPage.getByTestId('no-data-placeholder') + ).toBeVisible(); + + // Role Tab + await dataConsumerPage.getByTestId('roles').click(); + + await expect( + dataConsumerPage.getByTestId('add-placeholder-button') + ).not.toBeVisible(); + await expect( + dataConsumerPage.getByTestId('permission-error-placeholder') + ).toBeVisible(); + + // Policies Tab + await dataConsumerPage.getByTestId('policies').click(); + + await expect( + dataConsumerPage.getByTestId('add-placeholder-button') + ).not.toBeVisible(); + await expect( + dataConsumerPage.getByTestId('permission-error-placeholder') + ).toBeVisible(); + }); + + test('Should not have edit access on team page with data available', async ({ + dataConsumerPage, + }) => { + await team.visitTeamPage(dataConsumerPage); + + // User Tab + await expect( + dataConsumerPage.getByTestId('add-new-user') + ).not.toBeVisible(); + + // Role Tab + await dataConsumerPage.getByTestId('roles').click(); + + await expect(dataConsumerPage.getByTestId('add-role')).not.toBeVisible(); + + // Policies Tab + await dataConsumerPage.getByTestId('policies').click(); + + await expect(dataConsumerPage.getByTestId('add-policy')).not.toBeVisible(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/team/TeamClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/team/TeamClass.ts index d580c59c8b7d..a6553bc4eda0 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/team/TeamClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/team/TeamClass.ts @@ -10,8 +10,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { APIRequestContext } from '@playwright/test'; +import { APIRequestContext, expect, Page } from '@playwright/test'; +import { GlobalSettingOptions } from '../../constant/settings'; import { uuid } from '../../utils/common'; +import { settingClick } from '../../utils/sidebar'; +import { searchTeam } from '../../utils/team'; type ResponseDataType = { name: string; displayName: string; @@ -46,6 +49,28 @@ export class TeamClass { return this.responseData; } + async visitTeamPage(page: Page) { + // complete url since we are making basic and advance call to get the details of the team + const fetchOrganizationResponse = page.waitForResponse( + `/api/v1/teams/name/Organization?fields=users%2CdefaultRoles%2Cpolicies%2CchildrenCount%2Cdomains&include=all` + ); + await settingClick(page, GlobalSettingOptions.TEAMS); + await fetchOrganizationResponse; + + await searchTeam(page, this.responseData?.['displayName']); + + await page + .locator(`[data-row-key="${this.data.name}"]`) + .getByRole('link') + .click(); + + await page.waitForLoadState('networkidle'); + + await expect(page.getByTestId('team-heading')).toHaveText( + this.data.displayName + ); + } + async create(apiContext: APIRequestContext) { const response = await apiContext.post('/api/v1/teams', { data: this.data, diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/team.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/team.ts index 382f5c2f0146..f77452977e78 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/team.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/team.ts @@ -13,6 +13,7 @@ import { APIRequestContext, expect, Page } from '@playwright/test'; import { TableClass } from '../support/entity/TableClass'; import { TeamClass } from '../support/team/TeamClass'; +import { UserClass } from '../support/user/UserClass'; import { descriptionBox, toastNotification, uuid } from './common'; import { addOwner } from './entity'; import { validateFormNameFieldInput } from './form'; @@ -316,3 +317,39 @@ export const verifyAssetsInTeamsPage = async ( page.getByTestId('assets').getByTestId('filter-count') ).toContainText(assetCount.toString()); }; + +export const addUserInTeam = async (page: Page, user: UserClass) => { + const userName = user.data.email.split('@')[0]; + const fetchUsersResponse = page.waitForResponse( + '/api/v1/users?limit=25&isBot=false' + ); + await page.locator('[data-testid="add-new-user"]').click(); + await fetchUsersResponse; + + // Search and select the user + await page + .locator('[data-testid="selectable-list"] [data-testid="searchbar"]') + .fill(user.getUserName()); + + await page + .locator(`[data-testid="selectable-list"] [title="${user.getUserName()}"]`) + .click(); + + await expect( + page.locator( + `[data-testid="selectable-list"] [title="${user.getUserName()}"]` + ) + ).toHaveClass(/active/); + + const updateTeamResponse = page.waitForResponse('/api/v1/users*'); + + // Update the team with the new user + await page.locator('[data-testid="selectable-list-update-btn"]').click(); + await updateTeamResponse; + + // Verify the user is added to the team + + await expect( + page.locator(`[data-testid="${userName.toLowerCase()}"]`) + ).toBeVisible(); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/UserTab/UserTab.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/UserTab/UserTab.component.tsx index b4a4b0d16450..0205cf9c0a7d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/UserTab/UserTab.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/UserTab/UserTab.component.tsx @@ -105,6 +105,11 @@ export const UserTab = ({ [currentTeam.teamType] ); + const editUserPermission = useMemo( + () => permission.EditAll || permission.EditUsers, + [permission.EditAll, permission.EditUsers] + ); + /** * Make API call to fetch current team user data */ @@ -213,13 +218,13 @@ export const UserTab = ({
- {users.length > 0 && permission.EditAll && ( + {users.length > 0 && editUserPermission && ( { { fields: [ 'users', - 'userCount', 'defaultRoles', 'policies', 'childrenCount', diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TeamsPage/TeamsPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TeamsPage/TeamsPage.tsx index 29baf0f254fd..7cde8c3d8cc5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TeamsPage/TeamsPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TeamsPage/TeamsPage.tsx @@ -244,7 +244,6 @@ const TeamsPage = () => { const data = await getTeamByName(name, { fields: [ TabSpecificField.USERS, - TabSpecificField.USER_COUNT, TabSpecificField.DEFAULT_ROLES, TabSpecificField.POLICIES, TabSpecificField.CHILDREN_COUNT, From 912ca638c565cbea2e3d4a572958dd4ec5115b84 Mon Sep 17 00:00:00 2001 From: Ashish Gupta Date: Thu, 19 Dec 2024 12:14:27 +0530 Subject: [PATCH 016/294] 19064: fix the spacing around the input in custom properties right panel (#19132) * fix the spacing around the input in custom properties right panel * remove unwanted css (cherry picked from commit a0c33ccc3f5b4dbb186576143a298f2ce189c2ee) --- .../CustomPropertyTable/PropertyInput.tsx | 2 +- .../CustomPropertyTable/PropertyValue.tsx | 8 ++++---- .../CustomPropertyTable/property-value.less | 1 + .../common/InlineEdit/InlineEdit.component.tsx | 4 +++- .../common/InlineEdit/inline-edit.less | 18 ++++++++++++++++++ 5 files changed, 27 insertions(+), 6 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/InlineEdit/inline-edit.less diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/PropertyInput.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/PropertyInput.tsx index c8c0128ac6a2..1f43636a57b0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/PropertyInput.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/PropertyInput.tsx @@ -48,7 +48,7 @@ export const PropertyInput: FC = ({ = ({ const getPropertyInput = () => { const commonStyle: CSSProperties = { marginBottom: '0px', - minWidth: '250px', + width: '100%', }; switch (propertyType.name) { case 'string': @@ -314,11 +314,11 @@ export const PropertyValue: FC = ({ @@ -359,10 +359,10 @@ export const PropertyValue: FC = ({ @@ -905,7 +905,7 @@ export const PropertyValue: FC = ({ return (
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/property-value.less b/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/property-value.less index fc786b0bdae7..cd625c814427 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/property-value.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/property-value.less @@ -94,5 +94,6 @@ } .custom-property-inline-edit-container { + width: 100%; overflow-x: scroll; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/InlineEdit/InlineEdit.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/InlineEdit/InlineEdit.component.tsx index e7e97214b088..97e203b9fd16 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/InlineEdit/InlineEdit.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/InlineEdit/InlineEdit.component.tsx @@ -14,6 +14,7 @@ import { CheckOutlined, CloseOutlined } from '@ant-design/icons'; import { Button, Space } from 'antd'; import classNames from 'classnames'; import React from 'react'; +import './inline-edit.less'; import { InlineEditProps } from './InlineEdit.interface'; const InlineEdit = ({ @@ -28,7 +29,8 @@ const InlineEdit = ({ }: InlineEditProps) => { return ( Date: Thu, 19 Dec 2024 12:39:44 +0530 Subject: [PATCH 017/294] Cleanup lineage on pipeline and store procedure removal (#19133) (cherry picked from commit f9de2b926ad7048032fd5c222d35445b1bdda7da) --- .../openmetadata/service/jdbi3/CollectionDAO.java | 5 +---- .../service/jdbi3/LineageRepository.java | 2 +- .../service/jdbi3/PipelineRepository.java | 14 ++++++++++++++ .../service/jdbi3/StoredProcedureRepository.java | 13 +++++++++++++ 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java index a423db901cd3..5926e5413af3 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java @@ -1180,10 +1180,7 @@ void deleteLineageBySource( + "AND json->>'source' = :source", connectionType = POSTGRES) void deleteLineageBySourcePipeline( - @BindUUID("toId") UUID toId, - @Bind("toEntity") String toEntity, - @Bind("source") String source, - @Bind("relation") int relation); + @BindUUID("toId") UUID toId, @Bind("source") String source, @Bind("relation") int relation); class FromRelationshipMapper implements RowMapper { @Override diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/LineageRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/LineageRepository.java index 134fe998ec15..9026c4f36daf 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/LineageRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/LineageRepository.java @@ -633,7 +633,7 @@ public void deleteLineageBySource(UUID toId, String toEntity, String source) { .findLineageBySourcePipeline(toId, toEntity, source, Relationship.UPSTREAM.ordinal()); // Finally, delete lineage relationship dao.relationshipDAO() - .deleteLineageBySourcePipeline(toId, toEntity, source, Relationship.UPSTREAM.ordinal()); + .deleteLineageBySourcePipeline(toId, toEntity, Relationship.UPSTREAM.ordinal()); } else { relations = dao.relationshipDAO() diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/PipelineRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/PipelineRepository.java index 8aaf4db08d8f..bab495ad27d0 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/PipelineRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/PipelineRepository.java @@ -42,6 +42,8 @@ import org.openmetadata.schema.type.EntityReference; import org.openmetadata.schema.type.FieldChange; import org.openmetadata.schema.type.Include; +import org.openmetadata.schema.type.LineageDetails; +import org.openmetadata.schema.type.Relationship; import org.openmetadata.schema.type.Status; import org.openmetadata.schema.type.TagLabel; import org.openmetadata.schema.type.Task; @@ -296,6 +298,18 @@ public void storeEntity(Pipeline pipeline, boolean update) { pipeline.withService(service).withTasks(taskWithTagsAndOwners); } + @Override + protected void cleanup(Pipeline pipeline) { + // When a pipeline is removed , the linege needs to be removed + daoCollection + .relationshipDAO() + .deleteLineageBySourcePipeline( + pipeline.getId(), + LineageDetails.Source.PIPELINE_LINEAGE.value(), + Relationship.UPSTREAM.ordinal()); + super.cleanup(pipeline); + } + @Override public void storeRelationships(Pipeline pipeline) { addServiceRelationship(pipeline, pipeline.getService()); 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 60ad7db78d23..11624a475808 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 @@ -11,6 +11,7 @@ import org.openmetadata.schema.entity.data.StoredProcedure; import org.openmetadata.schema.type.EntityReference; import org.openmetadata.schema.type.Include; +import org.openmetadata.schema.type.LineageDetails; import org.openmetadata.schema.type.Relationship; import org.openmetadata.service.Entity; import org.openmetadata.service.resources.databases.StoredProcedureResource; @@ -69,6 +70,18 @@ public void storeRelationships(StoredProcedure storedProcedure) { Relationship.CONTAINS); } + @Override + protected void cleanup(StoredProcedure storedProcedure) { + // When a pipeline is removed , the linege needs to be removed + daoCollection + .relationshipDAO() + .deleteLineageBySourcePipeline( + storedProcedure.getId(), + LineageDetails.Source.QUERY_LINEAGE.value(), + Relationship.UPSTREAM.ordinal()); + super.cleanup(storedProcedure); + } + @Override public void setInheritedFields(StoredProcedure storedProcedure, EntityUtil.Fields fields) { DatabaseSchema schema = From 3add5f90f47d7b7118328edb3125c500b54ae1b9 Mon Sep 17 00:00:00 2001 From: Pranita Fulsundar Date: Wed, 18 Dec 2024 14:40:10 +0530 Subject: [PATCH 018/294] feat(ui): highlight search term for schema table in table details page (#19110) * feat: highlight search term * refactor: use stringToHTML instead of parse * test: highlightSearchText method * fix: test for schema table * fix: mock implemetation of stringToHTML * test: highlightSearchText function with null or falsy parameters (cherry picked from commit 31b11323896e9046d79e66dc3921dbccd79dfcff) --- .../SchemaTable/SchemaTable.component.tsx | 26 ++++----- .../Database/SchemaTable/SchemaTable.test.tsx | 10 ++++ .../ui/src/utils/EntityUtils.test.tsx | 56 +++++++++++++++++++ .../resources/ui/src/utils/EntityUtils.tsx | 13 +++++ .../ui/src/utils/mocks/EntityUtils.mock.ts | 8 +++ 5 files changed, 100 insertions(+), 13 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/SchemaTable/SchemaTable.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/SchemaTable/SchemaTable.component.tsx index 3b5fb3b97c62..a4c9abf53026 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/SchemaTable/SchemaTable.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/SchemaTable/SchemaTable.component.tsx @@ -23,7 +23,6 @@ import { isUndefined, set, sortBy, - toLower, uniqBy, } from 'lodash'; import { EntityTags, TagFilterOptions } from 'Models'; @@ -56,9 +55,11 @@ import { getColumnSorter, getEntityName, getFrequentlyJoinedColumns, + highlightSearchText, searchInColumns, } from '../../../utils/EntityUtils'; import { getEntityColumnFQN } from '../../../utils/FeedUtils'; +import { stringToHTML } from '../../../utils/StringsUtils'; import { getAllTags, searchTagInData, @@ -246,15 +247,12 @@ const SchemaTable = ({ return NO_DATA_PLACEHOLDER; } - return isReadOnly || - (displayValue && displayValue.length < 25 && !isReadOnly) ? ( - toLower(displayValue) - ) : ( - - - {displayValue} - - + return ( + + {stringToHTML(highlightSearchText(displayValue, searchText))} + ); }; @@ -268,7 +266,7 @@ const SchemaTable = ({ - {name} + {stringToHTML(highlightSearchText(name, searchText))}
{!isEmpty(displayName) ? ( @@ -386,7 +384,9 @@ const SchemaTable = ({ - {getEntityName(record)} + {stringToHTML( + highlightSearchText(getEntityName(record), searchText) + )} ) : null} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/SchemaTable/SchemaTable.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/SchemaTable/SchemaTable.test.tsx index 1e49687b4a50..401a7328534f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/SchemaTable/SchemaTable.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/SchemaTable/SchemaTable.test.tsx @@ -167,6 +167,16 @@ jest.mock('../../../constants/Table.constants', () => ({ }, })); +jest.mock('../../../utils/StringsUtils', () => ({ + ...jest.requireActual('../../../utils/StringsUtils'), + stringToHTML: jest.fn((text) => text), +})); + +jest.mock('../../../utils/EntityUtils', () => ({ + ...jest.requireActual('../../../utils/EntityUtils'), + highlightSearchText: jest.fn((text) => text), +})); + describe('Test EntityTable Component', () => { it('Initially, Table should load', async () => { render(, { diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/EntityUtils.test.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/EntityUtils.test.tsx index 264482fe8fcc..757e69de9f71 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/EntityUtils.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/EntityUtils.test.tsx @@ -23,12 +23,16 @@ import { getEntityLinkFromType, getEntityOverview, highlightEntityNameAndDescription, + highlightSearchText, } from './EntityUtils'; import { entityWithoutNameAndDescHighlight, highlightedEntityDescription, highlightedEntityDisplayName, + mockHighlightedResult, mockHighlights, + mockSearchText, + mockText, } from './mocks/EntityUtils.mock'; jest.mock('../constants/constants', () => ({ @@ -199,4 +203,56 @@ describe('EntityUtils unit tests', () => { expect(sorter(item2, item1)).toBe(1); }); }); + + describe('highlightSearchText method', () => { + it('should return the text with highlighted search text', () => { + const result = highlightSearchText(mockText, mockSearchText); + + expect(result).toBe(mockHighlightedResult); + }); + + it('should return the original text if searchText is not found', () => { + const result = highlightSearchText(mockText, 'nonexistent'); + + expect(result).toBe(mockText); + }); + + it('should return an empty string if no text is provided', () => { + const result = highlightSearchText('', 'test'); + + expect(result).toBe(''); + }); + + it('should return an empty string if no searchText is provided', () => { + const result = highlightSearchText(mockText, ''); + + expect(result).toBe(mockText); + }); + + it('should return empty string if both text and searchText are missing', () => { + const result = highlightSearchText('', ''); + + expect(result).toBe(''); + }); + + const falsyTestCases = [ + { text: null, searchText: 'test', expected: '' }, + { text: 'mockText', searchText: null, expected: 'mockText' }, + { text: null, searchText: null, expected: '' }, + { text: 0 as any, searchText: '', expected: 0 }, + { text: false as any, searchText: '', expected: false }, + ]; + + it.each(falsyTestCases)( + 'should return expected when text or searchText is null or falsy', + ({ text, searchText, expected }) => { + const result = highlightSearchText( + text ?? undefined, + searchText ?? undefined + ); + + expect(result).toBe(expected); + } + ); + }); }); 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 6740c1785118..7a4eb3fc908d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/EntityUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/EntityUtils.tsx @@ -2540,3 +2540,16 @@ export const getColumnSorter = (field: K) => { return 0; }; }; + +export const highlightSearchText = ( + text?: string, + searchText?: string +): string => { + if (!searchText || !text) { + return text ?? ''; + } + + const regex = new RegExp(`(${searchText})`, 'gi'); + + return text.replace(regex, `$1`); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/mocks/EntityUtils.mock.ts b/openmetadata-ui/src/main/resources/ui/src/utils/mocks/EntityUtils.mock.ts index aaea1f5e86a5..295ef7a91681 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/mocks/EntityUtils.mock.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/mocks/EntityUtils.mock.ts @@ -136,3 +136,11 @@ export const mockHighlights = { export const highlightedEntityDescription = `This dimension table contains the billing and shipping addresses of customers.`; export const highlightedEntityDisplayName = `dim_address`; + +export const mockText = + 'This is a test description to verify highlightText method.'; + +export const mockSearchText = 'test'; + +export const mockHighlightedResult = + 'This is a test description to verify highlightText method.'; From e0fde37e9392b7f65c51c14e6fc37945391ae323 Mon Sep 17 00:00:00 2001 From: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> Date: Mon, 16 Dec 2024 21:02:16 +0530 Subject: [PATCH 019/294] fix(ui): sync search value with url and state value (#19050) * fix(ui): sync search value with url and state value * fix flaky for user spec (cherry picked from commit 4d30c83c55b3b9aa8d18e9716567175b40039712) --- .../src/main/resources/ui/playwright/utils/user.ts | 8 +++++++- .../ui/src/pages/UserListPage/UserListPageV1.tsx | 6 ++++-- 2 files changed, 11 insertions(+), 3 deletions(-) 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 eaee1add4451..20926d5b9576 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/user.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/user.ts @@ -97,6 +97,12 @@ export const deletedUserChecks = async (page: Page) => { export const visitUserProfilePage = async (page: Page, userName: string) => { await settingClick(page, GlobalSettingOptions.USERS); + await page.waitForSelector( + '[data-testid="user-list-v1-component"] [data-testid="loader"]', + { + state: 'detached', + } + ); const userResponse = page.waitForResponse( '/api/v1/search/query?q=**&from=0&size=*&index=*' ); @@ -470,9 +476,9 @@ export const permanentDeleteUser = async ( ); await page.click('[data-testid="confirm-button"]'); await hardDeleteUserResponse; - await reFetchUsers; await toastNotification(page, `"${displayName}" deleted successfully!`); + await reFetchUsers; // Wait for the loader to disappear await page.waitForSelector('[data-testid="loader"]', { state: 'hidden' }); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/UserListPage/UserListPageV1.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/UserListPage/UserListPageV1.tsx index 1833734de7d5..08957e5818bc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/UserListPage/UserListPageV1.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/UserListPage/UserListPageV1.tsx @@ -14,7 +14,7 @@ import { Button, Col, Modal, Row, Space, Switch, Tooltip } from 'antd'; import { ColumnsType } from 'antd/lib/table'; import { AxiosError } from 'axios'; -import { capitalize, isEmpty } from 'lodash'; +import { capitalize, debounce, isEmpty } from 'lodash'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useHistory, useParams } from 'react-router-dom'; @@ -235,6 +235,7 @@ const UserListPageV1 = () => { const handleSearch = (value: string) => { setSearchValue(value); handlePageChange(INITIAL_PAGING_VALUE); + const params = new URLSearchParams({ user: value }); // This function is called onChange in the search input with debouncing // Hence using history.replace instead of history.push to avoid adding multiple routes in history @@ -459,7 +460,8 @@ const UserListPageV1 = () => { type: t('label.user'), })}...`} searchValue={searchValue} - onSearch={handleSearch} + typingInterval={0} + onSearch={debounce(handleSearch, 400)} /> From 1f59cec66ebfc2b9203d83f43d79f62200fca412 Mon Sep 17 00:00:00 2001 From: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> Date: Mon, 16 Dec 2024 22:08:54 +0530 Subject: [PATCH 020/294] fix(ui): expand invalid state upon glossary term add (#18968) * fix(ui): expand invalid state upon glossary term add invalid expand state for glossary term update * fix tests * update glossary store upon changes in tree * fix tests (cherry picked from commit 254fce41385e0f7debfd8e6210496f7567b3d1a1) --- .../GlossaryTermTab.component.tsx | 5 +- .../Glossary/GlossaryV1.component.tsx | 59 +++++--- .../components/Glossary/useGlossary.store.ts | 11 ++ .../main/resources/ui/src/rest/glossaryAPI.ts | 2 +- .../ui/src/utils/GlossaryUtils.test.ts | 136 ++++++++++++++++++ .../resources/ui/src/utils/GlossaryUtils.tsx | 32 +++++ 6 files changed, 221 insertions(+), 24 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx index 1e69fa0736fb..147f3dff4c19 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx @@ -106,7 +106,10 @@ const GlossaryTermTab = ({ useGlossaryStore(); const { t } = useTranslation(); - const glossaryTerms = (glossaryChildTerms as ModifiedGlossaryTerm[]) ?? []; + const glossaryTerms = useMemo( + () => (glossaryChildTerms as ModifiedGlossaryTerm[]) ?? [], + [glossaryChildTerms] + ); const [movedGlossaryTerm, setMovedGlossaryTerm] = useState(); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.component.tsx index c92528e87b93..a3c165428aa1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.component.tsx @@ -98,8 +98,12 @@ const GlossaryV1 = ({ const [editMode, setEditMode] = useState(false); - const { activeGlossary, glossaryChildTerms, setGlossaryChildTerms } = - useGlossaryStore(); + const { + activeGlossary, + glossaryChildTerms, + setGlossaryChildTerms, + insertNewGlossaryTermToChildTerms, + } = useGlossaryStore(); const { id, fullyQualifiedName } = activeGlossary ?? {}; @@ -128,11 +132,9 @@ const GlossaryV1 = ({ const { data } = await getFirstLevelGlossaryTerms( params?.glossary ?? params?.parent ?? '' ); - const children = data.map((data) => - data.childrenCount ?? 0 > 0 ? { ...data, children: [] } : data - ); - - setGlossaryChildTerms(children as ModifiedGlossary[]); + // We are considering childrenCount fot expand collapse state + // Hence don't need any intervention to list response here + setGlossaryChildTerms(data as ModifiedGlossary[]); } catch (error) { showErrorToast(error as AxiosError); } finally { @@ -231,7 +233,11 @@ const GlossaryV1 = ({ entity: t('label.glossary-term'), }); } else { - updateGlossaryTermInStore(response); + updateGlossaryTermInStore({ + ...response, + // Since patch didn't respond with childrenCount preserve it from currentData + childrenCount: currentData.childrenCount, + }); setIsEditModalOpen(false); } } catch (error) { @@ -256,29 +262,38 @@ const GlossaryV1 = ({ } }; - const onTermModalSuccess = useCallback(() => { - loadGlossaryTerms(true); - if (!isGlossaryActive && tab !== 'terms') { - history.push( - getGlossaryTermDetailsPath( - selectedData.fullyQualifiedName || '', - 'terms' - ) - ); - } - setIsEditModalOpen(false); - }, [isGlossaryActive, tab, selectedData]); + const onTermModalSuccess = useCallback( + (term: GlossaryTerm) => { + // Setting loading so that nested terms are rendered again on table with change + setIsTermsLoading(true); + // Update store with newly created term + insertNewGlossaryTermToChildTerms(term); + if (!isGlossaryActive && tab !== 'terms') { + history.push( + getGlossaryTermDetailsPath( + selectedData.fullyQualifiedName || '', + 'terms' + ) + ); + } + // Close modal and set loading to false + setIsEditModalOpen(false); + setIsTermsLoading(false); + }, + [isGlossaryActive, tab, selectedData] + ); const handleGlossaryTermAdd = async (formData: GlossaryTermForm) => { try { - await addGlossaryTerm({ + const term = await addGlossaryTerm({ ...formData, glossary: activeGlossaryTerm?.glossary?.name || (selectedData.fullyQualifiedName ?? ''), parent: activeGlossaryTerm?.fullyQualifiedName, }); - onTermModalSuccess(); + + onTermModalSuccess(term); } catch (error) { if ( (error as AxiosError).response?.status === HTTP_STATUS_CODE.CONFLICT diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/useGlossary.store.ts b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/useGlossary.store.ts index 81ec1d9bef89..f8799cb02a13 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/useGlossary.store.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/useGlossary.store.ts @@ -12,7 +12,9 @@ */ import { create } from 'zustand'; import { Glossary } from '../../generated/entity/data/glossary'; +import { GlossaryTerm } from '../../generated/entity/data/glossaryTerm'; import { GlossaryTermWithChildren } from '../../rest/glossaryAPI'; +import { findAndUpdateNested } from '../../utils/GlossaryUtils'; export type ModifiedGlossary = Glossary & { children?: GlossaryTermWithChildren[]; @@ -27,6 +29,7 @@ export const useGlossaryStore = create<{ updateGlossary: (glossary: Glossary) => void; updateActiveGlossary: (glossary: Partial) => void; setGlossaryChildTerms: (glossaryChildTerms: ModifiedGlossary[]) => void; + insertNewGlossaryTermToChildTerms: (glossary: GlossaryTerm) => void; }>()((set, get) => ({ glossaries: [], activeGlossary: {} as ModifiedGlossary, @@ -67,6 +70,14 @@ export const useGlossaryStore = create<{ glossaries[index] = updatedGlossary; } }, + insertNewGlossaryTermToChildTerms: (glossary: GlossaryTerm) => { + const { glossaryChildTerms } = get(); + + // Typically used to updated the glossary term list in the glossary page + set({ + glossaryChildTerms: findAndUpdateNested(glossaryChildTerms, glossary), + }); + }, setGlossaryChildTerms: (glossaryChildTerms: ModifiedGlossary[]) => { set({ glossaryChildTerms }); }, diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/glossaryAPI.ts b/openmetadata-ui/src/main/resources/ui/src/rest/glossaryAPI.ts index 72d817f816e0..2b6a4db5cd0f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/glossaryAPI.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/glossaryAPI.ts @@ -147,7 +147,7 @@ export const getGlossaryTermByFQN = async (fqn = '', params?: ListParams) => { export const addGlossaryTerm = ( data: CreateGlossaryTerm -): Promise => { +): Promise => { const url = '/glossaryTerms'; return APIClient.post(url, data); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.test.ts index bea6c81ae092..11263c62500a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.test.ts @@ -11,8 +11,10 @@ * limitations under the License. */ import { ModifiedGlossaryTerm } from '../components/Glossary/GlossaryTermTab/GlossaryTermTab.interface'; +import { ModifiedGlossary } from '../components/Glossary/useGlossary.store'; import { EntityType } from '../enums/entity.enum'; import { Glossary } from '../generated/entity/data/glossary'; +import { GlossaryTerm } from '../generated/entity/data/glossaryTerm'; import { MOCKED_GLOSSARY_TERMS, MOCKED_GLOSSARY_TERMS_1, @@ -22,6 +24,7 @@ import { import { buildTree, filterTreeNodeOptions, + findAndUpdateNested, findExpandableKeys, findExpandableKeysForArray, getQueryFilterToExcludeTerm, @@ -219,3 +222,136 @@ describe('Glossary Utils', () => { expect(filteredOptions).toEqual(expected_glossary); }); }); + +describe('findAndUpdateNested', () => { + it('should add new term to the correct parent', () => { + const terms: ModifiedGlossary[] = [ + { + fullyQualifiedName: 'parent1', + children: [], + id: 'parent1', + name: 'parent1', + description: 'parent1', + }, + { + fullyQualifiedName: 'parent2', + children: [], + id: 'parent2', + name: 'parent2', + description: 'parent2', + }, + ]; + + const newTerm: GlossaryTerm = { + fullyQualifiedName: 'child1', + parent: { + fullyQualifiedName: 'parent1', + id: 'parent1', + type: 'Glossary', + }, + id: 'child1', + name: 'child1', + description: 'child1', + glossary: { + fullyQualifiedName: 'child1', + id: 'child1', + name: 'child1', + description: 'child1', + type: 'Glossary', + }, + }; + + const updatedTerms = findAndUpdateNested(terms, newTerm); + + expect(updatedTerms[0].children).toHaveLength(1); + expect(updatedTerms?.[0].children?.[0]).toEqual(newTerm); + }); + + it('should add new term to nested parent', () => { + const terms: ModifiedGlossary[] = [ + { + fullyQualifiedName: 'parent1', + children: [ + { + fullyQualifiedName: 'child1', + children: [], + glossary: { + fullyQualifiedName: 'child1', + id: 'child1', + name: 'child1', + description: 'child1', + type: 'Glossary', + }, + id: 'child1', + name: 'child1', + description: 'child1', + }, + ], + id: 'parent1', + name: 'parent1', + description: 'parent1', + }, + ]; + + const newTerm: GlossaryTerm = { + fullyQualifiedName: 'child2', + parent: { fullyQualifiedName: 'child1', id: 'child1', type: 'Glossary' }, + id: 'child2', + name: 'child2', + description: 'child2', + glossary: { + fullyQualifiedName: 'child2', + id: 'child2', + name: 'child2', + description: 'child2', + type: 'Glossary', + }, + }; + + const updatedTerms = findAndUpdateNested(terms, newTerm); + + expect( + updatedTerms?.[0].children && updatedTerms?.[0].children[0].children + ).toHaveLength(1); + expect( + updatedTerms?.[0].children && + updatedTerms?.[0].children[0].children && + updatedTerms?.[0].children[0].children[0] + ).toEqual(newTerm); + }); + + it('should not modify terms if parent is not found', () => { + const terms: ModifiedGlossary[] = [ + { + fullyQualifiedName: 'parent1', + children: [], + id: 'parent1', + name: 'parent1', + description: 'parent1', + }, + ]; + + const newTerm: GlossaryTerm = { + fullyQualifiedName: 'child1', + parent: { + fullyQualifiedName: 'nonexistent', + id: 'nonexistent', + type: 'Glossary', + }, + id: 'child1', + name: 'child1', + description: 'child1', + glossary: { + fullyQualifiedName: 'child1', + id: 'child1', + name: 'child1', + description: 'child1', + type: 'Glossary', + }, + }; + + const updatedTerms = findAndUpdateNested(terms, newTerm); + + expect(updatedTerms).toEqual(terms); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.tsx index b95492e8bf39..96c553405090 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.tsx @@ -334,3 +334,35 @@ export const filterTreeNodeOptions = ( return filterNodes(options as ModifiedGlossaryTerm[]); }; + +export const findAndUpdateNested = ( + terms: ModifiedGlossary[], + newTerm: GlossaryTerm +): ModifiedGlossary[] => { + // If new term has no parent, it's a top level term + // So just update 0 level terms no need to iterate over it + if (!newTerm.parent) { + return [...terms, newTerm as ModifiedGlossary]; + } + + // If parent is there means term is created within a term + // So we need to find the parent term and update it's children + return terms.map((term) => { + if (term.fullyQualifiedName === newTerm.parent?.fullyQualifiedName) { + return { + ...term, + children: [...(term.children || []), newTerm] as GlossaryTerm[], + } as ModifiedGlossary; + } else if ('children' in term && term.children?.length) { + return { + ...term, + children: findAndUpdateNested( + term.children as ModifiedGlossary[], + newTerm + ), + } as ModifiedGlossary; + } + + return term; + }); +}; From aa5b6ca9390d278c563713b0802fe537745d5f67 Mon Sep 17 00:00:00 2001 From: Ashish Gupta Date: Thu, 19 Dec 2024 22:45:45 +0530 Subject: [PATCH 021/294] fix table page breaking due to highligther text (#19146) (cherry picked from commit 7d962d91eb9e1ad48a1be89e4b9b54e33011ee24) --- .../playwright/support/entity/TableClass.ts | 9 +- .../SchemaTable/SchemaTable.component.tsx | 3 +- .../ui/src/utils/EntityUtils.test.tsx | 100 ++++++++++++++++++ .../resources/ui/src/utils/EntityUtils.tsx | 29 ++++- .../ui/src/utils/mocks/EntityUtils.mock.ts | 2 +- 5 files changed, 136 insertions(+), 7 deletions(-) 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 29ac8d6490cb..bd40908dceb3 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 @@ -77,16 +77,17 @@ export class TableClass extends EntityClass { children: [ { name: 'first_name', - dataType: 'VARCHAR', + dataType: 'STRUCT', dataLength: 100, - dataTypeDisplay: 'varchar', + dataTypeDisplay: + 'struct', description: 'First name of the staff member.', }, { name: 'last_name', - dataType: 'VARCHAR', + dataType: 'ARRAY', dataLength: 100, - dataTypeDisplay: 'varchar', + dataTypeDisplay: 'array>>', }, ], }, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/SchemaTable/SchemaTable.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/SchemaTable/SchemaTable.component.tsx index a4c9abf53026..bbdafcb16071 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/SchemaTable/SchemaTable.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/SchemaTable/SchemaTable.component.tsx @@ -55,6 +55,7 @@ import { getColumnSorter, getEntityName, getFrequentlyJoinedColumns, + highlightSearchArrayElement, highlightSearchText, searchInColumns, } from '../../../utils/EntityUtils'; @@ -251,7 +252,7 @@ const SchemaTable = ({ - {stringToHTML(highlightSearchText(displayValue, searchText))} + {highlightSearchArrayElement(dataTypeDisplay, searchText)} ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/EntityUtils.test.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/EntityUtils.test.tsx index 757e69de9f71..b0e77f33a64c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/EntityUtils.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/EntityUtils.test.tsx @@ -10,6 +10,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { render } from '@testing-library/react'; +import React from 'react'; import { getEntityDetailsPath } from '../constants/constants'; import { EntityTabs, EntityType } from '../enums/entity.enum'; import { ExplorePageTabs } from '../enums/Explore.enum'; @@ -23,6 +25,7 @@ import { getEntityLinkFromType, getEntityOverview, highlightEntityNameAndDescription, + highlightSearchArrayElement, highlightSearchText, } from './EntityUtils'; import { @@ -255,4 +258,101 @@ describe('EntityUtils unit tests', () => { } ); }); + + describe('highlightSearchArrayElement method', () => { + it('should highlight the searchText in the text', () => { + const result = highlightSearchArrayElement(mockText, 'highlightText'); + const { container } = render(<>{result}); // Render the result to check JSX output + + // Check if the correct part of the text is wrapped in a with the correct class + const highlighted = container.querySelector('.text-highlighter'); + + expect(highlighted).toBeInTheDocument(); + expect(highlighted?.textContent).toBe('highlightText'); + }); + + it('should highlight multiple occurrences of the searchText', () => { + const result = highlightSearchArrayElement( + 'Data testing environment, Manually test data', + 'data' + ); + const { container } = render(<>{result}); + + // Check that there are two highlighted parts (one for each 'hello') + const highlightedElements = + container.querySelectorAll('.text-highlighter'); + + expect(highlightedElements).toHaveLength(2); + expect(highlightedElements[0].textContent).toBe('Data'); + expect(highlightedElements[1].textContent).toBe('data'); + }); + + it('should not modify parts of the text that do not match searchText', () => { + const result = highlightSearchArrayElement(mockText, 'highlightText'); + const { container } = render(<>{result}); + + // Ensure the non-matching part is plain text + const nonHighlighted = container.textContent; + + expect(nonHighlighted).toContain('description'); + }); + + it('should not wrap searchText in the result if it does not appear in text', () => { + const result = highlightSearchArrayElement(mockText, 'foo'); + const { container } = render(<>{result}); + + // Ensure that no parts of the text are highlighted + const highlighted = container.querySelector('.text-highlighter'); + + expect(highlighted).toBeNull(); + }); + + it('should handle case-insensitive search', () => { + const result = highlightSearchArrayElement(mockText, 'HighlightText'); + const { container } = render(<>{result}); + + const highlighted = container.querySelector('.text-highlighter'); + + expect(highlighted).toBeInTheDocument(); + expect(highlighted?.textContent).toBe('highlightText'); + }); + + it('should return an empty string if no text is provided', () => { + const result = highlightSearchArrayElement('', 'test'); + + expect(result).toBe(''); + }); + + it('should return an empty string if no searchText is provided', () => { + const result = highlightSearchArrayElement(mockText, ''); + + expect(result).toBe(mockText); + }); + + it('should return empty string if both text and searchText are missing', () => { + const result = highlightSearchArrayElement('', ''); + + expect(result).toBe(''); + }); + + const falsyTestCases = [ + { text: null, searchText: 'test', expected: '' }, + { text: 'mockText', searchText: null, expected: 'mockText' }, + { text: null, searchText: null, expected: '' }, + { text: 0 as any, searchText: '', expected: 0 }, + { text: false as any, searchText: '', expected: false }, + ]; + + it.each(falsyTestCases)( + 'should return expected when text or searchText is null or falsy', + ({ text, searchText, expected }) => { + const result = highlightSearchArrayElement( + text ?? undefined, + searchText ?? undefined + ); + + expect(result).toBe(expected); + } + ); + }); }); 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 7a4eb3fc908d..1a613534a46a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/EntityUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/EntityUtils.tsx @@ -2551,5 +2551,32 @@ export const highlightSearchText = ( const regex = new RegExp(`(${searchText})`, 'gi'); - return text.replace(regex, `$1`); + return text.replace(regex, `$1`); +}; + +/** + * It searches for a given text in a given string and returns an array that contains the string parts that have + * highlighted element if match found. + * @param text - The text to search in. + * @param searchText - The text to search for. + * @returns An Array of string or JSX.Element which contains highlighted element. + */ +export const highlightSearchArrayElement = ( + text?: string, + searchText?: string +): string | (string | JSX.Element)[] => { + if (!searchText || !text) { + return text ?? ''; + } + const stringParts = text.split(new RegExp(`(${searchText})`, 'gi')); + + return stringParts.map((part, index) => + part.toLowerCase() === (searchText ?? '').toLowerCase() ? ( + + {part} + + ) : ( + part + ) + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/mocks/EntityUtils.mock.ts b/openmetadata-ui/src/main/resources/ui/src/utils/mocks/EntityUtils.mock.ts index 295ef7a91681..7d6a4a520e7d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/mocks/EntityUtils.mock.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/mocks/EntityUtils.mock.ts @@ -143,4 +143,4 @@ export const mockText = export const mockSearchText = 'test'; export const mockHighlightedResult = - 'This is a test description to verify highlightText method.'; + 'This is a test description to verify highlightText method.'; From d5360073c63c7654f7db59c824f35eec873a6733 Mon Sep 17 00:00:00 2001 From: Karan Hotchandani <33024356+karanh37@users.noreply.github.com> Date: Thu, 19 Dec 2024 23:05:55 +0530 Subject: [PATCH 022/294] Fix flaky e2e tests (#19038) * fix lineage flaky tests * fix glossary flakiness (cherry picked from commit 9a76b07025fade6285ddcd45819fa93c40fe6806) --- .../src/main/resources/ui/playwright/utils/glossary.ts | 2 ++ .../src/main/resources/ui/playwright/utils/lineage.ts | 7 +++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts index d694d659f3db..361e19fc0aa0 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts @@ -1071,7 +1071,9 @@ export const approveTagsTask = async ( await taskResolve; await redirectToHomePage(page); + const glossaryTermsResponse = page.waitForResponse('/api/v1/glossaryTerms*'); await sidebarClick(page, SidebarItem.GLOSSARY); + await glossaryTermsResponse; await selectActiveGlossary(page, entity.data.displayName); const tagVisibility = await page.isVisible( 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 1c8f2cf5aa69..9dcdbb85e250 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/lineage.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/lineage.ts @@ -133,10 +133,9 @@ export const dragAndDropNode = async ( await page.hover(originSelector); await page.mouse.down(); const box = (await destinationElement.boundingBox())!; - const x = (box.x + box.width / 2) * 0.25; // 0.25 as zoom factor - const y = (box.y + box.height / 2) * 0.25; // 0.25 as zoom factor - await page.mouse.move(x, y); - await destinationElement.hover(); + const x = box.x + 250; + const y = box.y + box.height / 2; + await page.mouse.move(x, y, { steps: 20 }); await page.mouse.up(); }; From ae4658f85de20b78f16f22ae2e236fa0f85a656d Mon Sep 17 00:00:00 2001 From: IceS2 Date: Thu, 19 Dec 2024 10:20:21 +0100 Subject: [PATCH 023/294] MINOR: Fix Table constraint relationships for SoftDeleted entities. (#19129) * Update TableRepository.java * Fix Checkstyle --- .../java/org/openmetadata/service/jdbi3/TableRepository.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TableRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TableRepository.java index 0247bf0b7cdb..308b7adbd8e4 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TableRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TableRepository.java @@ -1138,7 +1138,7 @@ private void validateTableConstraints(Table table) { String toParent = FullyQualifiedName.getParentFQN(column); String columnName = FullyQualifiedName.getColumnName(column); try { - Table toTable = findByName(toParent, NON_DELETED); + Table toTable = findByName(toParent, ALL); validateColumn(toTable, columnName); } catch (EntityNotFoundException e) { throw new EntitySpecViolationException("Table not found: " + toParent); @@ -1232,8 +1232,7 @@ private void addConstraintRelationship(Table table, List constr for (String column : constraint.getReferredColumns()) { String toParent = FullyQualifiedName.getParentFQN(column); try { - EntityReference toTable = - Entity.getEntityReferenceByName(TABLE, toParent, NON_DELETED); + EntityReference toTable = Entity.getEntityReferenceByName(TABLE, toParent, ALL); addRelationship( table.getId(), toTable.getId(), TABLE, TABLE, Relationship.RELATED_TO); } catch (EntityNotFoundException e) { From 11796977c12a45e76146df6bad4f5ffe66b3c6fc Mon Sep 17 00:00:00 2001 From: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> Date: Thu, 19 Dec 2024 18:58:06 +0530 Subject: [PATCH 024/294] chore(ui): improve data insight chart and tooltip rendering (#19143) --- .../DataInsight/TotalEntityInsightSummary.component.tsx | 4 ++-- .../resources/ui/src/interface/data-insight.interface.ts | 1 + .../src/main/resources/ui/src/utils/DataInsightUtils.tsx | 5 ++++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataInsight/TotalEntityInsightSummary.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataInsight/TotalEntityInsightSummary.component.tsx index 446270600c57..0ae910cd95d5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataInsight/TotalEntityInsightSummary.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataInsight/TotalEntityInsightSummary.component.tsx @@ -13,7 +13,7 @@ import { Button, Col, Row } from 'antd'; import { Gutter } from 'antd/lib/grid/row'; import classNames from 'classnames'; -import { includes, toLower } from 'lodash'; +import { includes, startCase, toLower } from 'lodash'; import React, { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { updateActiveChartFilter } from '../../utils/ChartUtils'; @@ -114,7 +114,7 @@ const TotalEntityInsightSummary = ({ onMouseEnter={() => handleLegendMouseEnter(entity)} onMouseLeave={handleLegendMouseLeave}> { dateTimeFormatter?: (date?: number) => string; valueFormatter?: (value: number | string, key?: string) => string | number; timeStampKey?: string; + transformLabel?: boolean; } export interface UIKpiResult extends KpiResult { diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/DataInsightUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/DataInsightUtils.tsx index 297c08e425bf..7d0dca2fc50d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/DataInsightUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/DataInsightUtils.tsx @@ -149,6 +149,7 @@ export const CustomTooltip = (props: DataInsightChartTooltipProps) => { dateTimeFormatter = formatDate, isPercentage, timeStampKey = 'timestampValue', + transformLabel = true, } = props; if (active && payload && payload.length) { @@ -172,7 +173,9 @@ export const CustomTooltip = (props: DataInsightChartTooltipProps) => { - {startCase(entry.name ?? (entry.dataKey as string))} + {transformLabel + ? startCase(entry.name ?? (entry.dataKey as string)) + : entry.name ?? (entry.dataKey as string)} {valueFormatter From a0e755731de2f3698a71608662031a737681e08a Mon Sep 17 00:00:00 2001 From: Siddhant <86899184+Siddhanttimeline@users.noreply.github.com> Date: Thu, 19 Dec 2024 22:21:26 +0530 Subject: [PATCH 025/294] fix: Encode userName and correct placeholder for userResetPasswordLink (#19139) --- .../openmetadata/service/security/auth/BasicAuthenticator.java | 2 +- .../java/org/openmetadata/service/util/email/EmailUtil.java | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/BasicAuthenticator.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/BasicAuthenticator.java index 9d33d87927d1..a46809417aa3 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/BasicAuthenticator.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/BasicAuthenticator.java @@ -184,7 +184,7 @@ public void sendEmailVerification(UriInfo uriInfo, User user) throws IOException String.format( "%s/users/registrationConfirmation?user=%s&token=%s", getSmtpSettings().getOpenMetadataUrl(), - user.getFullyQualifiedName(), + URLEncoder.encode(user.getFullyQualifiedName(), StandardCharsets.UTF_8), mailVerificationToken); try { EmailUtil.sendEmailVerification(emailVerificationLink, user); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/util/email/EmailUtil.java b/openmetadata-service/src/main/java/org/openmetadata/service/util/email/EmailUtil.java index ee3ab278f213..1cdfbac8010e 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/util/email/EmailUtil.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/util/email/EmailUtil.java @@ -30,6 +30,7 @@ import static org.openmetadata.service.util.email.TemplateConstants.INVITE_RANDOM_PASSWORD_TEMPLATE; import static org.openmetadata.service.util.email.TemplateConstants.INVITE_SUBJECT; import static org.openmetadata.service.util.email.TemplateConstants.PASSWORD; +import static org.openmetadata.service.util.email.TemplateConstants.PASSWORD_RESET_LINKKEY; import static org.openmetadata.service.util.email.TemplateConstants.PASSWORD_RESET_SUBJECT; import static org.openmetadata.service.util.email.TemplateConstants.REPORT_SUBJECT; import static org.openmetadata.service.util.email.TemplateConstants.SUPPORT_URL; @@ -179,7 +180,7 @@ public static void sendPasswordResetLink( .add(ENTITY, getSmtpSettings().getEmailingEntity()) .add(SUPPORT_URL, getSmtpSettings().getSupportUrl()) .add(USERNAME, user.getName()) - .add(EMAIL_VERIFICATION_LINKKEY, passwordResetLink) + .add(PASSWORD_RESET_LINKKEY, passwordResetLink) .add(EXPIRATION_TIME_KEY, DEFAULT_EXPIRATION_TIME) .build(); From 88bbddb6da2ccccc882dd1f159fa97060fe881da Mon Sep 17 00:00:00 2001 From: Mohit Yadav <105265192+mohityadav766@users.noreply.github.com> Date: Mon, 16 Dec 2024 15:55:12 +0530 Subject: [PATCH 026/294] Search Index read entities from index mapping (#19084) (cherry picked from commit 2888e379984d1342f00b86c81ff15dca2cb0c297) --- .../bundles/searchIndex/SearchIndexApp.java | 89 +------------------ .../service/search/SearchRepository.java | 5 ++ .../SearchIndexingApplication.json | 62 ++++++------- 3 files changed, 40 insertions(+), 116 deletions(-) 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 fd34c2027f5c..1bb8cb283618 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 @@ -1,47 +1,8 @@ package org.openmetadata.service.apps.bundles.searchIndex; import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; -import static org.openmetadata.service.Entity.API_COLLCECTION; -import static org.openmetadata.service.Entity.API_ENDPOINT; -import static org.openmetadata.service.Entity.API_SERVICE; -import static org.openmetadata.service.Entity.CHART; -import static org.openmetadata.service.Entity.CLASSIFICATION; -import static org.openmetadata.service.Entity.CONTAINER; -import static org.openmetadata.service.Entity.DASHBOARD; -import static org.openmetadata.service.Entity.DASHBOARD_DATA_MODEL; -import static org.openmetadata.service.Entity.DASHBOARD_SERVICE; -import static org.openmetadata.service.Entity.DATABASE; -import static org.openmetadata.service.Entity.DATABASE_SCHEMA; -import static org.openmetadata.service.Entity.DATABASE_SERVICE; -import static org.openmetadata.service.Entity.DATA_PRODUCT; -import static org.openmetadata.service.Entity.DOMAIN; -import static org.openmetadata.service.Entity.ENTITY_REPORT_DATA; -import static org.openmetadata.service.Entity.GLOSSARY; -import static org.openmetadata.service.Entity.GLOSSARY_TERM; -import static org.openmetadata.service.Entity.INGESTION_PIPELINE; -import static org.openmetadata.service.Entity.MESSAGING_SERVICE; -import static org.openmetadata.service.Entity.METADATA_SERVICE; -import static org.openmetadata.service.Entity.METRIC; -import static org.openmetadata.service.Entity.MLMODEL; -import static org.openmetadata.service.Entity.MLMODEL_SERVICE; -import static org.openmetadata.service.Entity.PIPELINE; -import static org.openmetadata.service.Entity.PIPELINE_SERVICE; -import static org.openmetadata.service.Entity.QUERY; -import static org.openmetadata.service.Entity.SEARCH_INDEX; -import static org.openmetadata.service.Entity.SEARCH_SERVICE; -import static org.openmetadata.service.Entity.STORAGE_SERVICE; -import static org.openmetadata.service.Entity.STORED_PROCEDURE; -import static org.openmetadata.service.Entity.TABLE; -import static org.openmetadata.service.Entity.TAG; -import static org.openmetadata.service.Entity.TEAM; -import static org.openmetadata.service.Entity.TEST_CASE; import static org.openmetadata.service.Entity.TEST_CASE_RESOLUTION_STATUS; import static org.openmetadata.service.Entity.TEST_CASE_RESULT; -import static org.openmetadata.service.Entity.TEST_SUITE; -import static org.openmetadata.service.Entity.TOPIC; -import static org.openmetadata.service.Entity.USER; -import static org.openmetadata.service.Entity.WEB_ANALYTIC_ENTITY_VIEW_REPORT_DATA; -import static org.openmetadata.service.Entity.WEB_ANALYTIC_USER_ACTIVITY_REPORT_DATA; import static org.openmetadata.service.apps.scheduler.AbstractOmAppJobListener.APP_RUN_STATS; import static org.openmetadata.service.apps.scheduler.AbstractOmAppJobListener.WEBSOCKET_STATUS_CHANNEL; import static org.openmetadata.service.apps.scheduler.AppScheduler.ON_DEMAND_JOB; @@ -99,53 +60,8 @@ @Slf4j public class SearchIndexApp extends AbstractNativeApplication { - private static final String ALL = "all"; - public static final Set ALL_ENTITIES = - Set.of( - TABLE, - DASHBOARD, - TOPIC, - PIPELINE, - INGESTION_PIPELINE, - SEARCH_INDEX, - USER, - TEAM, - GLOSSARY, - GLOSSARY_TERM, - MLMODEL, - TAG, - CLASSIFICATION, - QUERY, - CONTAINER, - DATABASE, - DATABASE_SCHEMA, - TEST_CASE, - TEST_SUITE, - CHART, - DASHBOARD_DATA_MODEL, - DATABASE_SERVICE, - MESSAGING_SERVICE, - DASHBOARD_SERVICE, - PIPELINE_SERVICE, - MLMODEL_SERVICE, - STORAGE_SERVICE, - METADATA_SERVICE, - SEARCH_SERVICE, - ENTITY_REPORT_DATA, - WEB_ANALYTIC_ENTITY_VIEW_REPORT_DATA, - WEB_ANALYTIC_USER_ACTIVITY_REPORT_DATA, - DOMAIN, - STORED_PROCEDURE, - DATA_PRODUCT, - TEST_CASE_RESOLUTION_STATUS, - TEST_CASE_RESULT, - API_SERVICE, - API_ENDPOINT, - API_COLLCECTION, - METRIC); - public static final Set TIME_SERIES_ENTITIES = Set.of( ReportData.ReportDataType.ENTITY_REPORT_DATA.value(), @@ -180,8 +96,9 @@ public void init(App app) { JsonUtils.convertValue(app.getAppConfiguration(), EventPublisherJob.class) .withStats(new Stats()); - if (request.getEntities().contains(ALL)) { - request.setEntities(ALL_ENTITIES); + if (request.getEntities().size() == 1 && request.getEntities().contains(ALL)) { + SearchRepository searchRepo = Entity.getSearchRepo(); + request.setEntities(searchRepo.getSearchEntities()); } jobData = request; diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchRepository.java index 98f4477afdf1..d1e368fdc463 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchRepository.java @@ -38,6 +38,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; @@ -1057,4 +1058,8 @@ public List getEntitiesContainingFQNFromES( } return new ArrayList<>(); } + + public Set getSearchEntities() { + return new HashSet<>(entityIndexMap.keySet()); + } } diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/ApplicationSchemas/SearchIndexingApplication.json b/openmetadata-ui/src/main/resources/ui/src/utils/ApplicationSchemas/SearchIndexingApplication.json index 4957d1979fa9..ff25b2618b6c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/ApplicationSchemas/SearchIndexingApplication.json +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ApplicationSchemas/SearchIndexingApplication.json @@ -66,46 +66,48 @@ "type": "string", "enum": [ "table", - "dashboard", - "topic", - "pipeline", - "ingestionPipeline", - "searchIndex", - "user", - "team", - "glossary", - "glossaryTerm", - "mlmodel", - "tag", - "classification", - "query", - "container", - "database", "databaseSchema", - "testCase", + "container", "testSuite", - "chart", - "dashboardDataModel", - "databaseService", - "messagingService", - "dashboardService", - "pipelineService", "mlmodelService", - "storageService", + "pipelineService", + "messagingService", + "entityReportData", + "ingestionPipeline", + "database", "metadataService", "searchService", - "entityReportData", + "aggregatedCostAnalysisReportData", + "tag", + "dashboard", + "rawCostAnalysisReportData", "webAnalyticEntityViewReportData", - "webAnalyticUserActivityReportData", - "domain", "storedProcedure", "dataProduct", - "testCaseResolutionStatus", - "testCaseResult", + "databaseService", + "dashboardService", + "query", "apiService", - "apiEndpoint", + "searchIndex", + "testCaseResult", "apiCollection", - "metric" + "team", + "mlmodel", + "classification", + "glossaryTerm", + "testCaseResolutionStatus", + "dashboardDataModel", + "pipeline", + "glossary", + "apiEndpoint", + "storageService", + "metric", + "webAnalyticUserActivityReportData", + "domain", + "topic", + "chart", + "user", + "testCase" ] }, "default": ["all"], From 564d33d69bb4e5b850c5ba1276028928684c74e1 Mon Sep 17 00:00:00 2001 From: Mohit Yadav <105265192+mohityadav766@users.noreply.github.com> Date: Sun, 15 Dec 2024 01:24:03 +0530 Subject: [PATCH 027/294] Add Algorithm option for validation in yaml (#19049) * Add algorithm option in authentication * ENtity Repository code remove * Keep Default Value * Fix Test --------- Co-authored-by: Siddhant <86899184+Siddhanttimeline@users.noreply.github.com> (cherry picked from commit 50ae01e2ceeae8180178d24ddc66065e4b96cef0) --- conf/openmetadata.yaml | 1 + .../service/OpenMetadataApplication.java | 5 ++++- .../service/security/JwtFilter.java | 6 +++++- .../security/jwt/JWTTokenGenerator.java | 20 +++++++++++++++++-- .../security/JWTTokenGeneratorTest.java | 4 +++- .../authenticationConfiguration.json | 6 ++++++ .../authenticationConfiguration.ts | 17 +++++++++++++--- 7 files changed, 51 insertions(+), 8 deletions(-) diff --git a/conf/openmetadata.yaml b/conf/openmetadata.yaml index dedceb705dd9..cf7f07b7f286 100644 --- a/conf/openmetadata.yaml +++ b/conf/openmetadata.yaml @@ -180,6 +180,7 @@ authenticationConfiguration: # This will only be valid when provider type specified is customOidc providerName: ${CUSTOM_OIDC_AUTHENTICATION_PROVIDER_NAME:-""} publicKeyUrls: ${AUTHENTICATION_PUBLIC_KEYS:-[http://localhost:8585/api/v1/system/config/jwks]} + tokenValidationAlgorithm: ${AUTHENTICATION_TOKEN_VALIDATION_ALGORITHM:-"RS256"} authority: ${AUTHENTICATION_AUTHORITY:-https://accounts.google.com} clientId: ${AUTHENTICATION_CLIENT_ID:-""} callbackUrl: ${AUTHENTICATION_CALLBACK_URL:-""} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/OpenMetadataApplication.java b/openmetadata-service/src/main/java/org/openmetadata/service/OpenMetadataApplication.java index fdb07d0bcba9..8efa36c4bc47 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/OpenMetadataApplication.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/OpenMetadataApplication.java @@ -190,7 +190,10 @@ public void run(OpenMetadataApplicationConfig catalogConfig, Environment environ EntityMaskerFactory.createEntityMasker(); // Instantiate JWT Token Generator - JWTTokenGenerator.getInstance().init(catalogConfig.getJwtTokenConfiguration()); + JWTTokenGenerator.getInstance() + .init( + catalogConfig.getAuthenticationConfiguration().getTokenValidationAlgorithm(), + catalogConfig.getJwtTokenConfiguration()); // Set the Database type for choosing correct queries from annotations jdbi.getConfig(SqlObjects.class) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/JwtFilter.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/JwtFilter.java index 0e57365b069e..60f1bb688daf 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/JwtFilter.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/JwtFilter.java @@ -22,6 +22,7 @@ import static org.openmetadata.service.security.SecurityUtil.validatePrincipalClaimsMapping; import static org.openmetadata.service.security.jwt.JWTTokenGenerator.ROLES_CLAIM; import static org.openmetadata.service.security.jwt.JWTTokenGenerator.TOKEN_TYPE; +import static org.openmetadata.service.security.jwt.JWTTokenGenerator.getAlgorithm; import com.auth0.jwk.Jwk; import com.auth0.jwk.JwkProvider; @@ -71,6 +72,7 @@ public class JwtFilter implements ContainerRequestFilter { private boolean enforcePrincipalDomain; private AuthProvider providerType; private boolean useRolesFromProvider = false; + private AuthenticationConfiguration.TokenValidationAlgorithm tokenValidationAlgorithm; private static final List DEFAULT_PUBLIC_KEY_URLS = Arrays.asList( @@ -123,6 +125,7 @@ public JwtFilter( this.principalDomain = authorizerConfiguration.getPrincipalDomain(); this.enforcePrincipalDomain = authorizerConfiguration.getEnforcePrincipalDomain(); this.useRolesFromProvider = authorizerConfiguration.getUseRolesFromProvider(); + this.tokenValidationAlgorithm = authenticationConfiguration.getTokenValidationAlgorithm(); } @VisibleForTesting @@ -224,7 +227,8 @@ public Map validateJwtAndGetClaims(String token) { // Validate JWT with public key Jwk jwk = jwkProvider.get(jwt.getKeyId()); - Algorithm algorithm = Algorithm.RSA256((RSAPublicKey) jwk.getPublicKey(), null); + Algorithm algorithm = + getAlgorithm(tokenValidationAlgorithm, (RSAPublicKey) jwk.getPublicKey(), null); try { algorithm.verify(jwt); } catch (RuntimeException runtimeException) { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/jwt/JWTTokenGenerator.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/jwt/JWTTokenGenerator.java index 868175326469..21aaeeeef9b2 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/jwt/JWTTokenGenerator.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/jwt/JWTTokenGenerator.java @@ -37,6 +37,7 @@ import java.util.Set; import lombok.Getter; import lombok.extern.slf4j.Slf4j; +import org.openmetadata.schema.api.security.AuthenticationConfiguration; import org.openmetadata.schema.api.security.jwt.JWTTokenConfiguration; import org.openmetadata.schema.auth.JWTAuthMechanism; import org.openmetadata.schema.auth.JWTTokenExpiry; @@ -56,6 +57,7 @@ public class JWTTokenGenerator { @Getter private RSAPublicKey publicKey; private String issuer; private String kid; + private AuthenticationConfiguration.TokenValidationAlgorithm tokenValidationAlgorithm; private JWTTokenGenerator() { /* Private constructor for singleton */ @@ -66,7 +68,9 @@ public static JWTTokenGenerator getInstance() { } /** Expected to be initialized only once during application start */ - public void init(JWTTokenConfiguration jwtTokenConfiguration) { + public void init( + AuthenticationConfiguration.TokenValidationAlgorithm algorithm, + JWTTokenConfiguration jwtTokenConfiguration) { try { if (jwtTokenConfiguration.getRsaprivateKeyFilePath() != null && !jwtTokenConfiguration.getRsaprivateKeyFilePath().isEmpty() @@ -84,6 +88,7 @@ public void init(JWTTokenConfiguration jwtTokenConfiguration) { publicKey = (RSAPublicKey) kf.generatePublic(spec); issuer = jwtTokenConfiguration.getJwtissuer(); kid = jwtTokenConfiguration.getKeyId(); + tokenValidationAlgorithm = algorithm; } } catch (Exception ex) { LOG.error("Failed to initialize JWTTokenGenerator ", ex); @@ -141,7 +146,7 @@ public JWTAuthMechanism getJwtAuthMechanism( } } JWTAuthMechanism jwtAuthMechanism = new JWTAuthMechanism().withJWTTokenExpiry(expiry); - Algorithm algorithm = Algorithm.RSA256(null, privateKey); + Algorithm algorithm = getAlgorithm(tokenValidationAlgorithm, null, privateKey); String token = JWT.create() .withIssuer(issuer) @@ -214,4 +219,15 @@ public Date getTokenExpiryFromJWT(String token) { return jwt.getExpiresAt(); } + + public static Algorithm getAlgorithm( + AuthenticationConfiguration.TokenValidationAlgorithm algorithm, + RSAPublicKey publicKey, + RSAPrivateKey privateKey) { + return switch (algorithm) { + case RS_256 -> Algorithm.RSA256(publicKey, privateKey); + case RS_384 -> Algorithm.RSA384(publicKey, privateKey); + case RS_512 -> Algorithm.RSA512(publicKey, privateKey); + }; + } } diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/security/JWTTokenGeneratorTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/security/JWTTokenGeneratorTest.java index b7d935f5c624..555a079ddd30 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/security/JWTTokenGeneratorTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/security/JWTTokenGeneratorTest.java @@ -15,6 +15,7 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; +import org.openmetadata.schema.api.security.AuthenticationConfiguration; import org.openmetadata.schema.api.security.jwt.JWTTokenConfiguration; import org.openmetadata.schema.auth.JWTAuthMechanism; import org.openmetadata.schema.auth.JWTTokenExpiry; @@ -38,7 +39,8 @@ public void setup() { jwtTokenConfiguration.setRsaprivateKeyFilePath(rsaPrivateKeyPath); jwtTokenConfiguration.setRsapublicKeyFilePath(rsaPublicKeyPath); jwtTokenGenerator = JWTTokenGenerator.getInstance(); - jwtTokenGenerator.init(jwtTokenConfiguration); + jwtTokenGenerator.init( + AuthenticationConfiguration.TokenValidationAlgorithm.RS_256, jwtTokenConfiguration); } @Test diff --git a/openmetadata-spec/src/main/resources/json/schema/configuration/authenticationConfiguration.json b/openmetadata-spec/src/main/resources/json/schema/configuration/authenticationConfiguration.json index 670401107ca8..ac7d5075ecae 100644 --- a/openmetadata-spec/src/main/resources/json/schema/configuration/authenticationConfiguration.json +++ b/openmetadata-spec/src/main/resources/json/schema/configuration/authenticationConfiguration.json @@ -46,6 +46,12 @@ "type": "string" } }, + "tokenValidationAlgorithm": { + "description": "Token Validation Algorithm to use.", + "type": "string", + "enum": ["RS256", "RS384", "RS512"], + "default": "RS256" + }, "authority": { "description": "Authentication Authority", "type": "string" diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/configuration/authenticationConfiguration.ts b/openmetadata-ui/src/main/resources/ui/src/generated/configuration/authenticationConfiguration.ts index c21521c1562a..41f559faee8a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/configuration/authenticationConfiguration.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/configuration/authenticationConfiguration.ts @@ -10,9 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - - - /** +/** * This schema defines the Authentication Configuration. */ export interface AuthenticationConfiguration { @@ -69,6 +67,10 @@ export interface AuthenticationConfiguration { * Saml Configuration that is applicable only when the provider is Saml */ samlConfiguration?: SamlSSOClientConfig; + /** + * Token Validation Algorithm to use. + */ + tokenValidationAlgorithm?: TokenValidationAlgorithm; } /** @@ -492,3 +494,12 @@ export interface SP { */ spX509Certificate?: string; } + +/** + * Token Validation Algorithm to use. + */ +export enum TokenValidationAlgorithm { + Rs256 = "RS256", + Rs384 = "RS384", + Rs512 = "RS512", +} From db9c2bcfc7370309ad4ca722b2fde59eba3d44c6 Mon Sep 17 00:00:00 2001 From: Ashish Gupta Date: Tue, 31 Dec 2024 17:12:15 +0530 Subject: [PATCH 028/294] fix tag page flaky playwright test (#19150) * fix tag page flaky playwright failures * removed commented code (cherry picked from commit ceeba2ad752b5e4df00bb89cee31faf1db3f4f73) --- .../ui/playwright/support/tag/TagClass.ts | 1 + .../src/main/resources/ui/playwright/utils/tag.ts | 15 +++++++++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/tag/TagClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/tag/TagClass.ts index 4d15a077f808..b0c86b708052 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/tag/TagClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/tag/TagClass.ts @@ -66,6 +66,7 @@ export class TagClass { async visitPage(page: Page) { await visitClassificationPage( page, + this.responseData.classification.name, this.responseData.classification.displayName ); await page.getByTestId(this.data.name).click(); 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 f5539666bc0f..7252fe61ae90 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/tag.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/tag.ts @@ -38,23 +38,26 @@ export const TAG_INVALID_NAMES = { export const visitClassificationPage = async ( page: Page, - classificationName: string + classificationName: string, + classificationDisplayName: string ) => { await redirectToHomePage(page); const classificationResponse = page.waitForResponse( '/api/v1/classifications?**' ); + const fetchTags = page.waitForResponse( + `/api/v1/tags?*parent=${classificationName}**` + ); await sidebarClick(page, SidebarItem.TAGS); await classificationResponse; - const fetchTags = page.waitForResponse('/api/v1/tags?*parent=*'); await page - .locator(`[data-testid="side-panel-classification"]`) - .filter({ hasText: classificationName }) + .getByTestId('data-summary-container') + .getByText(classificationDisplayName) .click(); await expect(page.locator('.activeCategory')).toContainText( - classificationName + classificationDisplayName ); await fetchTags; @@ -350,7 +353,7 @@ export const editTagPageDescription = async (page: Page, tag: TagClass) => { }; export const verifyCertificationTagPageUI = async (page: Page) => { - await visitClassificationPage(page, 'Certification'); + await visitClassificationPage(page, 'Certification', 'Certification'); const res = page.waitForResponse(`/api/v1/tags/name/*`); await page.getByTestId('Gold').click(); await res; From 4d0b72ee2f8f4d2a19b9a53ddccd25516452b4c2 Mon Sep 17 00:00:00 2001 From: Sachin Chaurasiya Date: Fri, 27 Dec 2024 20:57:37 +0530 Subject: [PATCH 029/294] feat(#15380): replace description editor with block editor (#19003) --- .../decorators/FeedMessageDecorator.java | 4 +- .../ChangeEventParserResourceTest.java | 10 +- .../e2e/Features/QueryEntity.spec.ts | 2 +- .../e2e/Flow/AddRoleAndAssignToUser.spec.ts | 4 +- .../e2e/Flow/ApiServiceRest.spec.ts | 2 +- .../e2e/Pages/DataQualityAndProfiler.spec.ts | 12 +- .../e2e/Pages/SearchIndexApplication.spec.ts | 2 +- .../ui/playwright/e2e/Pages/Tag.spec.ts | 11 +- .../ui/playwright/e2e/Pages/Tags.spec.ts | 4 +- .../ui/playwright/e2e/Pages/Teams.spec.ts | 5 +- .../ui/playwright/e2e/Pages/TestCases.spec.ts | 7 +- .../ui/playwright/e2e/Pages/TestSuite.spec.ts | 2 +- .../playwright/e2e/Pages/UserDetails.spec.ts | 1 + .../VersionPages/EntityVersionPages.spec.ts | 8 +- .../ServiceEntityVersionPage.spec.ts | 8 +- .../resources/ui/playwright/utils/alert.ts | 8 +- .../resources/ui/playwright/utils/common.ts | 6 +- .../ui/playwright/utils/customProperty.ts | 26 +- .../resources/ui/playwright/utils/domain.ts | 2 +- .../resources/ui/playwright/utils/entity.ts | 26 +- .../resources/ui/playwright/utils/glossary.ts | 6 +- .../ui/playwright/utils/importUtils.ts | 9 +- .../main/resources/ui/playwright/utils/tag.ts | 5 +- .../resources/ui/playwright/utils/team.ts | 10 +- .../resources/ui/playwright/utils/user.ts | 5 +- .../src/assets/svg/ic-format-block-quote.svg | 3 + .../ui/src/assets/svg/ic-format-highlight.svg | 3 + .../assets/svg/ic-format-horizontal-line.svg | 3 + .../src/assets/svg/ic-format-image-inline.svg | 4 + .../APIEndpointSchema/APIEndpointSchema.tsx | 6 +- .../FeedCardBody/FeedCardBody.test.tsx | 2 +- .../FeedCardBody/FeedCardBody.tsx | 6 +- .../FeedCardBody/FeedCardBodyV1.tsx | 6 +- .../CustomPropertyFeed.component.tsx | 4 +- .../DescriptionFeed/DescriptionFeed.test.tsx | 2 +- .../DescriptionFeed/DescriptionFeed.tsx | 4 +- .../BlockEditor/BarMenu/BarMenu.tsx | 153 ++ .../BlockEditor/BarMenu/bar-menu.less | 57 + .../BlockEditor/BlockEditor.interface.ts | 26 + .../components/BlockEditor/BlockEditor.tsx | 54 +- .../components/BlockEditor/EditorSlots.tsx | 15 +- .../BlockEditor/Extensions/diff-view.ts | 45 +- .../components/BlockEditor/block-editor.less | 24 +- .../ContainerChildren.test.tsx | 12 + .../ContainerChildren/ContainerChildren.tsx | 4 +- .../ContainerDataModel.test.tsx | 2 +- .../DashboardDetails.test.tsx | 2 +- .../DashboardVersion.component.tsx | 4 +- .../DashboardVersion.test.tsx | 2 +- .../DataModel/DataModels/DataModelsTable.tsx | 4 +- .../AddDataQualityTest/EditTestCaseModal.tsx | 1 - .../components/TestCaseForm.tsx | 1 - .../TestCaseStatusModal.component.tsx | 6 +- .../AddTestSuiteForm/AddTestSuiteForm.tsx | 1 - .../DatabaseSchemaTable.tsx | 4 +- .../Profiler/TableProfiler/ColumnSummary.tsx | 4 +- .../Database/SchemaTable/SchemaTable.test.tsx | 2 +- .../TableDataCardBody.test.tsx | 2 +- .../TableDataCardBody/TableDataCardBody.tsx | 4 +- .../TableDescription.component.tsx | 4 +- .../TableDescription.test.tsx | 2 +- .../SubDomainsTable.component.tsx | 4 +- .../Entity/Task/TaskTab/TaskTab.component.tsx | 4 - ...TaskTabIncidentManagerHeader.component.tsx | 4 +- .../TaskTabIncidentManagerHeader.test.tsx | 2 +- .../VersionTable/VersionTable.component.tsx | 12 +- .../Entity/VersionTable/VersionTable.test.tsx | 2 +- .../DataProductSummary.component.tsx | 4 +- .../SummaryListItems.component.tsx | 4 +- .../SummaryListItems.test.tsx | 2 +- .../GlossaryTermTab.component.tsx | 4 +- .../GlossaryTermTab/GlossaryTermTab.test.tsx | 2 +- .../MlModelFeaturesList.test.tsx | 2 +- .../MlModelVersion.component.tsx | 4 +- .../ModalWithMarkdownEditor.tsx | 6 +- .../Modals/WhatsNewModal/ChangeLogs.tsx | 6 +- .../Modals/WhatsNewModal/FeaturesCarousel.tsx | 4 +- .../PersonaDetailsCard/PersonaDetailsCard.tsx | 4 +- .../PipelineVersion.component.tsx | 8 +- .../PipelineVersion/PipelineVersion.test.tsx | 2 +- .../ApplicationCard.component.tsx | 4 +- .../ApplicationCard/ApplicationCard.test.tsx | 12 + .../MarketPlaceAppDetails.component.tsx | 4 +- .../MarketPlaceAppDetails.test.tsx | 2 +- .../Bot/BotListV1/BotListV1.component.tsx | 4 +- .../CustomPropertyTable.test.tsx | 2 +- .../CustomProperty/CustomPropertyTable.tsx | 4 +- .../IngestionListTable.test.tsx | 2 +- .../IngestionListTable/IngestionListTable.tsx | 4 +- .../Settings/Services/Services.test.tsx | 2 +- .../components/Settings/Services/Services.tsx | 6 +- .../Team/TeamDetails/RolesAndPoliciesList.tsx | 4 +- .../Team/TeamDetails/TeamHierarchy.tsx | 4 +- .../SuggestionsAlert.test.tsx | 14 + .../SuggestionsAlert/SuggestionsAlert.tsx | 4 +- .../Topic/TopicDetails/TopicDetails.test.tsx | 2 +- .../Topic/TopicSchema/TopicSchema.test.tsx | 2 +- .../Topic/TopicSchema/TopicSchema.tsx | 6 +- .../AirflowMessageBanner.tsx | 4 +- .../CustomPropertyTable/ExtensionTable.tsx | 4 +- .../PropertyValue.test.tsx | 2 +- .../CustomPropertyTable/PropertyValue.tsx | 8 +- .../EntityDescription/DescriptionV1.tsx | 11 +- .../common/RichTextEditor/EditorToolBar.ts | 61 - .../RichTextEditor.interface.ts | 10 +- .../RichTextEditor/RichTextEditor.test.tsx | 77 - .../common/RichTextEditor/RichTextEditor.tsx | 146 +- .../RichTextEditorPreviewerV1.tsx | 104 ++ .../rich-text-editor-previewerV1.less | 40 + .../RichTextEditor/rich-text-editor.less | 1603 ----------------- .../SummaryTagsDescription.component.tsx | 4 +- .../common/TierCard/TierCard.test.tsx | 2 +- .../components/common/TierCard/TierCard.tsx | 4 +- .../APICollectionPage/APIEndpointsTab.tsx | 4 +- .../AddNotificationPage.tsx | 1 - .../AddObservabilityPage.tsx | 1 - .../AddQueryPage/AddQueryPage.component.tsx | 1 - .../AlertDetailsPage.test.tsx | 19 +- .../CustomPageSettings/CustomPageSettings.tsx | 4 +- .../pages/DataInsightPage/KPIList.test.tsx | 2 +- .../ui/src/pages/DataInsightPage/KPIList.tsx | 4 +- .../DatabaseDetailsPage.test.tsx | 2 +- .../DatabaseSchemaPage/SchemaTablesTab.tsx | 4 +- .../ui/src/pages/KPIPage/AddKPIPage.tsx | 1 - .../ui/src/pages/KPIPage/EditKPIPage.tsx | 1 - .../MetricListPage/MetricListPage.tsx | 4 +- .../NotificationListPage.tsx | 3 +- .../ObservabilityAlertsPage.tsx | 3 +- .../AddPolicyPage/AddPolicyPage.tsx | 1 - .../PoliciesDetailPage.test.tsx | 2 +- .../PoliciesDetailPage/PoliciesDetailPage.tsx | 4 +- .../PoliciesDetailsList.component.tsx | 4 +- .../PoliciesListPage.test.tsx | 2 +- .../PoliciesListPage/PoliciesListPage.tsx | 4 +- .../pages/PoliciesPage/RuleForm/RuleForm.tsx | 1 - .../AddAttributeModal.test.tsx | 2 +- .../AddAttributeModal/AddAttributeModal.tsx | 4 +- .../RolesPage/AddRolePage/AddRolePage.tsx | 1 - .../RolesDetailPage/RolesDetailPage.test.tsx | 2 +- .../RolesDetailPageList.component.tsx | 4 +- .../RolesListPage/RolesListPage.test.tsx | 2 +- .../RolesPage/RolesListPage/RolesListPage.tsx | 4 +- .../ServiceVersionMainTabContent.test.tsx | 2 +- .../StoredProcedureTab.test.tsx | 2 +- .../StoredProcedure/StoredProcedureTab.tsx | 4 +- .../ui/src/pages/TagsPage/TagsForm.tsx | 2 +- .../ui/src/pages/TagsPage/TagsPage.test.tsx | 2 +- .../RequestDescriptionPage.tsx | 9 +- .../UpdateDescriptionPage.tsx | 5 +- .../TasksPage/shared/DescriptionTabs.test.tsx | 2 +- .../TasksPage/shared/DescriptionTabs.tsx | 16 +- .../TasksPage/shared/DescriptionTask.tsx | 1 - .../src/pages/TasksPage/shared/DiffView.tsx | 8 +- .../ui/src/pages/TeamsPage/AddTeamForm.tsx | 5 +- .../ui/src/utils/BlockEditorUtils.test.ts | 74 + .../ui/src/utils/BlockEditorUtils.ts | 85 +- .../ui/src/utils/ClassificationUtils.tsx | 4 +- .../ui/src/utils/Database/Database.util.tsx | 4 +- .../__snapshots__/Database.util.test.tsx.snap | 2 +- .../ui/src/utils/EntityVersionUtils.tsx | 4 +- .../main/resources/ui/src/utils/FeedUtils.tsx | 6 +- .../src/utils/ServiceMainTabContentUtils.tsx | 4 +- .../main/resources/ui/src/utils/TagsUtils.tsx | 4 +- .../main/resources/ui/src/utils/formUtils.tsx | 2 +- 164 files changed, 1039 insertions(+), 2204 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-format-block-quote.svg create mode 100644 openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-format-highlight.svg create mode 100644 openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-format-horizontal-line.svg create mode 100644 openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-format-image-inline.svg create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BarMenu/BarMenu.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BarMenu/bar-menu.less delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/EditorToolBar.ts delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditor.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditorPreviewerV1.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/rich-text-editor-previewerV1.less delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/rich-text-editor.less create mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorUtils.test.ts diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/formatter/decorators/FeedMessageDecorator.java b/openmetadata-service/src/main/java/org/openmetadata/service/formatter/decorators/FeedMessageDecorator.java index 411391e2772c..cedd3f6fae06 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/formatter/decorators/FeedMessageDecorator.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/formatter/decorators/FeedMessageDecorator.java @@ -37,7 +37,7 @@ public String getLineBreak() { @Override public String getAddMarker() { - return ""; + return ""; } @Override @@ -47,7 +47,7 @@ public String getAddMarkerClose() { @Override public String getRemoveMarker() { - return ""; + return ""; } @Override diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/ChangeEventParserResourceTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/ChangeEventParserResourceTest.java index 29bb0247b19e..36ee3fc80adb 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/resources/ChangeEventParserResourceTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/ChangeEventParserResourceTest.java @@ -141,7 +141,7 @@ void testEntityReferenceFormat() { assertEquals(1, threadWithMessages.size()); assertEquals( - "Added **owners**: User One", + "Added **owners**: User One", threadWithMessages.get(0).getMessage()); } @@ -156,8 +156,8 @@ void testUpdateOfString() { assertEquals(1, threadMessages.size()); assertEquals( - "Updated **description**: old " - + "new description", + "Updated **description**: old " + + "new description", threadMessages.get(0).getMessage()); // test if it updates correctly with one add and one delete change @@ -225,7 +225,7 @@ void testMajorSchemaChange() { assertEquals(1, threadWithMessages.size()); assertEquals( - "Updated **columns**: lo_order priority", + "Updated **columns**: lo_order priority", threadWithMessages.get(0).getMessage()); // Simulate a change of datatype change in column @@ -261,7 +261,7 @@ void testMajorSchemaChange() { assertEquals(1, threadWithMessages.size()); assertEquals( - "Updated **columns**: lo_orderpriority , newColumn", + "Updated **columns**: lo_orderpriority , newColumn", threadWithMessages.get(0).getMessage()); } diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/QueryEntity.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/QueryEntity.spec.ts index b16719709108..01e70e1e6994 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/QueryEntity.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/QueryEntity.spec.ts @@ -140,7 +140,7 @@ test('Query Entity', async ({ page }) => { // Update Description await page.click(`[data-testid="edit-description"]`); - await page.fill(descriptionBox, 'updated description'); + await page.locator(descriptionBox).fill('updated description'); const updateDescriptionResponse = page.waitForResponse( (response) => response.url().includes('/api/v1/queries/') && diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/AddRoleAndAssignToUser.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/AddRoleAndAssignToUser.spec.ts index 0158fc12adb7..96ee92ff9295 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/AddRoleAndAssignToUser.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/AddRoleAndAssignToUser.spec.ts @@ -53,7 +53,7 @@ test.describe.serial('Add role and assign it to the user', () => { await page.click('[data-testid="add-role"]'); await page.fill('[data-testid="name"]', roleName); - await page.fill(descriptionBox, `description for ${roleName}`); + await page.locator(descriptionBox).fill(`description for ${roleName}`); await page.click('[data-testid="policies"]'); await page.click('[title="Data Consumer Policy"]'); @@ -87,7 +87,7 @@ test.describe.serial('Add role and assign it to the user', () => { await page.fill('[data-testid="email"]', user.email); await page.fill('[data-testid="displayName"]', userDisplayName); - await page.fill(descriptionBox, 'Adding user'); + await page.locator(descriptionBox).fill('Adding user'); const generatePasswordResponse = page.waitForResponse( `/api/v1/users/generateRandomPwd` ); 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..0f66d1dd1f05 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 @@ -40,7 +40,7 @@ test.describe('API service', () => { // step 1 await page.getByTestId('service-name').fill(apiServiceConfig.name); - await page.fill(descriptionBox, apiServiceConfig.description); + await page.locator(descriptionBox).fill(apiServiceConfig.description); await page.getByTestId('next-button').click(); // step 2 diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataQualityAndProfiler.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataQualityAndProfiler.spec.ts index 6d767449500c..41d4f38993f2 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataQualityAndProfiler.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataQualityAndProfiler.spec.ts @@ -90,7 +90,7 @@ test('Table test case', PLAYWRIGHT_INGESTION_TAG_OBJ, async ({ page }) => { '#tableTestForm_params_columnName', NEW_TABLE_TEST_CASE.field ); - await page.fill(descriptionBox, NEW_TABLE_TEST_CASE.description); + await page.locator(descriptionBox).fill(NEW_TABLE_TEST_CASE.description); await page.click('[data-testid="submit-test"]'); await page.waitForSelector('[data-testid="success-line"]'); @@ -200,7 +200,7 @@ test('Column test case', PLAYWRIGHT_INGESTION_TAG_OBJ, async ({ page }) => { '#tableTestForm_params_maxLength', NEW_COLUMN_TEST_CASE.max ); - await page.fill(descriptionBox, NEW_COLUMN_TEST_CASE.description); + await page.locator(descriptionBox).fill(NEW_COLUMN_TEST_CASE.description); await page.click('[data-testid="submit-test"]'); await page.waitForSelector('[data-testid="success-line"]'); @@ -395,7 +395,7 @@ test( // Edit test case description await page.click(`[data-testid="edit-${testCaseName}"]`); - await page.fill(descriptionBox, 'Test case description'); + await page.locator(descriptionBox).fill('Test case description'); const updateTestCaseResponse2 = page.waitForResponse( (response) => response.url().includes('/api/v1/dataQuality/testCases/') && @@ -407,7 +407,11 @@ test( expect(body2).toEqual( JSON.stringify([ - { op: 'add', path: '/description', value: 'Test case description' }, + { + op: 'add', + path: '/description', + value: '

Test case description

', + }, ]) ); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts index 9f8d15d1ef53..b4a0e491b5a0 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts @@ -114,7 +114,7 @@ test('Search Index Application', async ({ page }) => { await page.getByTestId('tree-select-widget').click(); // uncheck the entity - await page.getByRole('tree').getByTitle('Topic').click(); + await page.getByRole('tree').getByTitle('Table').click(); await page.click( '[data-testid="select-widget"] > .ant-select-selector > .ant-select-selection-item' 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 8ef44d871d39..4cdb8e0f56bc 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 @@ -18,7 +18,12 @@ import { TagClass } from '../../support/tag/TagClass'; import { TeamClass } from '../../support/team/TeamClass'; import { UserClass } from '../../support/user/UserClass'; import { performAdminLogin } from '../../utils/admin'; -import { getApiContext, redirectToHomePage, uuid } from '../../utils/common'; +import { + descriptionBox, + getApiContext, + redirectToHomePage, + uuid, +} from '../../utils/common'; import { addAssetsToTag, editTagPageDescription, @@ -172,9 +177,9 @@ test.describe('Tag Page with Admin Roles', () => { await expect(adminPage.getByRole('dialog')).toBeVisible(); - await adminPage.locator('.toastui-editor-pseudo-clipboard').clear(); + await adminPage.locator(descriptionBox).clear(); await adminPage - .locator('.toastui-editor-pseudo-clipboard') + .locator(descriptionBox) .fill(`This is updated test description for tag ${tag.data.name}.`); const editDescription = adminPage.waitForResponse(`/api/v1/tags/*`); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Tags.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Tags.spec.ts index 3cfc7619465f..d7ccd9ba9298 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Tags.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Tags.spec.ts @@ -225,7 +225,7 @@ test('Classification Page', async ({ page }) => { '[data-testid="displayName"]', NEW_CLASSIFICATION.displayName ); - await page.fill(descriptionBox, NEW_CLASSIFICATION.description); + await page.locator(descriptionBox).fill(NEW_CLASSIFICATION.description); await page.click('[data-testid="mutually-exclusive-button"]'); const createTagCategoryResponse = page.waitForResponse( @@ -261,7 +261,7 @@ test('Classification Page', async ({ page }) => { await page.fill('[data-testid="name"]', NEW_TAG.name); await page.fill('[data-testid="displayName"]', NEW_TAG.displayName); - await page.fill(descriptionBox, NEW_TAG.description); + await page.locator(descriptionBox).fill(NEW_TAG.description); await page.fill('[data-testid="icon-url"]', NEW_TAG.icon); await page.fill('[data-testid="tags_color-color-input"]', NEW_TAG.color); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Teams.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Teams.spec.ts index 0e8a26acec81..b4ce23d496ae 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Teams.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Teams.spec.ts @@ -23,6 +23,7 @@ import { performAdminLogin } from '../../utils/admin'; import { createNewPage, descriptionBox, + descriptionBoxReadOnly, getApiContext, redirectToHomePage, toastNotification, @@ -303,7 +304,9 @@ test.describe('Teams Page', () => { // Validating the updated description await expect( - page.locator('[data-testid="asset-description-container"] p') + page.locator( + `[data-testid="asset-description-container"] ${descriptionBoxReadOnly}` + ) ).toContainText(updatedDescription); }); 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..c9b69c526bd3 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 @@ -291,10 +291,9 @@ test('Column Values To Be Not Null', async ({ page }) => { await page.click( `[data-testid="${NEW_COLUMN_TEST_CASE_WITH_NULL_TYPE.type}"]` ); - await page.fill( - descriptionBox, - NEW_COLUMN_TEST_CASE_WITH_NULL_TYPE.description - ); + await page + .locator(descriptionBox) + .fill(NEW_COLUMN_TEST_CASE_WITH_NULL_TYPE.description); await page.click('[data-testid="submit-test"]'); await page.waitForSelector('[data-testid="success-line"]'); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/TestSuite.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/TestSuite.spec.ts index 45addd132221..14cde3d2309c 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/TestSuite.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/TestSuite.spec.ts @@ -73,7 +73,7 @@ test('Logical TestSuite', async ({ page }) => { await test.step('Create', async () => { await page.click('[data-testid="add-test-suite-btn"]'); await page.fill('[data-testid="test-suite-name"]', NEW_TEST_SUITE.name); - await page.fill(descriptionBox, NEW_TEST_SUITE.description); + await page.locator(descriptionBox).fill(NEW_TEST_SUITE.description); await page.click('[data-testid="submit-button"]'); const getTestCase = page.waitForResponse( diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/UserDetails.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/UserDetails.spec.ts index ad32bf9abf85..2c46f488be11 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/UserDetails.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/UserDetails.spec.ts @@ -155,6 +155,7 @@ test.describe('User with different Roles', () => { .getByTestId('edit-description') .click(); + await userPage.click(descriptionBox); await userPage.locator(descriptionBox).clear(); const removeUserDescription = userPage.waitForResponse( 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..2fc799eadfc2 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, + descriptionBoxReadOnly, + redirectToHomePage, +} from '../../utils/common'; import { addMultiOwner, assignTier } from '../../utils/entity'; const entities = [ @@ -123,7 +127,7 @@ entities.forEach((EntityClass) => { await expect( page.locator( - '[data-testid="viewer-container"] [data-testid="diff-added"]' + `[data-testid="asset-description-container"] ${descriptionBoxReadOnly} [data-testid="diff-added"]` ) ).toBeVisible(); 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..c7df9756dd9e 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, + descriptionBoxReadOnly, + redirectToHomePage, +} from '../../utils/common'; import { addMultiOwner, assignTier } from '../../utils/entity'; const entities = [ @@ -122,7 +126,7 @@ entities.forEach((EntityClass) => { await expect( page.locator( - '[data-testid="viewer-container"] [data-testid="diff-added"]' + `[data-testid="asset-description-container"] ${descriptionBoxReadOnly} [data-testid="diff-added"]` ) ).toBeVisible(); 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 e424bc5f885c..37aae920a894 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/alert.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/alert.ts @@ -37,7 +37,7 @@ import { toastNotification, uuid, } from './common'; -import { getEntityDisplayName } from './entity'; +import { getEntityDisplayName, getTextFromHtmlString } from './entity'; import { validateFormNameFieldInput } from './form'; import { addFilterWithUsersListInput, @@ -502,9 +502,9 @@ export const verifyAlertDetails = async ({ ); if (description) { - // Check alert name - await expect(page.getByTestId('alert-description')).toContainText( - description + // Check alert description + await expect(page.getByTestId('markdown-parser')).toContainText( + getTextFromHtmlString(description) ); } 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 446e4220cdec..08bc68ec4d18 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts @@ -19,8 +19,10 @@ import { sidebarClick } from './sidebar'; export const uuid = () => randomUUID().split('-')[0]; -export const descriptionBox = - '.toastui-editor-md-container > .toastui-editor > .ProseMirror'; +export const descriptionBox = '.om-block-editor[contenteditable="true"]'; +export const descriptionBoxReadOnly = + '.om-block-editor[contenteditable="false"]'; + export const INVALID_NAMES = { MAX_LENGTH: 'a87439625b1c2d3e4f5061728394a5b6c7d8e90a1b2c3d4e5f67890aba87439625b1c2d3e4f5061728394a5b6c7d8e90a1b2c3d4e5f67890abName can be a maximum of 128 characters', diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/customProperty.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/customProperty.ts index 994b61e70e10..64461e0f47be 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/customProperty.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/customProperty.ts @@ -21,7 +21,12 @@ import { ENTITY_PATH, } from '../support/entity/Entity.interface'; import { UserClass } from '../support/user/UserClass'; -import { clickOutside, descriptionBox, uuid } from './common'; +import { + clickOutside, + descriptionBox, + descriptionBoxReadOnly, + uuid, +} from './common'; export enum CustomPropertyType { STRING = 'String', @@ -107,16 +112,9 @@ export const setValueForProperty = async (data: { const patchRequest = page.waitForResponse(`/api/v1/${endpoint}/*`); switch (propertyType) { case 'markdown': - await page - .locator( - '.toastui-editor-md-container > .toastui-editor > .ProseMirror' - ) - .isVisible(); - await page - .locator( - '.toastui-editor-md-container > .toastui-editor > .ProseMirror' - ) - .fill(value); + await page.locator(descriptionBox).isVisible(); + await page.click(descriptionBox); + await page.keyboard.type(value); await page.locator('[data-testid="save"]').click(); break; @@ -282,6 +280,10 @@ export const validateValueForProperty = async (data: { await expect( page.getByRole('row', { name: `${values[0]} ${values[1]}` }) ).toBeVisible(); + } else if (propertyType === 'markdown') { + await expect( + container.locator(descriptionBoxReadOnly).last() + ).toContainText(value.replace(/\*|_/gi, '')); } else if ( ![ 'entityReference', @@ -681,7 +683,7 @@ export const addCustomPropertiesForEntity = async ({ // Description - await page.fill(descriptionBox, customPropertyData.description); + await page.locator(descriptionBox).fill(customPropertyData.description); const createPropertyPromise = page.waitForResponse( '/api/v1/metadata/types/name/*?fields=customProperties' diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/domain.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/domain.ts index 3704966267e1..b769ef573e70 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/domain.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/domain.ts @@ -151,7 +151,7 @@ const fillCommonFormItems = async ( ) => { await page.locator('[data-testid="name"]').fill(entity.name); await page.locator('[data-testid="display-name"]').fill(entity.displayName); - await page.fill(descriptionBox, entity.description); + await page.locator(descriptionBox).fill(entity.description); if (!isEmpty(entity.owners) && !isUndefined(entity.owners)) { await addOwner({ page, 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 36043e4573ca..5e54306fe450 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, descriptionBox, redirectToHomePage } from './common'; export const visitEntityPage = async (data: { page: Page; @@ -330,9 +330,9 @@ export const updateDescription = async ( 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.locator(descriptionBox).first().click(); + await page.locator(descriptionBox).first().clear(); + await page.locator(descriptionBox).first().fill(description); await page.getByTestId('save').click(); if (isModal) { @@ -783,10 +783,7 @@ const announcementForm = async ( await page.fill('#endTime', `${data.endDate}`); await page.press('#startTime', 'Enter'); - await page.fill( - '.toastui-editor-md-container > .toastui-editor > .ProseMirror', - data.description - ); + await page.locator(descriptionBox).fill(data.description); await page.locator('#announcement-submit').scrollIntoViewIfNeeded(); const announcementSubmit = page.waitForResponse( @@ -1355,3 +1352,16 @@ export const getEntityDisplayName = (entity?: { }) => { return entity?.displayName || entity?.name || ''; }; + +/** + * + * @param description HTML string + * @returns Text from HTML string + */ +export const getTextFromHtmlString = (description?: string): string => { + if (!description) { + return ''; + } + + return description.replace(/<[^>]*>/g, '').trim(); +}; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts index 361e19fc0aa0..4d2a85554609 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts @@ -28,6 +28,7 @@ import { GlossaryTerm } from '../support/glossary/GlossaryTerm'; import { clickOutside, closeFirstPopupAlert, + descriptionBox, getApiContext, INVALID_NAMES, NAME_MAX_LENGTH_VALIDATION_ERROR, @@ -47,9 +48,6 @@ type TaskEntity = { const GLOSSARY_NAME_VALIDATION_ERROR = 'Name size must be between 1 and 128'; -export const descriptionBox = - '.toastui-editor-md-container > .toastui-editor > .ProseMirror'; - export const checkDisplayName = async (page: Page, displayName: string) => { await expect(page.getByTestId('entity-header-display-name')).toHaveText( displayName @@ -244,7 +242,7 @@ export const createGlossary = async ( await page.fill('[data-testid="name"]', glossaryData.name); - await page.fill(descriptionBox, glossaryData.description); + await page.locator(descriptionBox).fill(glossaryData.description); await expect( page.locator('[data-testid="form-item-alert"]') diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/importUtils.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/importUtils.ts index ced6575cb1c1..b1bbed0d03d1 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/importUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/importUtils.ts @@ -46,14 +46,9 @@ export const fillDescriptionDetails = async ( description: string ) => { await page.locator('.InovuaReactDataGrid__cell--cell-active').press('Enter'); - await page.click( - '.toastui-editor-md-container > .toastui-editor > .ProseMirror' - ); + await page.click(descriptionBox); - await page.fill( - '.toastui-editor-md-container > .toastui-editor > .ProseMirror', - description - ); + await page.fill(descriptionBox, description); await page.click('[data-testid="save"]'); await page.click('.InovuaReactDataGrid__cell--cell-active'); 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 7252fe61ae90..cffea876f52b 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/tag.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/tag.ts @@ -22,6 +22,7 @@ import { TableClass } from '../support/entity/TableClass'; import { TopicClass } from '../support/entity/TopicClass'; import { TagClass } from '../support/tag/TagClass'; import { + descriptionBox, getApiContext, NAME_MIN_MAX_LENGTH_VALIDATION_ERROR, NAME_VALIDATION_ERROR, @@ -338,9 +339,9 @@ export const editTagPageDescription = async (page: Page, tag: TagClass) => { await expect(page.getByRole('dialog')).toBeVisible(); - await page.locator('.toastui-editor-pseudo-clipboard').clear(); + await page.locator(descriptionBox).clear(); await page - .locator('.toastui-editor-pseudo-clipboard') + .locator(descriptionBox) .fill(`This is updated test description for tag ${tag.data.name}.`); const editDescription = page.waitForResponse(`/api/v1/tags/*`); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/team.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/team.ts index f77452977e78..503984e340bb 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/team.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/team.ts @@ -40,12 +40,8 @@ export const createTeam = async (page: Page, isPublic?: boolean) => { await page.getByTestId('isJoinable-switch-button').click(); } - await page - .locator('.toastui-editor-md-container > .toastui-editor > .ProseMirror') - .isVisible(); - await page - .locator('.toastui-editor-md-container > .toastui-editor > .ProseMirror') - .fill(teamData.description); + await page.locator(descriptionBox).isVisible(); + await page.locator(descriptionBox).fill(teamData.description); const createTeamResponse = page.waitForResponse('/api/v1/teams'); @@ -226,7 +222,7 @@ export const addTeamHierarchy = async ( await page.click(`.ant-select-dropdown [title="${teamDetails.teamType}"]`); } - await page.fill(descriptionBox, teamDetails.description); + await page.locator(descriptionBox).fill(teamDetails.description); // Saving the created team const saveTeamResponse = page.waitForResponse('/api/v1/teams'); 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 20926d5b9576..8a6d184398a3 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/user.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/user.ts @@ -30,6 +30,7 @@ import { SidebarItem } from '../constant/sidebar'; import { UserClass } from '../support/user/UserClass'; import { descriptionBox, + descriptionBoxReadOnly, getAuthContext, getToken, redirectToHomePage, @@ -265,7 +266,7 @@ export const editDescription = async ( // Verify the updated description const description = page.locator( - '[data-testid="asset-description-container"] .toastui-editor-contents > p' + `[data-testid="asset-description-container"] ${descriptionBoxReadOnly}` ); await expect(description).toContainText(updatedDescription); @@ -707,7 +708,7 @@ export const addUser = async ( await page.fill('[data-testid="displayName"]', name); - await page.fill(descriptionBox, 'Adding new user'); + await page.locator(descriptionBox).fill('Adding new user'); await page.click(':nth-child(2) > .ant-radio > .ant-radio-input'); await page.fill('#password', password); diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-format-block-quote.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-format-block-quote.svg new file mode 100644 index 000000000000..a626260650fd --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-format-block-quote.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-format-highlight.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-format-highlight.svg new file mode 100644 index 000000000000..efeb1f6fce77 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-format-highlight.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-format-horizontal-line.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-format-horizontal-line.svg new file mode 100644 index 000000000000..aeb9db3e5ad4 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-format-horizontal-line.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-format-image-inline.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-format-image-inline.svg new file mode 100644 index 000000000000..c67825ead370 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-format-image-inline.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/openmetadata-ui/src/main/resources/ui/src/components/APIEndpoint/APIEndpointSchema/APIEndpointSchema.tsx b/openmetadata-ui/src/main/resources/ui/src/components/APIEndpoint/APIEndpointSchema/APIEndpointSchema.tsx index 0ebbf5a8e45e..f766cc1e34a1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/APIEndpoint/APIEndpointSchema/APIEndpointSchema.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/APIEndpoint/APIEndpointSchema/APIEndpointSchema.tsx @@ -51,7 +51,7 @@ import { updateFieldDescription, updateFieldTags, } from '../../../utils/TableUtils'; -import RichTextEditorPreviewer from '../../common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../common/RichTextEditor/RichTextEditorPreviewerV1'; import ToggleExpandButton from '../../common/ToggleExpandButton/ToggleExpandButton'; import { ColumnFilter } from '../../Database/ColumnFilter/ColumnFilter.component'; import TableDescription from '../../Database/TableDescription/TableDescription.component'; @@ -201,7 +201,7 @@ const APIEndpointSchema: FC = ({ {isVersionView ? ( - + ) : ( getEntityName(record) )} @@ -216,7 +216,7 @@ const APIEndpointSchema: FC = ({ (dataType: DataType, record: Field) => ( {isVersionView ? ( - ) : ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCard/FeedCardBody/FeedCardBody.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCard/FeedCardBody/FeedCardBody.test.tsx index f02d1777f7d9..586d18b08c1f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCard/FeedCardBody/FeedCardBody.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCard/FeedCardBody/FeedCardBody.test.tsx @@ -37,7 +37,7 @@ jest.mock('../../../../utils/FeedUtils', () => ({ }, })); -jest.mock('../../../common/RichTextEditor/RichTextEditorPreviewer', () => { +jest.mock('../../../common/RichTextEditor/RichTextEditorPreviewerV1', () => { return jest.fn().mockReturnValue(

RichText Preview

); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCard/FeedCardBody/FeedCardBody.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCard/FeedCardBody/FeedCardBody.tsx index 796470193d8e..3d8b1ca3c905 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCard/FeedCardBody/FeedCardBody.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCard/FeedCardBody/FeedCardBody.tsx @@ -21,7 +21,7 @@ import { getFrontEndFormat, MarkdownToHTMLConverter, } from '../../../../utils/FeedUtils'; -import RichTextEditorPreviewer from '../../../common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../../common/RichTextEditor/RichTextEditorPreviewerV1'; import ActivityFeedEditor from '../../ActivityFeedEditor/ActivityFeedEditor'; import Reactions from '../../Reactions/Reactions'; import { FeedBodyProp } from '../ActivityFeedCard.interface'; @@ -88,7 +88,7 @@ const FeedCardBody: FC = ({ onTextChange={handleMessageUpdate} /> ) : ( - @@ -114,7 +114,7 @@ const FeedCardBody: FC = ({ {postMessage} - diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCard/FeedCardBody/FeedCardBodyV1.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCard/FeedCardBody/FeedCardBodyV1.tsx index 499cc9830ca2..0e03ff70b1f1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCard/FeedCardBody/FeedCardBodyV1.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCard/FeedCardBody/FeedCardBodyV1.tsx @@ -18,7 +18,6 @@ import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import ActivityFeedEditor from '../../../../components/ActivityFeed/ActivityFeedEditor/ActivityFeedEditor'; -import RichTextEditorPreviewer from '../../../../components/common/RichTextEditor/RichTextEditorPreviewer'; import { ASSET_CARD_STYLES } from '../../../../constants/Feeds.constants'; import { EntityType } from '../../../../enums/entity.enum'; import { CardStyle } from '../../../../generated/entity/feed/thread'; @@ -34,6 +33,7 @@ import { getFrontEndFormat, MarkdownToHTMLConverter, } from '../../../../utils/FeedUtils'; +import RichTextEditorPreviewerV1 from '../../../common/RichTextEditor/RichTextEditorPreviewerV1'; import ExploreSearchCard from '../../../ExploreV1/ExploreSearchCard/ExploreSearchCard'; import DescriptionFeed from '../../ActivityFeedCardV2/FeedCardBody/DescriptionFeed/DescriptionFeed'; import TagsFeed from '../../ActivityFeedCardV2/FeedCardBody/TagsFeed/TagsFeed'; @@ -118,7 +118,7 @@ const FeedCardBodyV1 = ({ } return ( - @@ -192,7 +192,7 @@ const FeedCardBodyV1 = ({
- diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardV2/FeedCardBody/CustomPropertyFeed/CustomPropertyFeed.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardV2/FeedCardBody/CustomPropertyFeed/CustomPropertyFeed.component.tsx index bba62c4d7b94..a65cf2546161 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardV2/FeedCardBody/CustomPropertyFeed/CustomPropertyFeed.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardV2/FeedCardBody/CustomPropertyFeed/CustomPropertyFeed.component.tsx @@ -13,7 +13,7 @@ import React, { useMemo } from 'react'; import { getTextDiffCustomProperty } from '../../../../../utils/EntityVersionUtils'; -import RichTextEditorPreviewer from '../../../../common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../../../common/RichTextEditor/RichTextEditorPreviewerV1'; import { CustomPropertyFeedProps } from './CustomPropertyFeed.interface'; function CustomPropertyFeed({ feed }: Readonly) { @@ -28,7 +28,7 @@ function CustomPropertyFeed({ feed }: Readonly) { ); return ( - diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardV2/FeedCardBody/DescriptionFeed/DescriptionFeed.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardV2/FeedCardBody/DescriptionFeed/DescriptionFeed.test.tsx index 8bebd16ed3cb..d860f59b5aa1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardV2/FeedCardBody/DescriptionFeed/DescriptionFeed.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardV2/FeedCardBody/DescriptionFeed/DescriptionFeed.test.tsx @@ -20,7 +20,7 @@ import { } from '../../../../../mocks/DescriptionFeed.mock'; import DescriptionFeed from './DescriptionFeed'; -jest.mock('../../../../common/RichTextEditor/RichTextEditorPreviewer', () => { +jest.mock('../../../../common/RichTextEditor/RichTextEditorPreviewerV1', () => { return jest.fn().mockReturnValue(

RichTextEditorPreviewer

); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardV2/FeedCardBody/DescriptionFeed/DescriptionFeed.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardV2/FeedCardBody/DescriptionFeed/DescriptionFeed.tsx index f6f72abbc1e7..16a004167aaf 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardV2/FeedCardBody/DescriptionFeed/DescriptionFeed.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardV2/FeedCardBody/DescriptionFeed/DescriptionFeed.tsx @@ -19,7 +19,7 @@ import { getFieldOperationIcon, getFrontEndFormat, } from '../../../../../utils/FeedUtils'; -import RichTextEditorPreviewer from '../../../../common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../../../common/RichTextEditor/RichTextEditorPreviewerV1'; import { DescriptionFeedProps } from './DescriptionFeed.interface'; function DescriptionFeed({ feed }: Readonly) { @@ -45,7 +45,7 @@ function DescriptionFeed({ feed }: Readonly) {
{operationIcon}- diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BarMenu/BarMenu.tsx b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BarMenu/BarMenu.tsx new file mode 100644 index 000000000000..d718b61cd9e0 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BarMenu/BarMenu.tsx @@ -0,0 +1,153 @@ +/* + * 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 classNames from 'classnames'; +import { noop, uniqueId } from 'lodash'; +import React, { FC, Fragment } from 'react'; +import BlockQuoteIcon from '../../../assets/svg/ic-format-block-quote.svg'; +import BoldIcon from '../../../assets/svg/ic-format-bold.svg'; +import UnorderedListIcon from '../../../assets/svg/ic-format-bullet-list.svg'; +import CodeBlockIcon from '../../../assets/svg/ic-format-code-block.svg'; +import HighlightIcon from '../../../assets/svg/ic-format-highlight.svg'; +import HorizontalLineIcon from '../../../assets/svg/ic-format-horizontal-line.svg'; +import ImageIcon from '../../../assets/svg/ic-format-image-inline.svg'; +import InlineCodeIcon from '../../../assets/svg/ic-format-inline-code.svg'; +import ItalicIcon from '../../../assets/svg/ic-format-italic.svg'; +import LinkIcon from '../../../assets/svg/ic-format-link.svg'; +import OrderedListIcon from '../../../assets/svg/ic-format-numbered-list.svg'; +import StrikeIcon from '../../../assets/svg/ic-format-strike.svg'; +import { BarMenuProps } from '../BlockEditor.interface'; +import './bar-menu.less'; + +const BarMenu: FC = ({ editor, onLinkToggle }) => { + const formats = [ + [ + { + name: 'bold', + icon: BoldIcon, + command: () => editor.chain().focus().toggleBold().run(), + isActive: () => editor.isActive('bold'), + }, + { + name: 'italic', + icon: ItalicIcon, + command: () => editor.chain().focus().toggleItalic().run(), + isActive: () => editor.isActive('italic'), + }, + { + name: 'strike', + icon: StrikeIcon, + command: () => editor.chain().focus().toggleStrike().run(), + isActive: () => editor.isActive('strike'), + }, + ], + [ + { + name: 'inline-code', + icon: InlineCodeIcon, + command: () => editor.chain().focus().toggleCode().run(), + isActive: () => editor.isActive('code'), + }, + { + name: 'highlight', + icon: HighlightIcon, + command: () => noop, + isActive: () => editor.isActive('highlight'), + }, + ], + [ + { + name: 'unordered-list', + icon: UnorderedListIcon, + command: () => editor.chain().focus().toggleBulletList().run(), + isActive: () => editor.isActive('bulletList'), + }, + { + name: 'ordered-list', + icon: OrderedListIcon, + command: () => editor.chain().focus().toggleOrderedList().run(), + isActive: () => editor.isActive('orderedList'), + }, + ], + [ + { + name: 'link', + icon: LinkIcon, + command: () => { + editor.chain().focus().setLink({ href: '' }).run(); + onLinkToggle?.(); + }, + isActive: () => editor.isActive('link'), + }, + { + name: 'image', + icon: ImageIcon, + command: () => editor.chain().focus().setImage({ src: '' }).run(), + isActive: () => editor.isActive('image'), + }, + { + name: 'code-block', + icon: CodeBlockIcon, + command: () => editor.chain().focus().toggleCodeBlock().run(), + isActive: () => editor.isActive('codeBlock'), + }, + { + name: 'block-quote', + icon: BlockQuoteIcon, + command: () => editor.chain().focus().toggleBlockquote().run(), + isActive: () => editor.isActive('blockquote'), + }, + { + name: 'horizontal-line', + icon: HorizontalLineIcon, + command: () => editor.chain().focus().setHorizontalRule().run(), + isActive: () => false, + }, + ], + ]; + + return ( +
+ {formats.map((format, index) => { + return ( + +
+ {format.map((item) => { + return ( + + ); + })} +
+ {index !== formats.length - 1 && ( +
+ )} + + ); + })} +
+ ); +}; + +export default BarMenu; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BarMenu/bar-menu.less b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BarMenu/bar-menu.less new file mode 100644 index 000000000000..1dcb7bcef7c1 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BarMenu/bar-menu.less @@ -0,0 +1,57 @@ +/* + * 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. + */ + +@button-bg-color-hover: #ecedee; +@button-bg-color: #e8e8e9; +@format-group-separator-color: #e5e7eb; +@menu-bg-color: #f7f9fc; + +.bar-menu-wrapper { + border-top-left-radius: 4px; + border-top-right-radius: 4px; + background-color: @menu-bg-color; + display: flex; + flex-direction: row; + padding: 8px; + border-bottom: 1px solid @format-group-separator-color; + gap: 1rem; + + .bar-menu-wrapper--format-group { + display: flex; + flex-direction: row; + gap: 0.5rem; + + .bar-menu-wrapper--format-group--button { + background-color: transparent; + border: none; + border-radius: 0.375rem; + cursor: pointer; + + &:hover { + background-color: @button-bg-color-hover; + } + + &.active { + background-color: @button-bg-color; + } + .bar-menu-wrapper--format--button--icon { + width: 28px; + height: 28px; + } + } + } + + .bar-menu-wrapper--format-group--separator { + border-left: 1px solid @format-group-separator-color; + } +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BlockEditor.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BlockEditor.interface.ts index dba44a126c7c..2ebf3a73696f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BlockEditor.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BlockEditor.interface.ts @@ -10,8 +10,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { Editor } from '@tiptap/react'; import { SuggestionKeyDownProps } from '@tiptap/suggestion'; +export type MenuType = 'bubble' | 'bar'; + export interface SuggestionItem { id: string; name: string; @@ -27,4 +30,27 @@ export interface ExtensionRef { export interface EditorSlotsRef { onMouseDown: (e: React.MouseEvent) => void; + onLinkToggle: () => void; +} + +export interface BlockEditorRef { + editor: Editor | null; +} +export interface BlockEditorProps { + content?: string; + editable?: boolean; + onChange?: (htmlContent: string) => void; + menuType?: MenuType; + autoFocus?: boolean; + placeholder?: string; +} + +export interface EditorSlotsProps { + editor: Editor | null; + menuType: MenuType; +} + +export interface BarMenuProps { + editor: Editor; + onLinkToggle?: () => void; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BlockEditor.tsx b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BlockEditor.tsx index 5c45c975c317..3ae7f649474e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BlockEditor.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BlockEditor.tsx @@ -10,7 +10,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Editor, EditorContent } from '@tiptap/react'; +import { EditorContent } from '@tiptap/react'; +import classNames from 'classnames'; import { isNil } from 'lodash'; import React, { forwardRef, @@ -21,26 +22,33 @@ import React, { import { useTranslation } from 'react-i18next'; import { EDITOR_OPTIONS } from '../../constants/BlockEditor.constants'; import { formatContent, setEditorContent } from '../../utils/BlockEditorUtils'; +import BarMenu from './BarMenu/BarMenu'; import './block-editor.less'; -import { EditorSlotsRef } from './BlockEditor.interface'; +import { + BlockEditorProps, + BlockEditorRef, + EditorSlotsRef, +} from './BlockEditor.interface'; import EditorSlots from './EditorSlots'; import { extensions } from './Extensions'; import { useCustomEditor } from './hooks/useCustomEditor'; -export interface BlockEditorRef { - editor: Editor | null; -} -export interface BlockEditorProps { - content?: string; - editable?: boolean; - onChange?: (htmlContent: string) => void; -} - const BlockEditor = forwardRef( - ({ content = '', editable = true, onChange }, ref) => { + ( + { + content = '', + editable = true, + menuType = 'bubble', + autoFocus, + placeholder, + onChange, + }, + ref + ) => { const { i18n } = useTranslation(); const editorSlots = useRef(null); + // this hook to initialize the editor const editor = useCustomEditor({ ...EDITOR_OPTIONS, extensions, @@ -54,14 +62,18 @@ const BlockEditor = forwardRef( editorProps: { attributes: { class: 'om-block-editor', + ...(autoFocus ? { autofocus: 'true' } : {}), }, }, + autofocus: autoFocus, }); + // this hook to expose the editor instance useImperativeHandle(ref, () => ({ editor, })); + // this effect to handle the content change useEffect(() => { if (isNil(editor) || editor.isDestroyed || content === undefined) { return; @@ -77,6 +89,7 @@ const BlockEditor = forwardRef( }); }, [content, editor]); + // this effect to handle the editable state useEffect(() => { if ( isNil(editor) || @@ -91,6 +104,7 @@ const BlockEditor = forwardRef( setTimeout(() => editor.setEditable(editable)); }, [editable, editor]); + // this effect to handle the RTL and LTR direction useEffect(() => { const editorWrapper = document.getElementById('block-editor-wrapper'); if (!editorWrapper) { @@ -106,12 +120,24 @@ const BlockEditor = forwardRef( }, [i18n]); return ( -
+
+ {menuType === 'bar' && !isNil(editor) && ( + + )} - +
); } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/EditorSlots.tsx b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/EditorSlots.tsx index e3f56d35d120..cb4a54a61528 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/EditorSlots.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/EditorSlots.tsx @@ -14,19 +14,15 @@ import { Editor, ReactRenderer } from '@tiptap/react'; import { isEmpty, isNil } from 'lodash'; import React, { forwardRef, useImperativeHandle, useState } from 'react'; import tippy, { Instance, Props } from 'tippy.js'; -import { EditorSlotsRef } from './BlockEditor.interface'; +import { EditorSlotsProps, EditorSlotsRef } from './BlockEditor.interface'; import BlockMenu from './BlockMenu/BlockMenu'; import BubbleMenu from './BubbleMenu/BubbleMenu'; import LinkModal, { LinkData } from './LinkModal/LinkModal'; import LinkPopup from './LinkPopup/LinkPopup'; import TableMenu from './TableMenu/TableMenu'; -interface EditorSlotsProps { - editor: Editor | null; -} - const EditorSlots = forwardRef( - ({ editor }, ref) => { + ({ editor, menuType }, ref) => { const [isLinkModalOpen, setIsLinkModalOpen] = useState(false); const handleLinkToggle = () => { @@ -142,12 +138,17 @@ const EditorSlots = forwardRef( } }; - const menus = !isNil(editor) && ( + /** + * render the bubble menu only if the editor is available + * and the menu type is bubble + */ + const menus = !isNil(editor) && menuType === 'bubble' && ( ); useImperativeHandle(ref, () => ({ onMouseDown: handleLinkPopup, + onLinkToggle: handleLinkToggle, })); if (isNil(editor)) { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/diff-view.ts b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/diff-view.ts index 0aa240200fcb..5533bc669308 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/diff-view.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/diff-view.ts @@ -10,7 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { mergeAttributes, Node } from '@tiptap/core'; +import { Node } from '@tiptap/core'; export default Node.create({ name: 'diffView', @@ -23,18 +23,55 @@ export default Node.create({ class: { default: '', }, + 'data-testid': { + default: '', + parseHTML: (element) => element.getAttribute('data-testid'), + renderHTML: (attributes) => { + if (!attributes['data-testid']) { + return {}; + } + + return { + 'data-testid': attributes['data-testid'], + }; + }, + }, + 'data-diff': { + default: true, + parseHTML: (element) => element.getAttribute('data-diff'), + renderHTML: (attributes) => { + if (!attributes['data-diff']) { + return {}; + } + + return { + 'data-diff': attributes['data-diff'], + }; + }, + }, }; }, parseHTML() { return [ { - tag: 'diff-view', + tag: 'span[data-diff]', }, ]; }, - renderHTML({ HTMLAttributes }) { - return ['diff-view', mergeAttributes(HTMLAttributes), 0]; + renderHTML({ HTMLAttributes, node }) { + const diffNode = document.createElement('span'); + + Object.keys(HTMLAttributes).forEach((key) => { + diffNode.setAttribute(key, HTMLAttributes[key]); + }); + + diffNode.setAttribute('data-diff', 'true'); + diffNode.innerHTML = node.textContent; + + return { + dom: diffNode, + }; }, }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/block-editor.less b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/block-editor.less index b3853974b9f4..6141fa7ff5bc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/block-editor.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/block-editor.less @@ -203,6 +203,28 @@ .tippy-box { max-width: 400px !important; } + + &.block-editor-wrapper--bar-menu { + border: 1px solid #dadde6; + border-radius: 4px; + .om-block-editor { + max-height: 300px; + overflow: auto; + padding-left: 50px; + padding-right: 50px; + padding-top: 8px; + &:focus-visible { + outline: none; + } + } + } + + // if contenteditable is false, remove padding-bottom from last p tag + .om-block-editor[contenteditable='false'] { + > p:last-child { + padding-bottom: 0; + } + } } .menu-wrapper { @@ -388,7 +410,7 @@ width: 100%; } } -.om-image-node-embed-link-btn-col { +body .om-image-node-embed-link-btn-col { display: flex; justify-content: flex-end; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Container/ContainerChildren/ContainerChildren.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Container/ContainerChildren/ContainerChildren.test.tsx index d21604ae6abe..6a1914b02a77 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Container/ContainerChildren/ContainerChildren.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Container/ContainerChildren/ContainerChildren.test.tsx @@ -39,6 +39,15 @@ const mockDataProps = { }; describe('ContainerChildren', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + it('Should call fetch container function on load', () => { render( @@ -88,6 +97,9 @@ describe('ContainerChildren', () => { ); + // Fast-forward until all timers have been executed + jest.runAllTimers(); + const richTextPreviewers = screen.getAllByTestId('viewer-container'); expect(richTextPreviewers).toHaveLength(2); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Container/ContainerChildren/ContainerChildren.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Container/ContainerChildren/ContainerChildren.tsx index 405187c63ea9..30892f78a478 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Container/ContainerChildren/ContainerChildren.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Container/ContainerChildren/ContainerChildren.tsx @@ -21,7 +21,7 @@ import { Container } from '../../../generated/entity/data/container'; import { EntityReference } from '../../../generated/type/entityReference'; import { getColumnSorter, getEntityName } from '../../../utils/EntityUtils'; import ErrorPlaceHolder from '../../common/ErrorWithPlaceholder/ErrorPlaceHolder'; -import RichTextEditorPreviewer from '../../common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../common/RichTextEditor/RichTextEditorPreviewerV1'; import Table from '../../common/Table/Table'; interface ContainerChildrenProps { @@ -66,7 +66,7 @@ const ContainerChildren: FC = ({ render: (description: EntityReference['description']) => ( <> {description ? ( - + ) : ( {t('label.no-entity', { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Container/ContainerDataModel/ContainerDataModel.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Container/ContainerDataModel/ContainerDataModel.test.tsx index 6511b6fec06f..0c1372021156 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Container/ContainerDataModel/ContainerDataModel.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Container/ContainerDataModel/ContainerDataModel.test.tsx @@ -110,7 +110,7 @@ jest.mock('../../../utils/ContainerDetailUtils', () => ({ })); jest.mock( - '../../../components/common/RichTextEditor/RichTextEditorPreviewer', + '../../../components/common/RichTextEditor/RichTextEditorPreviewerV1', () => jest .fn() diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardDetails/DashboardDetails.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardDetails/DashboardDetails.test.tsx index 3b74b8e89cba..a87e3f79ea4a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardDetails/DashboardDetails.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardDetails/DashboardDetails.test.tsx @@ -99,7 +99,7 @@ jest.mock('../../common/TabsLabel/TabsLabel.component', () => { jest.mock('../../common/EntityDescription/DescriptionV1', () => { return jest.fn().mockReturnValue(

Description Component

); }); -jest.mock('../../common/RichTextEditor/RichTextEditorPreviewer', () => { +jest.mock('../../common/RichTextEditor/RichTextEditorPreviewerV1', () => { return jest.fn().mockReturnValue(

RichTextEditorPreviwer

); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardVersion/DashboardVersion.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardVersion/DashboardVersion.component.tsx index 395002a904bb..ef13f05e6b3c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardVersion/DashboardVersion.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardVersion/DashboardVersion.component.tsx @@ -39,7 +39,7 @@ import { import { CustomPropertyTable } from '../../common/CustomPropertyTable/CustomPropertyTable'; import DescriptionV1 from '../../common/EntityDescription/DescriptionV1'; import Loader from '../../common/Loader/Loader'; -import RichTextEditorPreviewer from '../../common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../common/RichTextEditor/RichTextEditorPreviewerV1'; import TabsLabel from '../../common/TabsLabel/TabsLabel.component'; import DataAssetsVersionHeader from '../../DataAssets/DataAssetsVersionHeader/DataAssetsVersionHeader'; import DataProductsContainer from '../../DataProducts/DataProductsContainer/DataProductsContainer.component'; @@ -133,7 +133,7 @@ const DashboardVersion: FC = ({ key: 'description', render: (text) => text ? ( - + ) : ( {t('label.no-description')} ), diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardVersion/DashboardVersion.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardVersion/DashboardVersion.test.tsx index 2943e2cf4e75..19a9e725fe85 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardVersion/DashboardVersion.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardVersion/DashboardVersion.test.tsx @@ -26,7 +26,7 @@ import { DashboardVersionProp } from './DashboardVersion.interface'; const mockPush = jest.fn(); -jest.mock('../../common/RichTextEditor/RichTextEditorPreviewer', () => { +jest.mock('../../common/RichTextEditor/RichTextEditorPreviewerV1', () => { return jest .fn() .mockImplementation(() =>
RichTextEditorPreviewer.component
); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DataModel/DataModels/DataModelsTable.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DataModel/DataModels/DataModelsTable.tsx index 1ac09ad1089b..fd72c640b8e8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DataModel/DataModels/DataModelsTable.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DataModel/DataModels/DataModelsTable.tsx @@ -36,7 +36,7 @@ import { showErrorToast } from '../../../../utils/ToastUtils'; import ErrorPlaceHolder from '../../../common/ErrorWithPlaceholder/ErrorPlaceHolder'; import NextPrevious from '../../../common/NextPrevious/NextPrevious'; import { NextPreviousProps } from '../../../common/NextPrevious/NextPrevious.interface'; -import RichTextEditorPreviewer from '../../../common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../../common/RichTextEditor/RichTextEditorPreviewerV1'; import Table from '../../../common/Table/Table'; const DataModelTable = () => { @@ -86,7 +86,7 @@ const DataModelTable = () => { key: 'description', render: (description: ServicePageData['description']) => !isUndefined(description) && description.trim() ? ( - + ) : ( {t('label.no-entity', { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/EditTestCaseModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/EditTestCaseModal.tsx index 645a2914ded2..d81dd6a40776 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/EditTestCaseModal.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/EditTestCaseModal.tsx @@ -323,7 +323,6 @@ const EditTestCaseModal: React.FC = ({ trigger="onTextChange" valuePropName="initialValue"> = ({ name="description" trigger="onTextChange"> (); const [isLoading, setIsLoading] = useState(false); const [options, setOptions] = useState([]); @@ -216,7 +214,6 @@ export const TestCaseStatusModal = ({ }, ]}> form.setFieldValue( [ diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestSuite/AddTestSuiteForm/AddTestSuiteForm.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestSuite/AddTestSuiteForm/AddTestSuiteForm.tsx index bd6a7621f19b..2b587d39bd50 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestSuite/AddTestSuiteForm/AddTestSuiteForm.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestSuite/AddTestSuiteForm/AddTestSuiteForm.tsx @@ -104,7 +104,6 @@ const AddTestSuiteForm: React.FC = ({ form.setFieldsValue({ description: value })} /> diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/DatabaseSchema/DatabaseSchemaTable/DatabaseSchemaTable.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/DatabaseSchema/DatabaseSchemaTable/DatabaseSchemaTable.tsx index 64148e9108e6..19858045499e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/DatabaseSchema/DatabaseSchemaTable/DatabaseSchemaTable.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/DatabaseSchema/DatabaseSchemaTable/DatabaseSchemaTable.tsx @@ -48,7 +48,7 @@ import DisplayName from '../../../common/DisplayName/DisplayName'; import ErrorPlaceHolder from '../../../common/ErrorWithPlaceholder/ErrorPlaceHolder'; import NextPrevious from '../../../common/NextPrevious/NextPrevious'; import { PagingHandlerParams } from '../../../common/NextPrevious/NextPrevious.interface'; -import RichTextEditorPreviewer from '../../../common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../../common/RichTextEditor/RichTextEditorPreviewerV1'; import Searchbar from '../../../common/SearchBarComponent/SearchBar.component'; import Table from '../../../common/Table/Table'; import { EntityName } from '../../../Modals/EntityNameModal/EntityNameModal.interface'; @@ -244,7 +244,7 @@ export const DatabaseSchemaTable = ({ key: 'description', render: (text: string) => text?.trim() ? ( - + ) : ( {t('label.no-entity', { entity: t('label.description') })} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ColumnSummary.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ColumnSummary.tsx index 1b7758190ae4..a0967669f6fa 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ColumnSummary.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ColumnSummary.tsx @@ -15,7 +15,7 @@ import { isEmpty } from 'lodash'; import React, { FC } from 'react'; import { Column } from '../../../../generated/entity/data/container'; import { getEntityName } from '../../../../utils/EntityUtils'; -import RichTextEditorPreviewer from '../../../common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../../common/RichTextEditor/RichTextEditorPreviewerV1'; import TagsViewer from '../../../Tag/TagsViewer/TagsViewer'; interface ColumnSummaryProps { @@ -31,7 +31,7 @@ const ColumnSummary: FC = ({ column }) => { {`(${column.dataType})`} - { }; }); -jest.mock('../../common/RichTextEditor/RichTextEditorPreviewer', () => { +jest.mock('../../common/RichTextEditor/RichTextEditorPreviewerV1', () => { return jest.fn().mockReturnValue(

RichTextEditorPreviewer

); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/TableDataCardBody/TableDataCardBody.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/TableDataCardBody/TableDataCardBody.test.tsx index 705dfa4c01ff..307ee62f7b85 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/TableDataCardBody/TableDataCardBody.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/TableDataCardBody/TableDataCardBody.test.tsx @@ -16,7 +16,7 @@ import React from 'react'; import { TAG_CONSTANT } from '../../../constants/Tag.constants'; import TableDataCardBody from './TableDataCardBody'; -jest.mock('../../common/RichTextEditor/RichTextEditorPreviewer', () => { +jest.mock('../../common/RichTextEditor/RichTextEditorPreviewerV1', () => { return jest.fn().mockReturnValue(

RichTextEditorPreviewer

); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/TableDataCardBody/TableDataCardBody.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/TableDataCardBody/TableDataCardBody.tsx index a38cdfe00014..1827ec5cc354 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/TableDataCardBody/TableDataCardBody.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/TableDataCardBody/TableDataCardBody.tsx @@ -18,7 +18,7 @@ import { useTranslation } from 'react-i18next'; import { TagLabel } from '../../../generated/type/tagLabel'; import { getTagValue } from '../../../utils/CommonUtils'; import EntitySummaryDetails from '../../common/EntitySummaryDetails/EntitySummaryDetails'; -import RichTextEditorPreviewer from '../../common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../common/RichTextEditor/RichTextEditorPreviewerV1'; import TagsViewer from '../../Tag/TagsViewer/TagsViewer'; type Props = { @@ -38,7 +38,7 @@ const TableDataCardBody: FunctionComponent = ({
{description.trim() ? ( - ; + return ; } else { return ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/TableDescription/TableDescription.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/TableDescription/TableDescription.test.tsx index 0da7d0c4cab3..2c3d679509bc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/TableDescription/TableDescription.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/TableDescription/TableDescription.test.tsx @@ -20,7 +20,7 @@ jest.mock('../../../pages/TasksPage/EntityTasks/EntityTasks.component', () => { return jest.fn().mockReturnValue(

EntityTasks

); }); -jest.mock('../../common/RichTextEditor/RichTextEditorPreviewer', () => { +jest.mock('../../common/RichTextEditor/RichTextEditorPreviewerV1', () => { return jest.fn().mockReturnValue(

RichTextEditorPreviewer

); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Domain/SubDomainsTable/SubDomainsTable.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Domain/SubDomainsTable/SubDomainsTable.component.tsx index 81d341d72aac..19a1958621f5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Domain/SubDomainsTable/SubDomainsTable.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Domain/SubDomainsTable/SubDomainsTable.component.tsx @@ -26,7 +26,7 @@ import { getDomainDetailsPath } from '../../../utils/RouterUtils'; import ErrorPlaceHolder from '../../common/ErrorWithPlaceholder/ErrorPlaceHolder'; import Loader from '../../common/Loader/Loader'; import { OwnerLabel } from '../../common/OwnerLabel/OwnerLabel.component'; -import RichTextEditorPreviewer from '../../common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../common/RichTextEditor/RichTextEditorPreviewerV1'; import { SubDomainsTableProps } from './SubDomainsTable.interface'; const SubDomainsTable = ({ @@ -63,7 +63,7 @@ const SubDomainsTable = ({ key: 'description', render: (description: string) => description.trim() ? ( - (); const updatedAssignees = Form.useWatch('assignees', assigneesForm); const { permissions } = usePermissionProvider(); const { task: taskDetails } = taskThread; @@ -1015,12 +1013,10 @@ export const TaskTab = ({ ]} trigger="onTextChange"> diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTabIncidentManagerHeader/TaskTabIncidentManagerHeader.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTabIncidentManagerHeader/TaskTabIncidentManagerHeader.component.tsx index 06d197178461..e58c79f86b31 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTabIncidentManagerHeader/TaskTabIncidentManagerHeader.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTabIncidentManagerHeader/TaskTabIncidentManagerHeader.component.tsx @@ -22,7 +22,7 @@ import { formatDateTime } from '../../../../utils/date-time/DateTimeUtils'; import { getEntityName } from '../../../../utils/EntityUtils'; import { useActivityFeedProvider } from '../../../ActivityFeed/ActivityFeedProvider/ActivityFeedProvider'; import { OwnerLabel } from '../../../common/OwnerLabel/OwnerLabel.component'; -import RichTextEditorPreviewer from '../../../common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../../common/RichTextEditor/RichTextEditorPreviewerV1'; import Severity from '../../../DataQuality/IncidentManager/Severity/Severity.component'; import './task-tab-incident-manager-header.style.less'; @@ -172,7 +172,7 @@ const TaskTabIncidentManagerHeader = ({ thread }: { thread: Thread }) => { {`${t('label.failure-comment')}: `} -
Severity.component
); } ); -jest.mock('../../../common/RichTextEditor/RichTextEditorPreviewer', () => { +jest.mock('../../../common/RichTextEditor/RichTextEditorPreviewerV1', () => { return jest .fn() .mockImplementation(() =>
RichTextEditorPreviewer.component
); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/VersionTable/VersionTable.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/VersionTable/VersionTable.component.tsx index 668987b72345..a56a440b474f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/VersionTable/VersionTable.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/VersionTable/VersionTable.component.tsx @@ -32,7 +32,7 @@ import { prepareConstraintIcon, } from '../../../utils/TableUtils'; import FilterTablePlaceHolder from '../../common/ErrorWithPlaceholder/FilterTablePlaceHolder'; -import RichTextEditorPreviewer from '../../common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../common/RichTextEditor/RichTextEditorPreviewerV1'; import Searchbar from '../../common/SearchBarComponent/SearchBar.component'; import TagsViewer from '../../Tag/TagsViewer/TagsViewer'; import { VersionTableProps } from './VersionTable.interfaces'; @@ -119,10 +119,10 @@ function VersionTable({
{deletedConstraintIcon} {addedConstraintIcon} - +
{!isEmpty(record.displayName) ? ( - + ) : null}
); @@ -158,13 +158,13 @@ function VersionTable({ return dataTypeDisplay ? ( }>
-
@@ -183,7 +183,7 @@ function VersionTable({ render: (description: T['description']) => description ? ( <> - + {getFrequentlyJoinedColumns( columnName, joins ?? [], diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/VersionTable/VersionTable.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/VersionTable/VersionTable.test.tsx index e5f0bdbc15c4..9d1e41ce055e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/VersionTable/VersionTable.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/VersionTable/VersionTable.test.tsx @@ -28,7 +28,7 @@ jest.mock('../../Tag/TagsViewer/TagsViewer', () => jest.fn().mockImplementation(() =>
TagsViewer
) ); -jest.mock('../../common/RichTextEditor/RichTextEditorPreviewer', () => +jest.mock('../../common/RichTextEditor/RichTextEditorPreviewerV1', () => jest .fn() .mockImplementation(({ markdown }) => ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/EntitySummaryPanel/DataProductSummary/DataProductSummary.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Explore/EntitySummaryPanel/DataProductSummary/DataProductSummary.component.tsx index 5a19a085866d..ca7c0b0e6ba0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Explore/EntitySummaryPanel/DataProductSummary/DataProductSummary.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/EntitySummaryPanel/DataProductSummary/DataProductSummary.component.tsx @@ -16,7 +16,7 @@ import { useTranslation } from 'react-i18next'; import { DataProduct } from '../../../../generated/entity/domains/dataProduct'; import { getEntityName } from '../../../../utils/EntityUtils'; import { OwnerLabel } from '../../../common/OwnerLabel/OwnerLabel.component'; -import RichTextEditorPreviewer from '../../../common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../../common/RichTextEditor/RichTextEditorPreviewerV1'; import SummaryPanelSkeleton from '../../../common/Skeleton/SummaryPanelSkeleton/SummaryPanelSkeleton.component'; interface DataProductSummaryProps { @@ -62,7 +62,7 @@ const DataProductSummary = ({
{entityDetails.description?.trim() ? ( - diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/EntitySummaryPanel/SummaryList/SummaryListItems/SummaryListItems.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Explore/EntitySummaryPanel/SummaryList/SummaryListItems/SummaryListItems.component.tsx index 722a94324d08..88fb1b75e24a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Explore/EntitySummaryPanel/SummaryList/SummaryListItems/SummaryListItems.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/EntitySummaryPanel/SummaryList/SummaryListItems/SummaryListItems.component.tsx @@ -17,7 +17,7 @@ import { useTranslation } from 'react-i18next'; import { MAX_CHAR_LIMIT_ENTITY_SUMMARY } from '../../../../../constants/constants'; import { getTagValue } from '../../../../../utils/CommonUtils'; import { prepareConstraintIcon } from '../../../../../utils/TableUtils'; -import RichTextEditorPreviewer from '../../../../common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../../../common/RichTextEditor/RichTextEditorPreviewerV1'; import TagsViewer from '../../../../Tag/TagsViewer/TagsViewer'; import { SummaryListItemProps } from './SummaryListItems.interface'; @@ -72,7 +72,7 @@ function SummaryListItem({
{entityDetails.description ? ( - diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/EntitySummaryPanel/SummaryList/SummaryListItems/SummaryListItems.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Explore/EntitySummaryPanel/SummaryList/SummaryListItems/SummaryListItems.test.tsx index 6682a2d91135..42ee8ca96978 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Explore/EntitySummaryPanel/SummaryList/SummaryListItems/SummaryListItems.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/EntitySummaryPanel/SummaryList/SummaryListItems/SummaryListItems.test.tsx @@ -21,7 +21,7 @@ import { } from '../../mocks/SummaryListItems.mock'; import SummaryListItem from './SummaryListItems.component'; -jest.mock('../../../../common/RichTextEditor/RichTextEditorPreviewer', () => +jest.mock('../../../../common/RichTextEditor/RichTextEditorPreviewerV1', () => jest .fn() .mockImplementation(() => ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx index 147f3dff4c19..1c6b5246158a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx @@ -47,7 +47,7 @@ import { ReactComponent as UpDownArrowIcon } from '../../../assets/svg/ic-up-dow import { ReactComponent as PlusOutlinedIcon } from '../../../assets/svg/plus-outlined.svg'; import ErrorPlaceHolder from '../../../components/common/ErrorWithPlaceholder/ErrorPlaceHolder'; import { OwnerLabel } from '../../../components/common/OwnerLabel/OwnerLabel.component'; -import RichTextEditorPreviewer from '../../../components/common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../../components/common/RichTextEditor/RichTextEditorPreviewerV1'; import StatusBadge from '../../../components/common/StatusBadge/StatusBadge.component'; import { API_RES_MAX_SIZE, @@ -187,7 +187,7 @@ const GlossaryTermTab = ({ width: permissions.Create ? '21%' : '33%', render: (description: string) => description.trim() ? ( - ({ .mockImplementation(() => Promise.resolve({ data: mockedGlossaryTerms })), patchGlossaryTerm: jest.fn().mockImplementation(() => Promise.resolve()), })); -jest.mock('../../common/RichTextEditor/RichTextEditorPreviewer', () => +jest.mock('../../common/RichTextEditor/RichTextEditorPreviewerV1', () => jest .fn() .mockImplementation(({ markdown }) => ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MlModel/MlModelDetail/MlModelFeaturesList.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MlModel/MlModelDetail/MlModelFeaturesList.test.tsx index af3a60eb66b7..8389d5255a33 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MlModel/MlModelDetail/MlModelFeaturesList.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/MlModel/MlModelDetail/MlModelFeaturesList.test.tsx @@ -122,7 +122,7 @@ jest.mock('../../../utils/CommonUtils', () => ({ getHtmlForNonAdminAction: jest.fn().mockReturnValue('admin action'), })); -jest.mock('../../common/RichTextEditor/RichTextEditorPreviewer', () => { +jest.mock('../../common/RichTextEditor/RichTextEditorPreviewerV1', () => { return jest.fn().mockReturnValue(

RichTextEditorPreviewer

); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MlModel/MlModelVersion/MlModelVersion.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MlModel/MlModelVersion/MlModelVersion.component.tsx index e0cfe79ad904..940a20155b89 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MlModel/MlModelVersion/MlModelVersion.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/MlModel/MlModelVersion/MlModelVersion.component.tsx @@ -42,7 +42,7 @@ import { CustomPropertyTable } from '../../common/CustomPropertyTable/CustomProp import DescriptionV1 from '../../common/EntityDescription/DescriptionV1'; import ErrorPlaceHolder from '../../common/ErrorWithPlaceholder/ErrorPlaceHolder'; import Loader from '../../common/Loader/Loader'; -import RichTextEditorPreviewer from '../../common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../common/RichTextEditor/RichTextEditorPreviewerV1'; import TabsLabel from '../../common/TabsLabel/TabsLabel.component'; import DataAssetsVersionHeader from '../../DataAssets/DataAssetsVersionHeader/DataAssetsVersionHeader'; import DataProductsContainer from '../../DataProducts/DataProductsContainer/DataProductsContainer.component'; @@ -251,7 +251,7 @@ const MlModelVersion: FC = ({
{feature.description ? ( - diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Modals/ModalWithMarkdownEditor/ModalWithMarkdownEditor.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Modals/ModalWithMarkdownEditor/ModalWithMarkdownEditor.tsx index 10683e3ebf5a..d9638f795d1b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Modals/ModalWithMarkdownEditor/ModalWithMarkdownEditor.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Modals/ModalWithMarkdownEditor/ModalWithMarkdownEditor.tsx @@ -35,13 +35,15 @@ export const ModalWithMarkdownEditor: FunctionComponent(false); - const markdownRef = useRef(); + const markdownRef = useRef({} as EditorContentRef); const handleSaveData = async () => { if (markdownRef.current) { setIsLoading(true); try { - await onSave?.(markdownRef.current?.getEditorContent().trim() ?? ''); + const content = + markdownRef.current?.getEditorContent?.()?.trim() ?? ''; + await onSave?.(content); } catch (error) { showErrorToast(error as AxiosError); } finally { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Modals/WhatsNewModal/ChangeLogs.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Modals/WhatsNewModal/ChangeLogs.tsx index e32b1b6cb5cd..5f3566a0041f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Modals/WhatsNewModal/ChangeLogs.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Modals/WhatsNewModal/ChangeLogs.tsx @@ -14,7 +14,7 @@ /* eslint-disable max-len */ import React from 'react'; -import RichTextEditorPreviewer from '../../common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../common/RichTextEditor/RichTextEditorPreviewerV1'; type Props = { data: { [name: string]: string }; @@ -29,13 +29,13 @@ const ChangeLogs = ({ data }: Props) => {

-

- diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Modals/WhatsNewModal/FeaturesCarousel.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Modals/WhatsNewModal/FeaturesCarousel.tsx index e342d8d11965..4c9875370cfe 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Modals/WhatsNewModal/FeaturesCarousel.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Modals/WhatsNewModal/FeaturesCarousel.tsx @@ -14,7 +14,7 @@ import { Carousel } from 'antd'; import { uniqueId } from 'lodash'; import React from 'react'; -import RichTextEditorPreviewer from '../../common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../common/RichTextEditor/RichTextEditorPreviewerV1'; import { FeaturesCarouselProps } from './FeaturesCarousel.interface'; const FeaturesCarousel = ({ data }: FeaturesCarouselProps) => { @@ -25,7 +25,7 @@ const FeaturesCarousel = ({ data }: FeaturesCarouselProps) => {

{d.title}

- diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/Persona/PersonaDetailsCard/PersonaDetailsCard.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MyData/Persona/PersonaDetailsCard/PersonaDetailsCard.tsx index adaa39a25b7b..c74672f2852c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/Persona/PersonaDetailsCard/PersonaDetailsCard.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/MyData/Persona/PersonaDetailsCard/PersonaDetailsCard.tsx @@ -17,7 +17,7 @@ import { useHistory } from 'react-router-dom'; import { Persona } from '../../../../generated/entity/teams/persona'; import { getEntityName } from '../../../../utils/EntityUtils'; import { getPersonaDetailsPath } from '../../../../utils/RouterUtils'; -import RichTextEditorPreviewer from '../../../common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../../common/RichTextEditor/RichTextEditorPreviewerV1'; interface PersonaDetailsCardProps { persona: Persona; @@ -43,7 +43,7 @@ export const PersonaDetailsCard = ({ persona }: PersonaDetailsCardProps) => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Pipeline/PipelineVersion/PipelineVersion.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Pipeline/PipelineVersion/PipelineVersion.component.tsx index 96265ac21dcd..134f87b8a03f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Pipeline/PipelineVersion/PipelineVersion.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Pipeline/PipelineVersion/PipelineVersion.component.tsx @@ -37,7 +37,7 @@ import { getFilterTags } from '../../../utils/TableTags/TableTags.utils'; import { CustomPropertyTable } from '../../common/CustomPropertyTable/CustomPropertyTable'; import DescriptionV1 from '../../common/EntityDescription/DescriptionV1'; import Loader from '../../common/Loader/Loader'; -import RichTextEditorPreviewer from '../../common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../common/RichTextEditor/RichTextEditorPreviewerV1'; import TabsLabel from '../../common/TabsLabel/TabsLabel.component'; import DataAssetsVersionHeader from '../../DataAssets/DataAssetsVersionHeader/DataAssetsVersionHeader'; import DataProductsContainer from '../../DataProducts/DataProductsContainer/DataProductsContainer.component'; @@ -111,14 +111,14 @@ const PipelineVersion: FC = ({ key: 'name', width: 250, render: (_, record) => ( - + ), }, { title: t('label.task-entity', { entity: t('label.type-lowercase') }), dataIndex: 'taskType', key: 'taskType', - render: (taskType) => , + render: (taskType) => , }, { title: t('label.description'), @@ -126,7 +126,7 @@ const PipelineVersion: FC = ({ key: 'description', render: (text) => text ? ( - + ) : ( {t('label.no-description')} ), diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Pipeline/PipelineVersion/PipelineVersion.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Pipeline/PipelineVersion/PipelineVersion.test.tsx index d22292e56ce1..1266e55e1ca5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Pipeline/PipelineVersion/PipelineVersion.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Pipeline/PipelineVersion/PipelineVersion.test.tsx @@ -36,7 +36,7 @@ jest.mock('../../Tag/TagsContainerV2/TagsContainerV2', () => jest.fn().mockImplementation(() =>
TagsContainerV2
) ); -jest.mock('../../common/RichTextEditor/RichTextEditorPreviewer', () => +jest.mock('../../common/RichTextEditor/RichTextEditorPreviewerV1', () => jest .fn() .mockImplementation(({ markdown }) => ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/ApplicationCard/ApplicationCard.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/ApplicationCard/ApplicationCard.component.tsx index 942cc8916d61..04ed2c0f20f5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/ApplicationCard/ApplicationCard.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/ApplicationCard/ApplicationCard.component.tsx @@ -16,7 +16,7 @@ import classNames from 'classnames'; import { kebabCase } from 'lodash'; import React from 'react'; import { useTranslation } from 'react-i18next'; -import RichTextEditorPreviewer from '../../../common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../../common/RichTextEditor/RichTextEditorPreviewerV1'; import AppLogo from '../AppLogo/AppLogo.component'; import { ApplicationCardProps } from './ApplicationCard.interface'; @@ -59,7 +59,7 @@ const ApplicationCard = ({ )}
{showDescription && ( - diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/ApplicationCard/ApplicationCard.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/ApplicationCard/ApplicationCard.test.tsx index ad6d28fa89be..198fcc8259a6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/ApplicationCard/ApplicationCard.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/ApplicationCard/ApplicationCard.test.tsx @@ -24,9 +24,21 @@ const props = { }; describe('ApplicationCard', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + it('renders the title correctly', () => { render(); + // Fast-forward until all timers have been executed + jest.runAllTimers(); + expect(screen.getByText('Search Index')).toBeInTheDocument(); expect(screen.getByText('Hello World')).toBeInTheDocument(); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/MarketPlaceAppDetails/MarketPlaceAppDetails.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/MarketPlaceAppDetails/MarketPlaceAppDetails.component.tsx index 21307cfa4ae2..c4344e8a801f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/MarketPlaceAppDetails/MarketPlaceAppDetails.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/MarketPlaceAppDetails/MarketPlaceAppDetails.component.tsx @@ -39,7 +39,7 @@ import { getEntityName } from '../../../../utils/EntityUtils'; import { getAppInstallPath } from '../../../../utils/RouterUtils'; import { showErrorToast } from '../../../../utils/ToastUtils'; import Loader from '../../../common/Loader/Loader'; -import RichTextEditorPreviewer from '../../../common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../../common/RichTextEditor/RichTextEditorPreviewerV1'; import PageLayoutV1 from '../../../PageLayoutV1/PageLayoutV1'; import applicationsClassBase from '../AppDetails/ApplicationsClassBase'; import AppLogo from '../AppLogo/AppLogo.component'; @@ -268,7 +268,7 @@ const MarketPlaceAppDetails = () => {
- diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/MarketPlaceAppDetails/MarketPlaceAppDetails.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/MarketPlaceAppDetails/MarketPlaceAppDetails.test.tsx index 5160c722c627..1ae2322c9ba5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/MarketPlaceAppDetails/MarketPlaceAppDetails.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/MarketPlaceAppDetails/MarketPlaceAppDetails.test.tsx @@ -41,7 +41,7 @@ jest.mock('react-router-dom', () => ({ })), })); -jest.mock('../../../common/RichTextEditor/RichTextEditorPreviewer', () => +jest.mock('../../../common/RichTextEditor/RichTextEditorPreviewerV1', () => jest.fn().mockReturnValue(<>RichTextEditorPreviewer) ); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Bot/BotListV1/BotListV1.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Bot/BotListV1/BotListV1.component.tsx index d63e26c2a0e8..7c6b402f7cb8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Bot/BotListV1/BotListV1.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Bot/BotListV1/BotListV1.component.tsx @@ -42,7 +42,7 @@ import ErrorPlaceHolder from '../../../common/ErrorWithPlaceholder/ErrorPlaceHol import FilterTablePlaceHolder from '../../../common/ErrorWithPlaceholder/FilterTablePlaceHolder'; import NextPrevious from '../../../common/NextPrevious/NextPrevious'; import { PagingHandlerParams } from '../../../common/NextPrevious/NextPrevious.interface'; -import RichTextEditorPreviewer from '../../../common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../../common/RichTextEditor/RichTextEditorPreviewerV1'; import Searchbar from '../../../common/SearchBarComponent/SearchBar.component'; import Table from '../../../common/Table/Table'; import TitleBreadcrumb from '../../../common/TitleBreadcrumb/TitleBreadcrumb.component'; @@ -134,7 +134,7 @@ const BotListV1 = ({ key: 'description', render: (_, record) => record?.description ? ( - + ) : ( {t('label.no-entity', { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/CustomProperty/CustomPropertyTable.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/CustomProperty/CustomPropertyTable.test.tsx index af99a66dbf39..4a3a20a3bd78 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/CustomProperty/CustomPropertyTable.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/CustomProperty/CustomPropertyTable.test.tsx @@ -21,7 +21,7 @@ import { import React from 'react'; import { CustomPropertyTable } from './CustomPropertyTable'; -jest.mock('../../common/RichTextEditor/RichTextEditorPreviewer', () => { +jest.mock('../../common/RichTextEditor/RichTextEditorPreviewerV1', () => { return jest.fn().mockReturnValue(

RichTextEditorPreview

); }); jest.mock('../../common/ErrorWithPlaceholder/ErrorPlaceHolder', () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/CustomProperty/CustomPropertyTable.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/CustomProperty/CustomPropertyTable.tsx index d0f40ce95863..b81f80aefb72 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/CustomProperty/CustomPropertyTable.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/CustomProperty/CustomPropertyTable.tsx @@ -25,7 +25,7 @@ import { ERROR_PLACEHOLDER_TYPE, OPERATION } from '../../../enums/common.enum'; import { CustomProperty } from '../../../generated/type/customProperty'; import { columnSorter, getEntityName } from '../../../utils/EntityUtils'; import ErrorPlaceHolder from '../../common/ErrorWithPlaceholder/ErrorPlaceHolder'; -import RichTextEditorPreviewer from '../../common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../common/RichTextEditor/RichTextEditorPreviewerV1'; import Table from '../../common/Table/Table'; import ConfirmationModal from '../../Modals/ConfirmationModal/ConfirmationModal'; import './custom-property-table.less'; @@ -200,7 +200,7 @@ export const CustomPropertyTable: FC = ({ width: 300, render: (text) => text ? ( - + ) : ( jest.fn().mockImplementation(() =>
ButtonSkeleton
) ); -jest.mock('../../../../common/RichTextEditor/RichTextEditorPreviewer', () => +jest.mock('../../../../common/RichTextEditor/RichTextEditorPreviewerV1', () => jest.fn().mockImplementation(() =>
RichTextEditorPreviewer
) ); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/IngestionListTable.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/IngestionListTable.tsx index 86738d11dc52..d576ed3b6317 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/IngestionListTable.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/IngestionListTable.tsx @@ -46,7 +46,7 @@ import { showSuccessToast, } from '../../../../../utils/ToastUtils'; import NextPrevious from '../../../../common/NextPrevious/NextPrevious'; -import RichTextEditorPreviewer from '../../../../common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../../../common/RichTextEditor/RichTextEditorPreviewerV1'; import ButtonSkeleton from '../../../../common/Skeleton/CommonSkeletons/ControlElements/ControlElements.component'; import Table from '../../../../common/Table/Table'; import EntityDeleteModal from '../../../../Modals/EntityDeleteModal/EntityDeleteModal'; @@ -257,7 +257,7 @@ function IngestionListTable({ key: 'description', render: (description: string) => !isUndefined(description) && description.trim() ? ( - diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Services.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Services.test.tsx index c19344bfe717..0eab37f464e2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Services.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Services.test.tsx @@ -208,7 +208,7 @@ jest.mock('../../common/ListView/ListView.component', () => ({ )), })); -jest.mock('../../common/RichTextEditor/RichTextEditorPreviewer', () => { +jest.mock('../../common/RichTextEditor/RichTextEditorPreviewerV1', () => { return jest .fn() .mockReturnValue( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Services.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Services.tsx index 49b4d14cce26..a6df62acd5f5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Services.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Services.tsx @@ -62,7 +62,7 @@ import { ListView } from '../../common/ListView/ListView.component'; import NextPrevious from '../../common/NextPrevious/NextPrevious'; import { PagingHandlerParams } from '../../common/NextPrevious/NextPrevious.interface'; import { OwnerLabel } from '../../common/OwnerLabel/OwnerLabel.component'; -import RichTextEditorPreviewer from '../../common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../common/RichTextEditor/RichTextEditorPreviewerV1'; import ButtonSkeleton from '../../common/Skeleton/CommonSkeletons/ControlElements/ControlElements.component'; import { ColumnFilter } from '../../Database/ColumnFilter/ColumnFilter.component'; import PageHeader from '../../PageHeader/PageHeader.component'; @@ -326,7 +326,7 @@ const Services = ({ serviceName }: ServicesProps) => { width: 200, render: (description) => description ? ( - { className="p-t-xs text-grey-body break-all description-text" data-testid="service-description"> {service.description ? ( - ( - + ), }, { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamHierarchy.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamHierarchy.tsx index d4e27a3ae18c..16c981780649 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamHierarchy.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamHierarchy.tsx @@ -40,7 +40,7 @@ import { isDropRestricted } from '../../../../utils/TeamUtils'; import { showErrorToast, showSuccessToast } from '../../../../utils/ToastUtils'; import { DraggableBodyRowProps } from '../../../common/Draggable/DraggableBodyRowProps.interface'; import FilterTablePlaceHolder from '../../../common/ErrorWithPlaceholder/FilterTablePlaceHolder'; -import RichTextEditorPreviewer from '../../../common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../../common/RichTextEditor/RichTextEditorPreviewerV1'; import Table from '../../../common/Table/Table'; import { MovedTeamProps, TeamHierarchyProps } from './team.interface'; import './teams.less'; @@ -136,7 +136,7 @@ const TeamHierarchy: FC = ({ {NO_DATA_PLACEHOLDER} ) : ( - ({ })); describe('SuggestionsAlert', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + const mockSuggestion: Suggestion = { id: '1', description: 'Test suggestion', @@ -54,6 +63,8 @@ describe('SuggestionsAlert', () => { ); + // Fast-forward until all timers have been executed + jest.runAllTimers(); expect(screen.getByText(/Test suggestion/i)).toBeInTheDocument(); expect(screen.getByText(/Test User/i)).toBeInTheDocument(); @@ -66,6 +77,9 @@ describe('SuggestionsAlert', () => { ); + // Fast-forward until all timers have been executed + jest.runAllTimers(); + expect(screen.getByText(/Test suggestion/i)).toBeInTheDocument(); expect(screen.getByText(/Test User/i)).toBeInTheDocument(); expect(screen.getByTestId('reject-suggestion')).toBeInTheDocument(); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Suggestions/SuggestionsAlert/SuggestionsAlert.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Suggestions/SuggestionsAlert/SuggestionsAlert.tsx index 2cc7eca98d38..0ba977f3f9c1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Suggestions/SuggestionsAlert/SuggestionsAlert.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Suggestions/SuggestionsAlert/SuggestionsAlert.tsx @@ -17,7 +17,7 @@ import { useTranslation } from 'react-i18next'; import { ReactComponent as StarIcon } from '../../../assets/svg/ic-suggestions-coloured.svg'; import UserPopOverCard from '../../common/PopOverCard/UserPopOverCard'; import ProfilePicture from '../../common/ProfilePicture/ProfilePicture'; -import RichTextEditorPreviewer from '../../common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../common/RichTextEditor/RichTextEditorPreviewerV1'; import { useSuggestionsContext } from '../SuggestionsProvider/SuggestionsProvider'; import { SuggestionAction } from '../SuggestionsProvider/SuggestionsProvider.interface'; import './suggestions-alert.less'; @@ -40,7 +40,7 @@ const SuggestionsAlert = ({ return (
- diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicDetails/TopicDetails.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicDetails/TopicDetails.test.tsx index e4704ba657ec..706a22220b35 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicDetails/TopicDetails.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicDetails/TopicDetails.test.tsx @@ -92,7 +92,7 @@ jest.mock('../../PageLayoutV1/PageLayoutV1', () => { return jest.fn().mockImplementation(({ children }) =>
{children}
); }); -jest.mock('../../common/RichTextEditor/RichTextEditorPreviewer', () => { +jest.mock('../../common/RichTextEditor/RichTextEditorPreviewerV1', () => { return jest.fn().mockReturnValue(

RichTextEditorPreviwer

); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicSchema/TopicSchema.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicSchema/TopicSchema.test.tsx index 6c94f7e53151..48d2a748cb99 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicSchema/TopicSchema.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicSchema/TopicSchema.test.tsx @@ -50,7 +50,7 @@ jest.mock('../../../utils/GlossaryUtils', () => ({ getGlossaryTermsList: jest.fn().mockImplementation(() => Promise.resolve([])), })); -jest.mock('../../common/RichTextEditor/RichTextEditorPreviewer', () => +jest.mock('../../common/RichTextEditor/RichTextEditorPreviewerV1', () => jest .fn() .mockReturnValue( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicSchema/TopicSchema.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicSchema/TopicSchema.tsx index cd7d72b1f5fa..94078e18f770 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicSchema/TopicSchema.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicSchema/TopicSchema.tsx @@ -46,7 +46,7 @@ import { updateFieldTags, } from '../../../utils/TableUtils'; import ErrorPlaceHolder from '../../common/ErrorWithPlaceholder/ErrorPlaceHolder'; -import RichTextEditorPreviewer from '../../common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../common/RichTextEditor/RichTextEditorPreviewerV1'; import ToggleExpandButton from '../../common/ToggleExpandButton/ToggleExpandButton'; import { ColumnFilter } from '../../Database/ColumnFilter/ColumnFilter.component'; import SchemaEditor from '../../Database/SchemaEditor/SchemaEditor'; @@ -134,7 +134,7 @@ const TopicSchemaFields: FC = ({ {isVersionView ? ( - + ) : ( getEntityName(record) )} @@ -149,7 +149,7 @@ const TopicSchemaFields: FC = ({ (dataType: DataTypeTopic, record: Field) => ( {isVersionView ? ( - ) : ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/AirflowMessageBanner/AirflowMessageBanner.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/AirflowMessageBanner/AirflowMessageBanner.tsx index c457865a22cf..508cf5fa3c3a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/AirflowMessageBanner/AirflowMessageBanner.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/AirflowMessageBanner/AirflowMessageBanner.tsx @@ -16,7 +16,7 @@ import { isEmpty } from 'lodash'; import React, { FC } from 'react'; import { ReactComponent as IconRetry } from '../../../assets/svg/ic-retry-icon.svg'; import { useAirflowStatus } from '../../../hooks/useAirflowStatus'; -import RichTextEditorPreviewer from '../../common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../RichTextEditor/RichTextEditorPreviewerV1'; import './airflow-message-banner.less'; const AirflowMessageBanner: FC = ({ className }) => { @@ -33,7 +33,7 @@ const AirflowMessageBanner: FC = ({ className }) => { data-testid="no-airflow-placeholder" size={16}> - diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/ExtensionTable.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/ExtensionTable.tsx index ebe0a1ed6d40..4e25677a768b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/ExtensionTable.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/ExtensionTable.tsx @@ -15,7 +15,7 @@ import { ColumnsType } from 'antd/lib/table'; import { isObject, isString, map } from 'lodash'; import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import RichTextEditorPreviewer from '../RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../RichTextEditor/RichTextEditorPreviewerV1'; import { ExtentionEntities, ExtentionEntitiesKeys, @@ -53,7 +53,7 @@ export const ExtensionTable = ({ const isStringValue = isString(value); if (isStringValue) { - return ; + return ; } return ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/PropertyValue.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/PropertyValue.test.tsx index 8867057441e4..3e272052b99c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/PropertyValue.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/PropertyValue.test.tsx @@ -16,7 +16,7 @@ import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import { PropertyValue } from './PropertyValue'; -jest.mock('../../common/RichTextEditor/RichTextEditorPreviewer', () => { +jest.mock('../../common/RichTextEditor/RichTextEditorPreviewerV1', () => { return jest .fn() .mockReturnValue( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/PropertyValue.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/PropertyValue.tsx index 3206fdb845b9..20d276522084 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/PropertyValue.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/PropertyValue.tsx @@ -76,7 +76,7 @@ import SchemaEditor from '../../Database/SchemaEditor/SchemaEditor'; import { ModalWithMarkdownEditor } from '../../Modals/ModalWithMarkdownEditor/ModalWithMarkdownEditor'; import InlineEdit from '../InlineEdit/InlineEdit.component'; import ProfilePicture from '../ProfilePicture/ProfilePicture'; -import RichTextEditorPreviewer from '../RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../RichTextEditor/RichTextEditorPreviewerV1'; import { PropertyValueProps, PropertyValueType, @@ -755,7 +755,7 @@ export const PropertyValue: FC = ({ const isKeyAdded = versionDataKeys?.includes(propertyName); return ( - @@ -763,7 +763,7 @@ export const PropertyValue: FC = ({ } switch (propertyType.name) { case 'markdown': - return ; + return ; case 'enum': return ( @@ -1034,7 +1034,7 @@ export const PropertyValue: FC = ({ {!isRenderedInRightPanel && (
- { - const hasDescription = Boolean(description.trim()); + const hasDescription = !isDescriptionContentEmpty(description.trim()); const isTaskEntity = TASK_ENTITIES.includes(entityType as EntityType); @@ -188,15 +189,13 @@ const DescriptionV1 = ({ if (suggestionData) { return suggestionData; } else { - return description.trim() ? ( - - ) : ( - {t('label.no-description')} ); } }, [description, suggestionData, isDescriptionExpanded]); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/EditorToolBar.ts b/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/EditorToolBar.ts deleted file mode 100644 index 9dff3a59718e..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/EditorToolBar.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* - * 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 MarkdownIcon from '../../../assets/svg/markdown.svg'; -import i18n from '../../../utils/i18next/LocalUtil'; - -/** - * Read more : https://nhn.github.io/tui.editor/latest/tutorial-example15-customizing-toolbar-buttons - * @returns HTMLElement for toolbar - */ -const markdownButton = (): HTMLButtonElement => { - const button = document.createElement('button'); - - button.className = 'toastui-editor-toolbar-icons markdown-icon'; - button.style.backgroundImage = 'none'; - button.type = 'button'; - button.style.margin = '0'; - button.style.marginTop = '4px'; - button.innerHTML = ` - - markdown-icon - `; - - return button; -}; - -export const EDITOR_TOOLBAR_ITEMS = [ - 'heading', - 'bold', - 'italic', - 'strike', - 'ul', - 'ol', - 'link', - 'hr', - 'quote', - 'code', - 'codeblock', - { - name: i18n.t('label.markdown-guide'), - el: markdownButton(), - tooltip: i18n.t('label.markdown-guide'), - }, -]; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditor.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditor.interface.ts index 9dddec599d89..ee03c664f158 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditor.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditor.interface.ts @@ -47,19 +47,11 @@ export type EditorType = 'markdown' | 'wysiwyg'; export interface RichTextEditorProp extends HTMLAttributes { autofocus?: boolean; initialValue?: string; - placeHolder?: string; - previewStyle?: PreviewStyle; - editorType?: EditorType; - previewHighlight?: boolean; - extendedAutolinks?: boolean; - hideModeSwitch?: boolean; - useCommandShortcut?: boolean; readonly?: boolean; - height?: string; onTextChange?: (value: string) => void; + placeHolder?: string; } export interface EditorContentRef { getEditorContent: () => string; - clearEditorContent: () => void; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditor.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditor.test.tsx deleted file mode 100644 index a1c1e8952b17..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditor.test.tsx +++ /dev/null @@ -1,77 +0,0 @@ -/* - * 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 { findByTestId, queryByTestId, render } from '@testing-library/react'; -import React, { Component } from 'react'; -import { MemoryRouter } from 'react-router-dom'; -import RichTextEditor from './RichTextEditor'; - -jest.mock('@toast-ui/react-editor', () => { - class Editor extends Component { - // eslint-disable-next-line @typescript-eslint/no-empty-function - getInstance() {} - - // eslint-disable-next-line @typescript-eslint/no-empty-function - getRootElement() {} - - render() { - return

Editor

; - } - } - - class Viewer extends Component { - // eslint-disable-next-line @typescript-eslint/no-empty-function - getInstance() {} - - // eslint-disable-next-line @typescript-eslint/no-empty-function - getRootElement() {} - - render() { - return

Viewer

; - } - } - - return { - Editor, - Viewer, - }; -}); - -const mockProp = { - initialValue: '', - readonly: false, -}; - -describe('Test RichText Editor', () => { - it('Should render rich text editor', async () => { - const { container } = render(, { - wrapper: MemoryRouter, - }); - - const editor = await findByTestId(container, 'editor'); - - expect(editor).toBeInTheDocument(); - }); - - it('Should render viewer if readOnly is true', async () => { - const { container } = render(, { - wrapper: MemoryRouter, - }); - - const editor = queryByTestId(container, 'editor'); - const viewer = await findByTestId(container, 'viewer'); - - expect(editor).not.toBeInTheDocument(); - expect(viewer).toBeInTheDocument(); - }); -}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditor.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditor.tsx index 7e67ffd30ed3..495c8f35c7f8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditor.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditor.tsx @@ -13,147 +13,55 @@ /* eslint-disable */ -import { Editor, Viewer } from '@toast-ui/react-editor'; import classNames from 'classnames'; -import { uniqueId } from 'lodash'; -import React, { - createRef, - forwardRef, - useEffect, - useImperativeHandle, - useState, -} from 'react'; -import { useTranslation } from 'react-i18next'; -import i18n from '../../../utils/i18next/LocalUtil'; -import { customHTMLRenderer } from './CustomHtmlRederer/CustomHtmlRederer'; -import { EDITOR_TOOLBAR_ITEMS } from './EditorToolBar'; -import './rich-text-editor.less'; -import { editorRef, RichTextEditorProp } from './RichTextEditor.interface'; +import React, { forwardRef, useImperativeHandle, useRef } from 'react'; +import { formatContent } from '../../../utils/BlockEditorUtils'; +import BlockEditor from '../../BlockEditor/BlockEditor'; +import { BlockEditorRef } from '../../BlockEditor/BlockEditor.interface'; +import { + EditorContentRef, + RichTextEditorProp, +} from './RichTextEditor.interface'; -const RichTextEditor = forwardRef( +const RichTextEditor = forwardRef( ( { - placeHolder = 'Write your description', - previewStyle = 'tab', - editorType = 'markdown', - previewHighlight = false, - useCommandShortcut = true, - extendedAutolinks = true, - hideModeSwitch = true, autofocus = false, initialValue = '', readonly, - height, className, style, + placeholder, onTextChange, }: RichTextEditorProp, ref ) => { - const { t } = useTranslation(); - const richTextEditorRef = createRef(); + const editorRef = useRef({} as BlockEditorRef); - const [editorValue, setEditorValue] = useState(initialValue); - const [imageBlobError, setImageBlobError] = useState(null); - - const onChangeHandler = () => { - const value = richTextEditorRef.current - ?.getInstance() - .getMarkdown() as string; - setEditorValue(value); - onTextChange && onTextChange(value); + const onChangeHandler = (backendFormatHtmlContent: string) => { + onTextChange && onTextChange(backendFormatHtmlContent); }; useImperativeHandle(ref, () => ({ getEditorContent() { - return editorValue; - }, - clearEditorContent() { - richTextEditorRef.current?.getInstance().setMarkdown(''); + const htmlContent = editorRef.current?.editor?.getHTML() ?? ''; + const backendFormat = formatContent(htmlContent, 'server'); + + return backendFormat; }, })); - useEffect(() => { - setEditorValue(initialValue); - }, [initialValue]); - - // handle the direction of the editor - useEffect(() => { - const dir = i18n.dir(); - const editorElement = document.querySelector('.toastui-editor.md-mode'); - const previewElement = document.querySelector( - '.toastui-editor-md-preview' - ); - const textAlign = dir === 'rtl' ? 'right' : 'left'; - if (editorElement) { - editorElement.setAttribute('dir', dir); - editorElement.setAttribute('style', `text-align: ${textAlign};`); - } - - if (previewElement) { - previewElement.setAttribute('dir', dir); - previewElement.setAttribute('style', `text-align: ${textAlign};`); - } - }, []); - return ( -
- {readonly ? ( -
- -
- ) : ( -
- { - setImageBlobError(null); - }, 3000); - }, - }} - autofocus={autofocus} - extendedAutolinks={extendedAutolinks} - height={height ?? '320px'} - hideModeSwitch={hideModeSwitch} - initialEditType={editorType} - initialValue={editorValue} - placeholder={placeHolder} - previewHighlight={previewHighlight} - previewStyle={previewStyle} - ref={richTextEditorRef} - customHTMLRenderer={customHTMLRenderer} - toolbarItems={[EDITOR_TOOLBAR_ITEMS]} - useCommandShortcut={useCommandShortcut} - onChange={onChangeHandler} - /> - {imageBlobError && ( -
-
-
- {imageBlobError} -
-
-
-
- )} -
- )} +
+
); } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditorPreviewerV1.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditorPreviewerV1.tsx new file mode 100644 index 000000000000..9a050872ae0f --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditorPreviewerV1.tsx @@ -0,0 +1,104 @@ +/* + * 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 { Button } from 'antd'; +import classNames from 'classnames'; +import React, { FC, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { DESCRIPTION_MAX_PREVIEW_CHARACTERS } from '../../../constants/constants'; +import { + formatContent, + isDescriptionContentEmpty, +} from '../../../utils/BlockEditorUtils'; +import { getTrimmedContent } from '../../../utils/CommonUtils'; +import BlockEditor from '../../BlockEditor/BlockEditor'; +import './rich-text-editor-previewerV1.less'; +import { PreviewerProp } from './RichTextEditor.interface'; + +const RichTextEditorPreviewerV1: FC = ({ + markdown = '', + className = '', + enableSeeMoreVariant = true, + textVariant = 'black', + showReadMoreBtn = true, + maxLength = DESCRIPTION_MAX_PREVIEW_CHARACTERS, + isDescriptionExpanded = false, + reducePreviewLineClass, +}) => { + const { t, i18n } = useTranslation(); + const [content, setContent] = useState(''); + + const [readMore, setReadMore] = useState(false); + + const handleReadMoreToggle = () => setReadMore((pre) => !pre); + + // whether has read more content or not + const hasReadMore = useMemo( + () => enableSeeMoreVariant && markdown.length > maxLength, + [enableSeeMoreVariant, markdown, maxLength] + ); + + /** + * if hasReadMore is true then value will be based on read more state + * else value will be content + */ + const viewerValue = useMemo(() => { + if (hasReadMore) { + return readMore ? content : `${getTrimmedContent(content, maxLength)}...`; + } + + return content; + }, [hasReadMore, readMore, maxLength, content]); + + useEffect(() => { + setContent(formatContent(markdown, 'client')); + }, [markdown]); + + useEffect(() => { + setReadMore(Boolean(isDescriptionExpanded)); + }, [isDescriptionExpanded]); + + // if markdown is empty then show no description placeholder + if (isDescriptionContentEmpty(markdown)) { + return {t('label.no-description')}; + } + + return ( +
+
+ +
+ {hasReadMore && showReadMoreBtn && ( + + )} +
+ ); +}; + +export default RichTextEditorPreviewerV1; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/rich-text-editor-previewerV1.less b/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/rich-text-editor-previewerV1.less new file mode 100644 index 000000000000..d15bb69436ee --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/rich-text-editor-previewerV1.less @@ -0,0 +1,40 @@ +/* + * 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 url('./../../../styles/variables.less'); + +.rich-text-editor-container { + .ant-btn { + padding: 0px; + margin: 0px; + line-height: 0px; + height: 16px; + } + + .ant-btn:focus, + .ant-btn:hover { + color: @primary-color; + } + + &.text-grey-muted { + .markdown-parser { + .om-block-editor { + p { + color: @grey-4; + } + ul li::before { + background-color: @grey-4; + } + } + } + } +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/rich-text-editor.less b/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/rich-text-editor.less deleted file mode 100644 index b60802c2d8f3..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/rich-text-editor.less +++ /dev/null @@ -1,1603 +0,0 @@ -/* - * 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. - */ -/*! - * @toast-ui/editor - * @version 3.1.3 | Thu Feb 10 2022 - * @author NHN FE Development Lab - * @license MIT - */ - -@import (reference) url('../../../styles/variables.less'); - -.ProseMirror { - position: relative; -} - -.ProseMirror { - word-wrap: break-word; - white-space: pre-wrap; - white-space: break-spaces; - -webkit-font-variant-ligatures: none; - font-variant-ligatures: none; - font-feature-settings: 'liga' 0; /* the above doesn't seem to work in Edge */ -} - -.ProseMirror pre { - white-space: pre-wrap; -} - -.ProseMirror li { - position: relative; -} - -.ProseMirror-hideselection *::selection { - background: transparent; -} -.ProseMirror-hideselection *::-moz-selection { - background: transparent; -} -.ProseMirror-hideselection { - caret-color: transparent; -} - -.ProseMirror-selectednode { - outline: 2px solid #8cf; -} - -/* Make sure li selections wrap around markers */ - -li.ProseMirror-selectednode { - outline: none; -} - -li.ProseMirror-selectednode:after { - content: ''; - position: absolute; - left: -32px; - right: -2px; - top: -2px; - bottom: -2px; - border: 2px solid #8cf; - pointer-events: none; -} - -/* height */ -.auto-height, -.auto-height .toastui-editor-defaultUI { - height: auto; -} - -.auto-height .toastui-editor-md-container { - position: relative; -} - -:not(.auto-height) > .toastui-editor-defaultUI, -:not(.auto-height) > .toastui-editor-defaultUI > .toastui-editor-main { - display: -ms-flexbox; - display: flex; - -ms-flex-direction: column; - flex-direction: column; -} - -:not(.auto-height) > .toastui-editor-defaultUI > .toastui-editor-main { - -ms-flex: 1; - flex: 1; -} - -/* toastui editor */ -.toastui-editor-md-container::after, -.toastui-editor-defaultUI-toolbar::after { - content: ''; - display: block; - height: 0; - clear: both; -} - -.toastui-editor-main { - min-height: 0px; - position: relative; - height: inherit; - box-sizing: border-box; -} - -.toastui-editor-md-container { - display: none; - overflow: hidden; - height: 100%; -} - -.toastui-editor-md-container .toastui-editor { - line-height: 1.5; - position: relative; -} - -.toastui-editor-md-container .toastui-editor, -.toastui-editor-md-container .toastui-editor-md-preview { - box-sizing: border-box; - padding: 0; - height: inherit; -} - -.toastui-editor-md-container .toastui-editor-md-preview { - overflow: auto; - padding: 0 25px; - height: 100%; -} - -.toastui-editor-md-container .toastui-editor-md-preview > p:first-child { - margin-top: 0 !important; -} - -.toastui-editor-md-container - .toastui-editor-md-preview - .toastui-editor-contents { - padding-top: 8px; -} - -.toastui-editor-main .toastui-editor-md-tab-style > .toastui-editor, -.toastui-editor-main .toastui-editor-md-tab-style > .toastui-editor-md-preview { - width: 100%; - display: none; -} - -.toastui-editor-main .toastui-editor-md-tab-style > .active { - display: block; -} - -.toastui-editor-main .toastui-editor-md-vertical-style > .toastui-editor-tabs { - display: none; -} - -.toastui-editor-main .toastui-editor-md-tab-style > .toastui-editor-tabs { - display: block; -} - -.toastui-editor-main .toastui-editor-md-vertical-style .toastui-editor { - width: 50%; -} - -.toastui-editor-main - .toastui-editor-md-vertical-style - .toastui-editor-md-preview { - width: 50%; -} - -.toastui-editor-main .toastui-editor-md-splitter { - display: none; - height: 100%; - width: 1px; - background-color: #ebedf2; - position: absolute; - left: 50%; -} - -.toastui-editor-main - .toastui-editor-md-vertical-style - .toastui-editor-md-splitter { - display: block; -} - -.toastui-editor-ww-container { - display: none; - overflow: hidden; - height: inherit; - background-color: #fff; -} - -.auto-height .toastui-editor-main-container { - position: relative; -} - -.toastui-editor-main-container { - position: absolute; - line-height: 1; - color: #222; - width: 100%; - height: inherit; -} - -.toastui-editor-ww-container > .toastui-editor { - height: inherit; - position: relative; - width: 100%; -} - -.toastui-editor-ww-container .toastui-editor-contents { - overflow: auto; - box-sizing: border-box; - margin: 0px; - padding: 16px 25px 0px 25px; - height: inherit; -} - -.toastui-editor-ww-container .toastui-editor-contents p { - margin: 0; -} - -.toastui-editor-md-mode .toastui-editor-md-container, -.toastui-editor-ww-mode .toastui-editor-ww-container { - display: block; - z-index: 20; -} - -.toastui-editor-md-mode .toastui-editor-md-vertical-style { - display: -ms-flexbox; - display: flex; -} - -.toastui-editor-main.hidden, -.toastui-editor-defaultUI.hidden { - display: none; -} - -/* default UI Styles */ -.toastui-editor-defaultUI .ProseMirror { - padding: 18px 25px; - font-size: 14px; -} - -.toastui-editor-defaultUI { - position: relative; - border: 1px solid #dadde6; - height: 100%; - border-radius: 4px; -} - -.toastui-editor-defaultUI button { - color: #333; - height: 28px; - font-size: 13px; - cursor: pointer; - border: none; - border-radius: 2px; -} - -.toastui-editor-defaultUI .toastui-editor-ok-button { - min-width: 63px; - height: 32px; - background-color: @primary-color; - color: #fff; - outline-color: @primary-color; -} - -.toastui-editor-defaultUI .toastui-editor-ok-button:hover { - background-color: @primary-color; -} - -.toastui-editor-defaultUI .toastui-editor-close-button { - min-width: 63px; - height: 32px; - background-color: #f7f9fc; - border: 1px solid #dadde6; - margin-right: 5px; - outline-color: #cbcfdb; -} - -.toastui-editor-defaultUI .toastui-editor-close-button:hover { - border-color: #cbcfdb; -} - -/* mode switch tab */ -.toastui-editor-mode-switch { - background-color: #fff; - border-top: 1px solid #dadde6; - font-size: 12px; - text-align: right; - height: 28px; - padding-right: 10px; - border-radius: 0 0 3px 3px; -} - -.toastui-editor-mode-switch .tab-item { - display: inline-block; - width: 96px; - height: 24px; - line-height: 24px; - text-align: center; - background: #f7f9fc; - color: #969aa5; - margin-top: -1px; - margin-right: -1px; - cursor: pointer; - border: 1px solid #dadde6; - border-radius: 0 0 4px 4px; - font-weight: 500; - box-sizing: border-box; -} - -.toastui-editor-mode-switch .tab-item.active { - border-top: 1px solid #fff; - background-color: #fff; - color: #555; -} - -/* markdown tab */ -.toastui-editor-defaultUI .toastui-editor-md-tab-container { - float: left; - height: 45px; - font-size: 13px; - background: #f7f9fc; - border-bottom: 1px solid #ebedf2; - border-top-left-radius: 3px; -} - -.toastui-editor-md-tab-container .toastui-editor-tabs { - margin-left: 10px; - height: 100%; -} - -.toastui-editor-md-tab-container .tab-item { - display: inline-block; - width: 70px; - height: 33px; - line-height: 33px; - font-size: 14px; - font-weight: 500; - text-align: center; - background: #eaedf1; - color: #969aa5; - cursor: pointer; - border: 1px solid #dadde6; - border-radius: 4px 4px 0 0; - box-sizing: border-box; - margin-top: 13px; -} - -.toastui-editor-md-tab-container .tab-item.active { - border-bottom: 1px solid #fff; - background-color: #fff; - color: #555; -} - -.toastui-editor-md-tab-container .tab-item:last-child { - margin-left: -1px; -} - -/* toolbar */ -.toastui-editor-defaultUI-toolbar { - display: -ms-flexbox; - display: flex; - height: 45px; - background-color: #f7f9fc; - border-bottom: 1px solid #ebedf2; - border-radius: 3px 3px 0 0; -} - -.toastui-editor-toolbar { - height: 46px; - box-sizing: border-box; -} - -.toastui-editor-toolbar-divider { - display: inline-block; - width: 1px; - height: 18px; - background-color: #e1e3e9; - margin: 14px 12px; -} - -.toastui-editor-toolbar-group { - display: -ms-flexbox; - display: flex; -} - -.toastui-editor-defaultUI-toolbar button { - box-sizing: border-box; - cursor: pointer; - width: 32px; - height: 32px; - padding: 0; - border-radius: 3px; - margin: 10px 5px; - border: 1px solid #f7f9fc; - border-bottom: none; -} - -.toastui-editor-defaultUI-toolbar .scroll-sync { - display: inline-block; - position: relative; - width: 70px; - height: 10px; - text-align: center; - line-height: 10px; - color: #81858f; - cursor: pointer; -} - -.toastui-editor-defaultUI-toolbar .scroll-sync::before { - content: 'Scroll'; - position: absolute; - left: 0; - font-size: 14px; -} - -.toastui-editor-defaultUI-toolbar .scroll-sync.active::before { - color: @primary-color; -} - -.toastui-editor-defaultUI-toolbar .scroll-sync input { - opacity: 0; - width: 0; - height: 0; -} - -.toastui-editor-defaultUI-toolbar .switch { - position: absolute; - top: 0; - left: 45px; - right: 0; - bottom: 0; - background-color: #d6d8de; - -webkit-transition: 0.4s; - transition: 0.4s; - border-radius: 50px; -} - -.toastui-editor-defaultUI-toolbar input:checked + .switch { - background-color: #acddfa; -} - -.toastui-editor-defaultUI-toolbar .switch::before { - position: absolute; - content: ''; - height: 14px; - width: 14px; - left: 0px; - bottom: -2px; - background-color: #94979f; - -webkit-transition: 0.4s; - transition: 0.4s; - border-radius: 50%; -} - -.toastui-editor-defaultUI-toolbar input:checked + .switch::before { - background-color: @primary-color; - -webkit-transform: translateX(12px); - -moz-transform: translateX(12px); - -ms-transform: translateX(12px); - transform: translateX(12px); -} - -.toastui-editor-dropdown-toolbar .scroll-sync { - margin: 0 5px; -} - -.toastui-editor-dropdown-toolbar { - position: absolute; - height: 46px; - z-index: 30; - border-radius: 2px; - box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.08); - border: 1px solid #dadde6; - background-color: #f7f9fc; - display: -ms-flexbox; - display: flex; -} - -.toastui-editor-toolbar-item-wrapper { - margin: 7px 5px; - height: 32px; - line-height: 32px; -} - -/* toolbar popup */ -.toastui-editor-popup { - width: 400px; - margin-right: auto; - background: #fff; - z-index: 30; - position: absolute; - border-radius: 2px; - box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.08); - border: 1px solid #dadde6; -} - -.toastui-editor-popup-body { - padding: 15px; - font-size: 12px; -} - -.toastui-editor-popup-body label { - font-weight: 600; - color: #555; - display: block; - margin: 20px 0 5px; -} - -.toastui-editor-popup-body .toastui-editor-button-container { - text-align: right; - margin-top: 20px; -} - -.toastui-editor-popup-body input[type='text'] { - width: calc(100% - 26px); - height: 30px; - padding: 0 12px; - border-radius: 2px; - border: 1px solid #e1e3e9; - color: #333; -} - -.toastui-editor-popup-body input[type='text']:focus { - outline: 1px solid @primary-color; - border-color: transparent; -} - -.toastui-editor-popup-body input[type='text'].disabled { - background-color: #f7f9fc; - border-color: #e1e3e9; - color: #969aa5; -} - -.toastui-editor-popup-body input[type='file'] { - opacity: 0; - border: none; - width: 1px; - height: 1px; - position: absolute; - top: 0; - left: 0; -} - -.toastui-editor-popup-body input.wrong, -.toastui-editor-popup-body span.wrong { - border-color: #fa2828; -} - -.toastui-editor-popup-add-link .toastui-editor-popup-body, -.toastui-editor-popup-add-image .toastui-editor-popup-body { - padding: 0 20px 20px; -} - -.toastui-editor-popup-add-image .toastui-editor-tabs { - margin: 5px 0 10px; -} - -.toastui-editor-popup-add-image .toastui-editor-tabs .tab-item { - display: inline-block; - width: 60px; - height: 40px; - line-height: 40px; - border-bottom: 1px solid #dadde6; - color: #333; - font-size: 13px; - font-weight: 600; - text-align: center; - cursor: pointer; - box-sizing: border-box; -} - -.toastui-editor-popup-add-image .toastui-editor-tabs .tab-item:hover { - border-bottom: 1px solid #cbcfdb; -} - -.toastui-editor-popup-add-image .toastui-editor-tabs .tab-item.active { - color: @primary-color; - border-bottom: 2px solid @primary-color; -} - -.toastui-editor-popup-add-image .toastui-editor-file-name { - width: 58%; - display: inline-block; - border-radius: 2px; - border: 1px solid #e1e3e9; - color: #dadde6; - height: 30px; - line-height: 30px; - padding: 0 12px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - cursor: pointer; -} - -.toastui-editor-popup-add-image .toastui-editor-file-name.has-file { - color: #333; -} - -.toastui-editor-popup-add-image .toastui-editor-file-select-button { - width: 33%; - margin-left: 5px; - height: 32px; - border-radius: 2px; - border: 1px solid #dadde6; - background-color: #f7f9fc; - vertical-align: top; -} - -.toastui-editor-popup-add-image .toastui-editor-file-select-button:hover { - border-color: #cbcfdb; -} - -.toastui-editor-popup-add-table { - width: auto; -} - -.toastui-editor-popup-add-table .toastui-editor-table-selection { - position: relative; -} - -.toastui-editor-popup-add-table .toastui-editor-table-cell { - display: table-cell; - width: 20px; - height: 20px; - border: 1px solid #e1e3e9; - background: #fff; - box-sizing: border-box; -} - -.toastui-editor-popup-add-table .toastui-editor-table-cell.header { - background: #f7f9fc; -} - -.toastui-editor-popup-add-table .toastui-editor-table-row { - display: table-row; -} - -.toastui-editor-popup-add-table .toastui-editor-table { - display: table; - border-collapse: collapse; -} - -.toastui-editor-popup-add-table .toastui-editor-table-selection-layer { - position: absolute; - top: 0; - left: 0; - border: 1px solid @primary-color; - background: rgba(0, 169, 255, 0.1); - z-index: 30; -} - -.toastui-editor-popup-add-table .toastui-editor-table-description { - margin: 5px 0 0; - text-align: center; - color: #333; -} - -.toastui-editor-popup-add-heading { - width: auto; -} - -.toastui-editor-popup-add-heading .toastui-editor-popup-body { - padding: 0; -} - -.toastui-editor-popup-add-heading h1, -.toastui-editor-popup-add-heading h2, -.toastui-editor-popup-add-heading h3, -.toastui-editor-popup-add-heading h4, -.toastui-editor-popup-add-heading h5, -.toastui-editor-popup-add-heading h6, -.toastui-editor-popup-add-heading ul, -.toastui-editor-popup-add-heading p { - padding: 0; - margin: 0; -} - -.toastui-editor-popup-add-heading ul { - padding: 5px 0; - list-style: none; -} - -.toastui-editor-popup-add-heading ul li { - padding: 4px 12px; - cursor: pointer; -} - -.toastui-editor-popup-add-heading ul li:hover { - background-color: #dff4ff; -} - -.toastui-editor-popup-add-heading h1 { - font-size: 24px; -} - -.toastui-editor-popup-add-heading h2 { - font-size: 22px; -} - -.toastui-editor-popup-add-heading h3 { - font-size: 20px; -} - -.toastui-editor-popup-add-heading h4 { - font-size: 18px; -} - -.toastui-editor-popup-add-heading h5 { - font-size: 16px; -} - -.toastui-editor-popup-add-heading h6 { - font-size: 14px; -} - -/* table context menu */ -.toastui-editor-context-menu { - position: absolute; - width: auto; - min-width: 197px; - color: #333; - border-radius: 2px; - box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.08); - border: 1px solid #dadde6; - z-index: 30; - padding: 5px 0; - background-color: #fff; -} - -.toastui-editor-context-menu .menu-group { - list-style: none; - border-bottom: 1px solid #ebedf2; - padding: 0; - margin: 0; - font-size: 13px; -} - -.toastui-editor-context-menu .menu-group:last-child { - border-bottom: none !important; -} - -.toastui-editor-context-menu .menu-item { - height: 32px; - line-height: 32px; - padding: 0 14px; - cursor: pointer; -} - -.toastui-editor-context-menu span { - display: inline-block; -} - -.toastui-editor-context-menu span::before { - background: url(data:image/png;base64,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) - no-repeat; - background-size: 466px 146px; - content: ''; - width: 20px; - height: 20px; - display: inline-block; - vertical-align: middle; - margin-right: 10px; -} - -.toastui-editor-context-menu .add-row-up::before { - background-position: 3px -104px; -} - -.toastui-editor-context-menu .add-row-down::before { - background-position: -19px -104px; -} - -.toastui-editor-context-menu .remove-row::before { - background-position: -41px -104px; -} - -.toastui-editor-context-menu .add-column-left::before { - background-position: -63px -104px; -} - -.toastui-editor-context-menu .add-column-right::before { - background-position: -85px -104px; -} - -.toastui-editor-context-menu .remove-column::before { - background-position: -111px -104px; -} - -.toastui-editor-context-menu .align-column-left::before { - background-position: -129px -104px; -} - -.toastui-editor-context-menu .align-column-center::before { - background-position: -151px -104px; -} - -.toastui-editor-context-menu .align-column-right::before { - background-position: -173px -104px; -} - -.toastui-editor-context-menu .remove-table::before { - background-position: -197px -104px; -} - -.toastui-editor-context-menu .disabled span::before { - opacity: 0.3; -} - -.toastui-editor-context-menu li:not(.disabled):hover { - background-color: #dff4ff; -} - -.toastui-editor-context-menu li.disabled { - color: #c9ccd5; -} - -.toastui-editor-tooltip { - position: absolute; - background-color: #444; - z-index: 40; - padding: 4px 7px; - font-size: 12px; - border-radius: 3px; - color: #fff; -} - -.toastui-editor-tooltip .arrow { - content: ''; - display: inline-block; - width: 10px; - height: 10px; - background-color: #444; - -webkit-transform: rotate(45deg); - -moz-transform: rotate(45deg); - -ms-transform: rotate(45deg); - -o-transform: rotate(45deg); - transform: rotate(45deg); - position: absolute; - top: -3px; - left: 6px; - z-index: -1; -} - -.toastui-editor-toolbar-icons { - background: url(data:image/png;base64,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) - no-repeat; - background-size: 466px 120px; -} - -@media only screen and (-webkit-min-device-pixel-ratio: 2), - only screen and (min--moz-device-pixel-ratio: 2), - only screen and (-o-min-device-pixel-ratio: 2/1), - only screen and (min-device-pixel-ratio: 2), - only screen and (min-resolution: 192dpi), - only screen and (min-resolution: 2dppx) { - .toastui-editor-toolbar-icons, - .toastui-editor-context-menu span::before { - background: url(data:image/png;base64,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P0A9dgflJJi2Szl5led4F57jlvpECs/6vuTjuhR3dAo961VP9UoRby5eaGBuxz2Zy/3pyiWBlJEiA/s5FHaEnU170tGmBChvV5O1/Zske9dQ3upqiLdbfuL3hTpvY+q8m3ELRkrj1xLs36Md8A17jhOutYwTnYufJw89tLmvbxmhJagIVEmgaoW0yvjlXQREQASakkA77LJL42wfGmdpGiktpZBagaFRfQqNsDNGSykNldFTUQ6+3ZQFWEJlRsA6pVmzZjML1sks0hoiosy9vMoqq2wVjg7VEMNQkHbLTzEI7tOqdCoeTd1nn7dK2tjwf2F5Gsro3cVhdS4CItCcBKSQNud9kVQiIAIikIqAjZLSQNu8lGeujZiyW8pvs7mHI6W2tb/tlF28jigvcRfSmLUpw+doZDQvxM0Xb40zpbLKiE03nWtLiOpVRp1A7ZYfl6+4yTPqo5hO4nndFPt4fi+OHTv2Qb5x/Y+4P9lFQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAREQAQ6mYDfyZlX3kVABESgnQgc/WRhlf5F3j6Dvjdw6UT/mnbKm/IiAiIgAiIgAiLQngSkkLbnfVWuREAEOohA7+OFsc8v8w4peN43yfZGlnW/y9t/9kT/2g7CoKyKgAiIgAiIgAi0IIGeFpRZIouACIhA3QSOm1fY4A3f28MiWqXg/fyCyf7CuiMdhQiOmFc4YOEy7zsk/fZhyRe89ww7b8GTI+YXthv0vBO9gjcZ8TdoUBYWer43r8vzzpk1yb+vQWkqmVEmcPQjhY36+rzv0amzI6I0qqy5XC9kdGDumDHeSRdv5T/nHOsx2y0/9bCoFPbIBYX/Vyh4H6KuebNf8BZ3dXl/fvM23oO9vo+TDhEQgUYQ0AhpIygrDREQgaYiMO2hwrYDg96dKDprB4L53qvdXd4ul2zj/66pBK0gzLR5hY8P+N5d5GNEXe773umzJ/m9FaJo2ssoo9+kNXhWUt4aITT8mPnsnYpS+u1GpKc0Ro+AKW8r+rxHkGCd0ZMiSPnlsWO8repVStstP3ndk6nzC5+ifjmDTogPFKfB8/8ibheu0eNdcO5W/tLi6zoXARHIlkDVI6Q8wGcjwjH8LqKxc0oW4uQRZzVyVduTSEW1hErs7zQB7+32vetmTvTvqia90fZrvKmATyAPYyvK4nsraJSdm9W9rpheDR5OnldYa7HvbV/wvXeQp/GDBe9VRjdeLHR7v5u9jf9sDVEqSJsTGBz0LqCsDCmjllfsgZvnfTSe9aqelXjAtPY6ny80pugZpl76J/l4NqlxVSxOs+fLRkbJx5nkZ4SiXZyXvM4ZMeki9TOR5b56R0pz550WQp3lzSXTbvmxkVHyNtrKqOFdJ5TlAMe6FrPd8lMLg3Jhri8Uuucs8M7jGT+2lD+uvZlrZy3u86Yc/lDhs5du4z9Zym+zuPNc7kvbp/+SSf7PmkUmySECaQlUrZBGioyPQuN5mSikecSZFkAtPYlUVGsQ/7tpLL27v+B9hUrgnkKX95VWqLBCLscge9SQLcvK/PlBB0Qm97psWlVePGpe4T39vjf9tYK3N/ekhzxFRzDPZsDzuDd/pUV72bhx3uzzt/BfjjzI0ukEtkwAkOSW/llJiLCiU53PFx1FdxyxoLA7ZX/DtxS8q5/3vZOwj+jtT5CjqfPF82vTdGlbBcecgX7vpMF+ryFTqrt6vA26ewIFZSfqlS46uk5Einqn7ubLOwRV0aizvMXib6v88OrYMZa3UbVmIcuwOLq93Wmb3NrITLGMYHee4V9YmsNkaaQQJdLqLRR6UEav4tneN+ZlOe2ch5iu+zjybkDn3rZcXz+8/h5vwLt/2vzCh1H0noqFaSrr4QsK+xcGvatp9li75wu8G65vKgEljAhUIFC1QkrtMqTIOLNCAqkuu7icmSpQNp6y6Emk4tqBlQYP0pP+uXp70rPJVcVYLqLyTT1CSmwXVYyxwR6ofA/pG/RmUB7HlUuae/P/eMGc/foy73ga7ofMmujfXM6/rnUMgUfJ6UeKcmtuxUf6Z6U4ZJpzRqzwVtfzRZm+zSVFQ8RZK5nNna+hNaNBHhqpjFqCoeJ7EkrpvECAmCzBeW1/+fJOK1MG5S1Mqt3y0+g1o+XuWBayRHE0Whm1jM2a7N9y+LyoLopkKZfpRlwzZfT5ImUU5fNXzHQ7lJluf3UyBCOo8+mI8j2CeKvgvh4K9s298wqTeyf7rzt/TWUWvKMjefxg0CQ6HS3L1+YV1lvqe0fRTtuVjr3NUPgHKBVPwfy2Mb53ycUTfZsWnXiUmbloOvc/uTfziOfqCdt412utbyLChjhOfbiwDfrPnsww244EN+SevNUS5rn5B8bfWY99HwN2N87e2n/I3MsdVU+HopKJaplLJ5N0BkcecaYVi7QNWiYVJjReAfwHW2ikNMA0dV7hGG7qhXbCDb1o9mS/5DSWIMAo/9Ho/iKl8L/jYsDe1v88Sj6ep9J7B+a7ON+U3/i4v+5u76OsE7w/7iZ75xGgEv1AYcC7gxdlS68hLb5zPBvWgJpu7jwTLbuGNP5O6FvubVicz0acjxnHsozwyOpd5+KT2VwE4uWtGSSrt7zF81NvXLXyaAYZ4rJTMSYpoxcyknhc3F/cfuS8wgeZhXUv74mhgRh2MYenbSCX6VEoFPxp873dBru9Qi0dCEc8Wth4cIX3l1Covp7VvQ1mvtf/lxPS4p/6sLdr14DnW2eBc8/TZIBmCsunLiWN1ZLSoa25jN8xyHN58fUqZy4uYDOwvWds5T9THI/O8yPAoNDeKKJn0dZ+d5pUuNd/Qj/6Fp+j+0kp/25KVKnrneCeiTJqoGgIvokb9P1Wg0bP1a5O5i56rpy9Gc1pjxXeBueZMdkep+G9My+VrfkddOkk/yQU6n15aUwau5b3ZvJzEn5fc/5RQkxJ1dHhBOit++2qBW9zysfh9jN7q21o1OG3UNkXAREQgVQEalFGLeKZk/0HaSSf7hKhrXEccWXebp4637swmOI84N1i051demnNwgrvQOcXGW+LK6Pmbsou045vsTToKKhrRo5Lp5xJx+gpKKM/xk+iMmphUWRWRZ7vMyBybnFcVc5cnNjf7z141COFdxbHo/PsCRhn7u8D3qB3Q1pl1KQI/BLGwpa6V5k/WNlnv7ViRFnagS3Ed24VqY//v8KqlJQdTV56MJatv4431+zNegy8EazncpXccyzm3RFFdE6SvBdv6r/BFOpz8LMJ179J/k4b9xZP6yqSYGXkZj2bVDhfmfpIwa2/qSrmesNXk5h95oXycZn9WvWTL9XkV35FQAREoNMI1KqMOk4fn+h9l7ZDMNpI+2795x8JPkHlLtdtmvJGJNF0WxslrSHSSCEl7FUVwh8dplnBW22XUaj3JANnutAoyH9gN4B9aIe92X6cf55rD7vr+P0a8hzuzs3Ebcfo3NZAMxvT/TZY1VuFTmSbn/k9/Nj0XRsMejNK7E/y6CyI5JDFY9nbDv193u/g/aFacVhYi8PiKo6j+jWkxTG06bkV/lJZC75f2OUdRA/QGTw5bipH5J1J8l/g5K7IoYkty18MHvyhdZi+N7f3nf7yJhbXpiH+GwU6OJibfuR5E/2XKsl73uTAT+bTbCql22nX2fThvXw64V7yvZ7X7x2GuW01DOoNX01a8isCIiACSQRqmSI+dpz3VWtYW3w0HM5bsdz7z6S4y7nFp4iX8zea13ofL4zlm8dnIMNBoRw/QkE4rXcL39bCN91RrzJqGdrX9wcOn1/4HW29T9u5P+BthvFbs9d7oLwdSjvyLBcP7ZtrZ23t3WbzXNMeRy0oTGY/DTdtcvGq63s/Lw77lknebaydvZa20352DfMs0n4habpscdhqzo9/vLDO68tZThXujk5+fsVnc/Ys+mzO/xz9ZOHWvte8H/PM7BPIw1KxIx8u/O/Mrf3Hw/SimYvFU5jDsjYff/OnPlS4jVlvv8LezW/i8w8FG1VdG8YhAwKmpL/0kDeBDa8mDAx4azO+P477Y7yqOvwubwvWiVpHQ916I/d9XeT5JUrpJ9kD4x4nSJezyExPIBhZmeh/l16a0xJDFbztE92b0JECFk3XpfJo7um6jxfWoCINFkwj62DP+OYezW3C252bSMEaFi94MaxXSyL1hq8lTYURAREQARFITyBURk8mhCkM9js5dEsfSYN8WkM8YQOjsmtGS4lGJ8Pz0bWMNgyykcRBz5vt4iWNX04Y5x3s++yCUcXRVxg2Xfen57/NX1Yc3Db9CeImDXfN0jYZ3HkWJsro11F2gn0ZaKP9dcya3r8VKaNBMjZ7bcKq3hTyPKTYM7AzOOBtU60MfNZvLumc58LRPjzA2WV63mHzChss/J338b5+b1L/oLchy/NWr0UZRYl9Czy/wa9uZTR2X8aglP40Pn1XCmmMTrXWVQa9HyWG8UdnE45EWSo48jCvVEgLza2QdvfHvhvpeS9RqS2qkD1dbgABemjfyoYKv3KdBST5mt8djJCmSr3e8KkSkScRGEUCNqvG1m7RI/xtevXfMYqiNCRpy2OQ13mFCy3vDUlUiTSCwEEJiSS5JXhrrBPK6Hd4J+3rUqWtU5MyauHREN8XxVNgh9c6D/vOMgrhNUQzNFLFjrFdq3qfr3ak2XYDRn0NRj1NJPJYcrquxW1p2O60ofjdJoPJEp7XZdj34JHlKBcJ9iPLtdGCvI7x9kDma5HpyrdMxKzhQKm9PgqWzY7oUXStarFNrNh0aHP2tJlMHlarNx/co6/xDKxZbzzF4W2klPW/Vzt3KaSORIYmlWBTT3t1WeVjz5si6yZ2ToF7spm/sWUyvq/Pe4GKK+g9RO71rQI0dx2jS4Cet+8jwTsDKXxvKTsZ75Zmi28ndb3hXTwyRSAtgZ5x3vuYInmL/caNjb43mDZ41f54IdxAoKOZkfINppg9OnVB4ctVR9IiASxvlscgr+yiGea9RaSXmO1AwHbHpY1gGxoGB0rLReV203X+ksyj5xXehXv0jWrWeD6Y5C+tGwrglkzTtWm14yyMtb3G+t5ul2zhL0kbh/M3d763Mw2iCeH5wrds481x15JMS8PSsjTD6+NMFpMpyX81bowO2NrRVcMwD6fZzXf2Vv4/uS/7sxnlv/f6fn816Tm/41f3/uzstA/XjewdbJn6kPdelk1Zua37YHT0I7S631N3RCUi4Dn9kO3Ya5elkJaAlMb5DdaRJvnjYf9DknuzubEWIhodpcDd1mzyFctz+GS/jxfLo879VW/lVBXnJrOxBGzKDy+h3cJUB5jGvmc1n9WpN3xjc9sSqbmGhgkbt7eE8I0Q0pRR6mjrjd/afgNd3idyT9f3PhxLYzxTlX7A7pI31rr5VyyuprFaXixPljeEGh8JNjzvkbMsLUkgaVZYktuoZg7N5tRIAN97YMIk7/jovAqLjTTR6PgB7aOxFoz2x6/pbI0+B1VFVIFXmzmAAng7J64zfSGt8F3KfY+zXBq8ew+MXb8GpY5Bz/JHkBZp4mth6HMtk6nemRvIEo1GE+8VYdy5G4uXBmt6h9IpDG0+lXuiTZyATdPNShm1bFLmp+SdXft8jKUhhbQG0jYFielIJ/MQn1Ei+BMl3JvKuZU+9xKBG9pKPDjlQTmXntCJ0TVZGkqAj4SvxtvvQpco92MmC9Rtg4FUR73hUyXSYZ5mTfSuRtmaYj+zd1j2K2Y3Ukb5RFfgmW9HM28tdZmtmEAJD/QCj3jX0oD7HA2Hx3iXfLZEsJZxDvJAXoI8FUmdlPciLzodBQL2LWZ2Nz2QDWZWSZu8bWCE3+/yM2XGft8N3bA2x3HCI4XVaUV/MpLG9w6NK2o2zZXOk2gKbuSvyBJ8u3O+dwnld4foUql9QyIPpS1HLyi8mU9l3IkPN4X9NTpwP806yGdLhyp9xb6QwPt3L+eDxX0lp+s6P860NC1tzl8L3TYw2QIZnacqTGPFs/9RF4QlOzc5e94m6a5UhFdOR8472aaM39ZNo9xtnplwXd7GxLVRZvGViIh7+G7qo20owzqSCMQ/6lx8PVgxXqYfqsf3Li8O02znVpm9/oK3o8mFItH0n3tx/Mas5c1gd7avWAHmt2q/593HS/XkCRO9S+IvHedfZn4EXujypvISe7ulQBl6nnuzslc6RbL1hk+RRFkvwW7ZvreHeVqFaUvt8OmXcEOMoGEyu2zuO+9ikjJKY3O/5SvqXxNWK03SX5/fTdRh/7Xm6t5x33uPv7jWuEYj3El/LIxftNS7gOm5Xx6N9JVmbQQYyT6m0D/UmbjiNW/qtMcLu6aZMhqucfw6qdqvKY/XrUHuRjR97++zJ/rDBgjmLPDuMiWTZ+6eCet4n076soApWEfMD753PtVlkk6+75X6xJzzU8qE7xrsvHwrcm0a+lmOQrgHnxyLZnyVClvKfdmLdGitnInwR76ZuqCU3yR3S5upunswsGJK8jiYbLrC825F1o+nKQvxOKc95r2T82jUt1YlOx5nGjujujuyPOBrzi/3KNNO2N5nCuOe/1cweudGoq+asK73raQy42RoVBiXXty03XQ5r3vNqIsTnh+iXDTkQJHec0SvbUNSbuNEuIE3z5jou0XjTZvTVvvciwNpu7NRkVuv4Auh22o8MBezgcFjvGD2tR4i51dmvgRohEY9k1D/VrkNDJIkqTd8Upxp3aY9VNh2me89wcv4UvuZ3dzShpe/1iJQShntX+79vhlyQh325deWeI9Mm1f4WDPIk0YGk9VkNtnT+Jef5iAQKKOxmS1I9bGBZd5tpjQ1h4R1SjG4ch0hZXPl+kKitdFR3N5vKWDusPDlYP+DYQk6ZZR2+BHRBTbdodPbdhmt+rBP5Qwu836GMmobzNjBKgFvfxTC+4ZOa/tnavwUF5J255XOXo1pMpgshBkIwiGjyWoyVxMPMz2iEWc6p3OtU002FOlJtPe+G/vki4m7YMI2sQ2OqspAsufnX/b+g3JgG/pMCH9fM7dk30OujQqTJANlwhTSzA7aRtmNtlaQivbgdnWNkJYbRayQdvtezmAHtkbA4eZH60epzEZ9/SiVy9k88Ce4ns1yDPjO64iDl4s9ONctnO95lMsR11M7+N4KKtRz6Qk9JXWYBI/V5CcheHZOGeWnWKBpjxXeNrDC+wD3y4ZH+3idTSXPF5g/7sXjlKkfMGX08lJb2Ncbvlieas8p/xcge7A9fRAWe+AWm3Zk7rnfx5zuTyUe7ZqvpHw3uzIak/mdg3y3j8bWB0qNnNR93zIqb7YJCpOEfsUzlHq6ZyyfkbVZ8hMJ1OaWBGXU5dgppalGSi3QsY8V3vLGgNdTz5pKl3iWJpsOvYyC5I5hG7vYN0Upu6fR0J4ReCh4U45cULhi5kT/LhfgiAXeYbzWhimjG0z0Dq5lBpZ1kNNZ/kPii6YQowBOZcOfG116tZhfm1dYb4kXrAMNgvf01D4yaLKwl8NUnufvW2Qm6/PLkblQOKDU+7tYZsKsF3P7a8xet7VYz+AzQyMO2hsvwmDvWu7RiMiGOxww/DQ4M7fjEtydU6PCuPQi074zatPVsjrgum7QxssqwvLxbMizoSNLAjyYh/IR5e9lGWcecVHQViqkzbGh0TEU/Op65fIAMyTDMRlE3W75GYaEz7zszf0aqvoK3hhsk1FE7Tux1stuOxxexov9V6U++VBv+GHC1HaStKtgklu+9zG78lYthZbOl+2M63bJNYWzVOZbSBkNssBzw+xxb+dS+cG9vvuWUXkzGU3WMnKmvdQU+UkrbCv7G6GMFrzf0g44O5Ynp5RWHCllV8xDlr/h/R+K37MoDMfG4miYFWWvh7S/Qb6usu9Yu4TX6vf+xIvJqaRvL96sh86emfi9xfmng3v49OOVI5nW2Xplrcqoxf/CQ95neE72c2nB+1sogJe781pNlFGbnTTGwpPXX8/Yyn+m1rgsnMlksrk4TOZpDw0tZ3FuZc1CNF3XvLl1qWWDZHaRLy+wH8pt6/V4f88szpURLV1pjWxJbtFFLEnXk9zqDRMPH9iZqTZuhGMdDiwMXqeO4FUFpfy9ta4R0qpS6yTPBe/EafMLc/iMyu3NmG1k24RKeBOTjULwVJN87uUiatZUI6S5MmUEgfgvyiCNdsvPcCTxl/bwK9EZL7WdlvNCp6f1EyN6WusNH6VSs8XW7nykKHTSep5872N25a0oKxVPWzpf4c64W1suqcOuRfHcr3j6baspo8Edozz0FMpuslTffcuovNEIvotG4IoMOhGbIj/xp2XsOO+r8fNUdttJGC09OLDXEocLniq9Kj0lKaP9Xd6BhWXe63TsLCe6M8MonVJacqTUlFHyaqNptpGNHRcw6uij6F0wdJr/vymjz88PRgX3CWTo82ztdbDe87uT/dcYeZ+LjJ8IJBn0LsHcLS5VT5d3JJ8b2z1wK3g72ifkLJydo+X9B9rsWOJ9lGm65+cw6hYXpTY7m9ZF5a2KzYxqSyxVqNWcL7gljGG6qzmY1jFe8A5iVHcjpvPuWu23XMtJxLvlP2nHDGsPmlszhCknQytegzOvlCqP+PD5pZO5NRkcecSZVqx42mnDpPEHmWcnjPPeneXDkSbdNH7iLydu4EWzJ/uj0sOZRtZq/NiurS/43peYjnM84YZN1eF+3MwGBl8otxi9mrQ63S9l6I+8eN4dcGDKLuXoe10D3k/snClTe3PtRF4SwYg37A9jCnQwHchxqze8i6dW03aYZP3JHci4dhCH773azVb4fLLmd7XGqXDZEYjXy33LvQ2LY7YR0oFuvrkX2y3XevWdUpqFMkpDPepxr/ddF89PcV5i5890e95Bl0z2/zfm1rRWW0PKwrMfIeA7KwlZL79K8dd7PeX9qTeZ1OHr5RXPD3XzsdTHF0aJMzLqlFHnRln/MnanlJrz/3av6o1QSiNl1M2OcRFgsrfD8XGlNC5DvfmJJeM5ZZQ87ePcSfvLpP3f7hyFdCfqg2gableXdww7wF/srpvJO+hvxPE2s/Pt7A/kUfcjq03ZvcrqJkvHDqYlfqWeUVIbDWaG0V+GYvP6elb3Npj5Xv9f4XlNBlN2D3VTdi0C3tnXsuQm9ZRddtm2bywHilsWbcpKZcdmXrG2ajIynwjb7aJM+95Mvml6VHSegYWytC/vmaFpuGyaRFvm+krRNipMsRxTH6Tc+97qxe41n3d7s8j7iPdvzfGVD/hnjZCWAFSpArUHYlmXtyO7jNr03I2Ko+EheccLbwTbat9cfG20zymwu1LIgoOKfNTXj2bFo3ey/zpxzWTjgtl3L/D2RzE9g/OgscT9+CyLzW/k2u62jiSrNDs1HorP213e6Sa/nB0ErRfaHZeMXTVYi3KKOcD+EIxhCmm94V1CtZqse/otz/Dmb8R32d3Gt88YtORhn24YXOx9jBf05tRJy1CuH754G2/+iJHplszdSKFtZ1wbFbWGU6CUopia3dzMd+QenHivUAYjZXVkbKPvgrwtt8uuKc7ssruV7bILX1NodDQhAerassqoiUynz3+hlNrhlNIRI6UjlFHfe4Rnz0ZXP2gBed+ez0ipF1dKzT3LwzYlYpfcq8hTpIzy7JwfV0YtPdsNFyXrR9SHB9k5ylIvyuHM3vh3On3vaeQPFFL2QHiT+cv6sPQYtTv4+WXeusj8SYsfmWYj20u2drOm9PpD5cgC+97tGSije5pMThYUyl8ymHJwVe+OgveKC0/7ch1nz8sMd8T/Off0FhT+M6l/vmlpIfsRRz5cmDVza//xrNIOFdCKSmg8vUaFiadpdjpWXmXkPzOFFK7/gmmjFNK/SyEtvqMpz8MH4prjHy/c8foyKuYkpXQwaBw1lUJqn3tZ9oK3A5WjPbzLVlnfuydlllvGW6hwXsn22z+xLbvJa7AtOA/Xp+bMDxajn9cymWlWQX3WKlgh4kD5+fGQbeW/jZYyrTJQSGkwbLHySmirN/yICKt3CJ/hy6oP2VwhaAQe1rfIO43yHb04aICxIZN3N73ph87a0n+6uSTORhobDU1SSoPYS4ycZpNydrHwbPyTdT+H8ZmEm7KLtXExhZ+pOeTwhwo3s23/ZZTB9RuXej4p8V6s/v3ANF3yHiwB4J7eT934QLXSufdUteFS+08YGY2HLaeUDrzhfYE8fZ8feDhQRgtvePsNrOL1M8X8SlwaopTetcA7Fxn2DWQYkuMCGv9fjc5jlp61vMNWLKJOLARrsucNU0bNXyGc4WNW36trhDGW7AirzZJjB+PPDyz37ibNyXjg08feNdTbu6BI3zciQAUH6vYDnZfuOqfrIsN2dCRcYzIFcfIdz65x3uerndnHwMbfiGcoikLC+37oUub/gcJfKJyKUrodz5/9uvoHvENJ6PjME2uBCP0u73l6PKJ2QL0ic1+fgGnS3hr1Rj0iPLMY7pNCOgJLdQ7nb+G/TM/hSRSCEd8/4vlsyI2sRmL73AtyMX7F4Xtzz3+b39j5/tUIW6ffcHruCUwnWUElHmzZTt6/eem8wkWHT/b76oy+o4PTKqGeGmqc8D27qstQveE7Gn4s86wH34P14JfGnOLWjxf6vDuYyr5VOHsgfq0t7ElKaZQxv3lGRinv/Twvw963uN2Ey2GXbuX/M5K5RS2XolBPfaTwALXqZeTzc/FsWN7j581uZ7ZH2TViSfKHa0YDhRSl44Fa4mCEMvqeYlIadblVUEZd3ElKKZ8BmUdNvxn54lZyhMooIzGLrObvX9WbkqSUOgXFxV2vyRTxXajrjnPxoPhfiDJaUvGwT8Th9xOMmG02YyvvyXgliSK2JfJNCONaPm597wkXbx6mfdfz6AWF3Wh0/JoX56akMY70f44c26OUPpo2TfxPItx7Qv+LGVCoecAjZPBzk8Xig+eTY3xvt4uRNYw/tVFYxVvgL2eQHIUQ5X4L+xZno5ZHmVLKe/Acpr0FU3cppLukFrzNPK63jff8wt95r5Ot1bLIGvfzN8QTzDrKIr5ycdAxeyPT2XXUS2DVQW9uUhw8GEPTQZIujpIbitnK3XXbaLpuOZxv2cY7zSrb0M86DGd/vJx/XatMgIoqeonamtHiEHE3/I6YPlNv+OL0OvWcnvY1Xd4p43/lN4NK/QfUPUEnAZw3YR7yCc5PO5qmlJLP/WgoR9PGzG5ubk3pqOebzr+YDIvpyT6Etft7zm4DZdTly/JiebK84bbYuXMv5kZ2WRpPIKUy6gQzpRT7qe6cDoZ3JyqjoQfbGKnfNtnxvAddGJSm8509KxOFw5bgBAf13K9QRiPl1LknmUzf/HN8+mnwLVLPuzzm9/ZGdMxfPNF/kQWkpiy5pSFrwen24l2AY3KNsFLfG+fgoI7/Wa1yW5qWNhGtFUa30GQLZAwdqjFM4aa+HXrPs3fEC/8KlqtVE0VdftmBal4sgrfH7B1lNeUcxS67zpVBprV73nN5Q6Qs/4llVA9JIc2TtG/r15vroCJfqZA2x+decgfEQ2o99NbTExy82DZxdpm1EaAcXeJC0mA5kTWj02yjGfuZ3dzcdfz+wNmdWW94F0+nmzTKrmKqy+eoaQ5m3c9mnB/N2qRDOf+yYwPr7Zy9Xc1IKfW8h4MRnGZSRoE+jl0geeleFPy6vS1nT/St0d+Wh+XNJ48uv5b3tsxoi2SqeAOjNGIXK6VBmPjIaFEkSUppkZe6To+dVzAl44NhJMup375SS4Ts+O7fNd+bgfK0bRCe/VmZlnhWLXHVEoap+c+S3qcJ+1oYfgNm193J6OmbK8VnijT7NUSjVYVu76pKYZKuB2mRJtc2CK+/ZjKZbEn+07rxnvmp88v3lP/d2WU2lsBlk9kLoyfa9KruxGnLXVl3JBUiQIn+lnnpqeBPl1MQCDc3GuGTSu8fIxxH0aFJP/fSKCKuV9KjAT++UYm2azrswHc53xndjzK+E73ntkX+KW7NKOfRwUtqjvmdHbkMWeoNXxRdR5+ye+TIaVvd3u9p6AQH98g1PNqaUzgaunszZjJcr3xsM8qWh0xh47Zj8psHw6ziNGWxlrjC6bvLqT9OoHPhocIK7/hgmm6JyAKltGj6bgmvVTuvWKmMWthHalGeTBllXb11pE51AqDgnUUH3nx33gjTpugyXXYPRihNKRwH303J362sM/24jTSWkuGuh3nXetE044U7b+3NiU9DLhUu7k4aazCd/Fbe0TZt2I7lKKN7VDNteCjYyH9/jPdjykhveGWvo+YV3j9jsv/YSJ8rXdiMb82+14I126uPWcv7UjjNeqWHlDbbcTfW7vhbymBt6232Nt4fpj5E9vqHf22ilgyzN8D9rDD+I3zfU0v4SmFoI/6GTszgCw1NN4JXSfhmu86mRuvQ8LOddkccVOLxaQQjrjfaIdhdN0yUSvC2Rqc/qukVvI1d+rTTI+XUucmsjoBNgWLkY4opnKVC2rUhP+5zdSt91ht+ZUyyJRFg7eiXnDv34XfOLlMEREAE0hJAKb26/w1vYt8b3iHllFEXX14jpbyzoxFEGq2/d+mlNROVUd/78axJ3vS0cWTpzzYzIh/7EycTtjjY7Ii1uj9jR96xwXnCHxsFrpyuy47i1X4twOK2NCytMHr6kL39TZaE5Kp2ss3zeNcMjZKy3php3JexC27JQS+udbEZ3/X0Xx/Kb//+RcFmRFWna/FQPqIZWcRlin5HH9a+unSi/wQjj6aD1NQhFQeIvnAe+syiuFsWduL8V0/Pyl2jKY86aiFgn31hM6P9S+2wa3ECu7ZtvWsRKEUYeuR2c966fXrJWuj42rzCenw37O7g90jhfdWIzjcnN4wr42MGPes70lEnARv1YaTzE7yEDiOqBzGX2C+0H2bXwpGhxJTqDZ8YqRzZXbfwGSqf+GYfI0dQxUkEREAEciBgSilKwYFZRs265GhNMtNBN6wm7lLK6ISJ3hfja0uriTMLv/bZFxrg0WgtzD75/HLvhyZvcfz2dQSUrr2ce/fQzsbutKJpSlsQd/jpGQtgadf86ZkSKRLn13n3MOAb7Hr4oefne5fbVONi76aoLlzgzULR+ZRdo90wyD2uul0W5Gvosy/BshSLp6d72Prg4qQ76tym726wrXf3mB5vfk+X93eGBpZyf4Y6QaogwSjpC3j/Dj9b/pbV0cc9/7cZW/nPuAhL9l44D51qxj/Mm8Qg2DGEGqLM8Zetm0ghbfXPvSz1vb2ovHYMePd79zHl5bNpevYs36//k46BwtC3maiwnpgxqfw0kjL3VJeKCIQv9O/jbL9hR/E03WEXw5N6wyfFmdbNOpWGfYfU1l60+EEn2VaU9Wv4DTVq+E4d60r/p8WzJfFFQARaiADT55eF3zXNRuou709uCQJ122RTaMK9IcrGX04ZJXz5FlzZmLO5iEJ4OR2Ib6Ftc5bFiLnftPnBmr1b4im88U/vs5y7pUZ/nDnZXxC/Xsn+wnxvVxTeaP0p7aBv0X66vFK4aq9fMsl/irbzSYS7wMKS5sF8am+TIxcUprMT74Njl3njWeL2UT7TchIXt3XxI8/0S7bx73fnmPYuDpaa8Emp3djFe9gAir27bZou8ZwIs0AZtbCkl+k3SC3OVj/Ccm7LB+1Xz/FzvljxQGHQ+ymc160nIhsZNWWU5Ub3xOOhQ0NHHgR4wE5spk+LtPrnXhjRvROmrwT3quCtzWjv3VR8s1gTMaHU/ePh2ZVvrv7aXmDODz1EVlnq6HAC0x4qbLuMzgnK0aX2M7u5tTKWoxYU3kqj7Re8oNcI8/HMGgVtLtHK91Syi4AIsHByK28e73/3eaT1XniIkbgKR7Mro058OgzPxn6xO08y0Zyj6brsgVHTZkaxeC8O04w5ZWe9dLJ/IbFF/dEoLx9luvevBpZ5ixnI+QfvqBt4R0XvWu7rFZds4xmD6EBhmRudDHi32ABR/Gfx8Amgm+PKKPHM2WCc99UonCyZEzAFsmeMty2sf1Nr5BbW4ihWRi0+jZDGemJqBTwinO+d02yjEq3+uZeZE/2/8j2x7fr7g63KN4K5TQOZOrjcO4wexvlUTPOpxJYy2cXc34USujl5flfRvflPeiSH9TwWXddphxCgbFxAGVk7yq51cpgbL8/IDQtl62xeqCfgd2zcPTM705sot+dSX5xST5wn/bEwftHSYBq+PRt2vMacrM+cN9F/aeh0+H+r5Gu41K17ljvvtGgyKm/tlp+0+ORvdAjYKA/Ldc4ndZs2yAcvvVPpQJxTNKoWCdcqyqgTePYk71hGRu+w8+I2ypF/KKzbv3Roaqtd765hd91LJrFp0nzvM0nxm1vWB0rpEdyvp4j3P3h/JuoZKCZv8F49iXffRZH2GgoyZox30oq+IM/rVJKNeCgO3ix2mf9q7xZ+MF24Uhhdr51AOMX2w8zG2ps5BmfB/t1pYqOd8yfbTddtYJQUJrGgJHlsVzfriQGoLS7P5kAZ3WBi5d67bBJLjqVSY4GG90X0Nl0UhM6ogZIsSbaufE/s8aMfKXy4r8+7lHsWrIdFEbVRfutt2xa3YM6GGUVHH5XW6Xn2Chalp9PmJ7BlgohJbsfkpoyaAKbo+t4x2GpWSG2zioVLvf8hrq3CPPWxXuTf6MR5IjxPMpo9X9GUra4eb4PB/sZuRGZpxqCZLPUe+fJOK10G5S1Mqt3yk5ag/I0SAaZCXcSDaCOFW1i9OTDgzUXpOXPCJO878em79jWBqQuCT41t70Tl/f9jWzPaDNN0nUxxM1y6kthZ3r/M2xO/Y8w/+bg/vuYuHkc5e7n4y4Wr5xrfJD6P753+FKXlONppO/Ge25j7tog8PEO8t45h06OLJ/Ft1oTj4q3852jrbUVb73u063bES7w+Ng62V8XfuHYnCvrl1jZMiEZOORJg06SfEP1P2KNlG+7xnugVNnV6Q+7NWy1Z7rlNEf47I/r3oYjeaN8ZNfdyR/UKqS1YHnqpZdcTkUec5XIdu1ZNT0wsWJL1L9yIE5tkZDR9YyG7BkoSk8zdrKIi0t2ZjvsJCvw3qJA+FpTH5JSW0+FwXXePd44qrGRAHez6KHn/SFH+za34uIgXaa4jpCQ41DlUnHKKc9vUgc0hfsQzsHPkne8FMp1pOzqm3NqaJ9lg6uqwUeK8NXW+YD6PPO1hwvL82i7mJzVKKTVlNExziJXJUv+RL++08tm7to7yFkum3fITdYCMXcXbecUb3l2xvOZutTR5l7kjiw6QdsuP1zvZf/3oeYXP9aGU8e5fH1hjYHbGwvnecXSwz+Ndv5B6430Dnvf+eJuAdllTK6PuppcyWWa0OCobfvmpvaXiGC338PM8x9WSftjWO6CWsArTOAKhollR2UwjEc9wdYeNvhHCevQvqneamUs5jzhd3GlMemI2KtUTUyq89dBYDwAArSK8cWt+zbJmtNII6bA8tdAI6TC5wxP7rtbACm97tkR/K+ssVude9DBm+gqV+JM9a3q/rfW7Vklpya19CNCr94HCAFOk3LRd33u1u8vbhSlgLfWJFJ71famHrqt0Z6ivplBf17v2qFIymV1n07LtaITNJW+jus8B3NjU09sxzQZqmWVeETWcACNtV1PespspVUcOKG/XMLpUV0O83fITx3nEo4WNB1d4N+O2Rdw9yc7z+z1GRr/RrCOjSTInuTHq+3naNz3U4dcnXZebCLQDAeo+HSIgAiLQeQTaYZfdI+YV9qGhUrGR0moKqZVGlG2bxnzGaCmloTJ6Ksrotzvv6eisHFunNGvWHiHX64xyzl8eO8bbKhwdqlmUdstPMYjgqwH/9I6mQ/F4OhKYzTvi+F82kzjtksn+3SOuyEEERKApCUghbcrbIqFEQAREIB0BGyXF5+ZlfCdN2S3jvXku2UgpCveJNDxtp+xh64hylNKm/s1jaPYcjYzmSLnJoq5lplSGWVhIY2yuLSGqVxl1MrVbfly+4qZtXnT0Q96kPs/btKvgjUc5fbHH9x6cMdG39Ws6REAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAEREAERAACviiIgAiIgAgMJ7DPPvvshMuNoeueN9xww5zhPnQmAiIgAiIgAiIgAiKQBYGeLCJRHCIgAiLQTgR839++UCiMtzyZHUMKaTvd4AzzctBBB627bNmydxDl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UvksxkDZi5yw9xKm1xzi5RjlMLe10HTi3YPCeTZ1ZPD5F2TYieT/wnPzFPb+vr6+zTDdxmA2a0Yjo3aDdIiACIiACIhADgS6ysRpa5gadTQyrUblqSHp0GgyZTTYwCghwWBNqflJuNZop2rucTV+S+Wjmjiq8VsqvbzdU8tIebDNVtxuusEGRjTArcPi2piQ+9Eg3yN2LqsI1EMgdflMmwhK4ADldgr+o3JLXRaN+DMD5GyU1prXQjMKfarF4eQhbp/fppy/FzOujM4kHU3TdaBkioAIiIAIiEDGBEoqpLaGibQyb2QkyD8qa8Ro7Ng60cX2C+0JojW/E0qF7aAbrRslL2faLyb57qGfmFPjrVWUp0zKQ6PTy5toFfmx0ZzbkMfKdqCMWqMdt+LG/XN8f/E3dch9axjWpgW33QGvtqgfKtyYzO5hNeWzgkzDLlu55XcEv1eHXchgLTRxFsJnYwfsd/GL775rQ7j2fHwGpfgorg1bX1oki05FQAREQAREQAREoHMJMBp2s02Rsx/2MxwJs8fctcuuA9MhJpuxbMrvU8XZtZEfysZetkau+JrORaDZCFBex1OP/drVZUXmNfGRzHpl57lYm867iTw32y5atGi9euNTeBEQAREQAREQgXQEgo+Dp/MqX81IgAbUm2iUXYBs8+nJvyguIw2sYzifRO/+cYwEBN8AiV+XXQREQASalYApo9Rh5dZCm+jX2rTe+Ohms+ZHcomACIiACIiACIiACIiACIiACLQIAUZDvx4fEaXz7SwbEcXtmrg7SuueLZIliSkCIiACIiACIpBAoOQa0gS/chIBERABERCBhhBg1LMRa6EbkhclIgIiIAIiIAIiIAIiIAIiIAIi0GIEtBa6xW6YxBUBERABERABERABERABERABERABERABERABERCBViHw/wHIAHUN1iL6RwAAAABJRU5ErkJggg==) - no-repeat; - background-size: 466px 120px; - } -} - -.toastui-editor-toolbar-icons { - background-position-y: 7px; -} - -.toastui-editor-toolbar-icons:disabled { - opacity: 0.3; -} - -.toastui-editor-toolbar-icons.heading { - background-position-x: 3px; -} - -.toastui-editor-toolbar-icons.bold { - background-position-x: -23px; -} - -.toastui-editor-toolbar-icons.italic { - background-position-x: -49px; -} - -.toastui-editor-toolbar-icons.strike { - background-position-x: -75px; -} - -.toastui-editor-toolbar-icons.hrline { - background-position-x: -101px; -} - -.toastui-editor-toolbar-icons.quote { - background-position-x: -127px; -} - -.toastui-editor-toolbar-icons.bullet-list { - background-position-x: -153px; -} - -.toastui-editor-toolbar-icons.ordered-list { - background-position-x: -179px; -} - -.toastui-editor-toolbar-icons.task-list { - background-position-x: -205px; -} - -.toastui-editor-toolbar-icons.indent { - background-position-x: -231px; -} - -.toastui-editor-toolbar-icons.outdent { - background-position-x: -257px; -} - -.toastui-editor-toolbar-icons.table { - background-position-x: -283px; -} - -.toastui-editor-toolbar-icons.image { - background-position-x: -309px; -} - -.toastui-editor-toolbar-icons.link { - background-position-x: -334px; -} - -.toastui-editor-toolbar-icons.code { - background-position-x: -361px; -} - -.toastui-editor-toolbar-icons.codeblock { - background-position-x: -388px; -} - -.toastui-editor-toolbar-icons.more { - background-position-x: -412px; -} - -.toastui-editor-toolbar-icons:not(:disabled).active { - background-position-y: -17px; -} - -@media only screen and (max-width: 480px) { - .toastui-editor-popup { - max-width: 300px; - margin-left: -150px; - } - - .toastui-editor-dropdown-toolbar { - max-width: none; - } -} -/* - z-index basis - -1: pseudo element - 20 - preview, wysiwyg - 30 - wysiwyg code block language editor, popup, context menu - 40 - tooltip -*/ -.ProseMirror { - color: #222; - font-size: 13px; - overflow-y: auto; - overflow-x: hidden; - height: calc(100% - 36px); -} - -.ProseMirror .placeholder { - color: #37352f; -} - -.ProseMirror:focus { - outline: none; -} - -.ProseMirror-selectednode { - outline: none; -} - -table.ProseMirror-selectednode { - border-radius: 2px; - outline: 2px solid @primary-color; -} - -.html-block.ProseMirror-selectednode { - border-radius: 2px; - outline: 2px solid @primary-color; -} - -.toastui-editor-contents { - margin: 0; - padding: 0; - font-size: 13px; - z-index: 20; -} - -.toastui-editor-contents *:not(table) { - line-height: 160%; - box-sizing: content-box; -} - -.toastui-editor-contents i, -.toastui-editor-contents cite, -.toastui-editor-contents em, -.toastui-editor-contents var, -.toastui-editor-contents address, -.toastui-editor-contents dfn { - font-style: italic; -} - -.toastui-editor-contents strong { - font-weight: bold; -} - -.toastui-editor-contents p { - margin: 10px 0; - color: #222; -} - -.toastui-editor-contents > h1:first-of-type, -.toastui-editor-contents > div > div:first-of-type h1 { - margin-top: 14px; -} - -.toastui-editor-contents h1, -.toastui-editor-contents h2, -.toastui-editor-contents h3, -.toastui-editor-contents h4, -.toastui-editor-contents h5, -.toastui-editor-contents h6 { - font-weight: bold; - color: #222; -} - -.toastui-editor-contents h1 { - font-size: 24px; - line-height: 28px; - border-bottom: 3px double #37352f; - margin: 52px 0 15px 0; - padding-bottom: 7px; -} - -.toastui-editor-contents h2 { - font-size: 22px; - line-height: 23px; - border-bottom: 1px solid #dbdbdb; - margin: 20px 0 13px 0; - padding-bottom: 7px; -} - -.toastui-editor-contents h3 { - font-size: 20px; - margin: 18px 0 2px; -} - -.toastui-editor-contents h4 { - font-size: 18px; - margin: 10px 0 2px; -} - -.toastui-editor-contents h3, -.toastui-editor-contents h4 { - line-height: 18px; -} - -.toastui-editor-contents h5 { - font-size: 16px; -} - -.toastui-editor-contents h6 { - font-size: 14px; -} - -.toastui-editor-contents h5, -.toastui-editor-contents h6 { - line-height: 17px; - margin: 9px 0 -4px; -} - -.toastui-editor-contents blockquote { - margin: 14px 0; - border-left: 4px solid #e5e5e5; - padding: 0 16px; - color: #37352f; -} - -.toastui-editor-contents blockquote p, -.toastui-editor-contents blockquote ul, -.toastui-editor-contents blockquote ol { - color: #37352f; -} - -.toastui-editor-contents blockquote > :first-child { - margin-top: 0; -} - -.toastui-editor-contents blockquote > :last-child { - margin-bottom: 0; -} - -.toastui-editor-contents pre, -.toastui-editor-contents code { - border: 0; - border-radius: 0; -} - -.toastui-editor-contents pre { - margin: 2px 0 8px; - padding: 18px; - background-color: #f4f7f8; -} - -.toastui-editor-contents code { - color: #c1798b; - background-color: #f9f2f4; - padding: 2px 3px; - letter-spacing: -0.3px; - border-radius: 2px; -} - -.toastui-editor-contents pre code { - padding: 0; - color: inherit; - white-space: pre-wrap; - background-color: transparent; -} - -.toastui-editor-contents img { - margin: 4px 0 10px; - box-sizing: border-box; - vertical-align: top; - max-width: 100%; -} - -.toastui-editor-contents table { - border: 1px solid rgba(0, 0, 0, 0.1); - margin: 12px 0 14px; - color: #222; - width: auto; - border-collapse: collapse; - box-sizing: border-box; -} - -.toastui-editor-contents table th, -.toastui-editor-contents table td { - border: 1px solid rgba(0, 0, 0, 0.1); - padding: 5px 14px 5px 12px; - height: 32px; -} - -.toastui-editor-contents table th { - background-color: #555; - font-weight: 300; - color: #fff; - padding-top: 6px; -} - -.toastui-editor-contents th p { - margin: 0; - color: #fff; -} - -.toastui-editor-contents td p { - margin: 0; - padding: 0 2px; -} - -.toastui-editor-contents td.toastui-editor-cell-selected { - background-color: #d8dfec; -} - -.toastui-editor-contents th.toastui-editor-cell-selected { - background-color: #908f8f; -} - -.toastui-editor-contents ul, -.toastui-editor-contents menu, -.toastui-editor-contents ol, -.toastui-editor-contents dir { - display: block; - list-style-type: none; - padding-left: 24px; - margin: 6px 0 10px; - color: #222; -} - -.toastui-editor-contents ol { - list-style-type: none; - counter-reset: li; -} - -.toastui-editor-contents ol > li { - counter-increment: li; -} - -.toastui-editor-contents ul > li::before, -.toastui-editor-contents ol > li::before { - display: inline-block; - position: absolute; -} - -.toastui-editor-contents ul > li::before { - content: ''; - margin-top: 6px; - margin-left: -17px; - width: 5px; - height: 5px; - border-radius: 50%; - background-color: #37352f; -} - -.toastui-editor-contents ol > li::before { - content: '.' counter(li); - margin-left: -28px; - width: 24px; - text-align: right; - direction: rtl; - color: #37352f; -} - -.toastui-editor-contents ul ul, -.toastui-editor-contents ul ol, -.toastui-editor-contents ol ol, -.toastui-editor-contents ol ul { - margin-top: 0 !important; - margin-bottom: 0 !important; -} - -.toastui-editor-contents ul li, -.toastui-editor-contents ol li { - position: relative; -} - -.toastui-editor-contents ul p, -.toastui-editor-contents ol p { - margin: 0; -} - -.toastui-editor-contents hr { - border-top: 1px solid #eee; - margin: 16px 0; -} - -.toastui-editor-contents a { - text-decoration: underline; - color: @primary-color; -} - -.toastui-editor-contents a:hover { - color: @primary-color; -} - -.toastui-editor-contents .image-link { - position: relative; -} - -.toastui-editor-contents .image-link:hover::before { - content: ''; - position: absolute; - width: 30px; - height: 30px; - right: 0px; - border-radius: 50%; - border: 1px solid #c9ccd5; - background: #fff - url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCIgdmlld0JveD0iMCAwIDIwIDIwIj4KICAgIDxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIj4KICAgICAgICA8ZyBzdHJva2U9IiM1NTUiIHN0cm9rZS13aWR0aD0iMS41Ij4KICAgICAgICAgICAgPGc+CiAgICAgICAgICAgICAgICA8Zz4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNNy42NjUgMTUuMDdsLTEuODE5LS4wMDJjLTEuNDg2IDAtMi42OTItMS4yMjgtMi42OTItMi43NDR2LS4xOTJjMC0xLjUxNSAxLjIwNi0yLjc0NCAyLjY5Mi0yLjc0NGgzLjg0NmMxLjQ4NyAwIDIuNjkyIDEuMjI5IDIuNjkyIDIuNzQ0di4xOTIiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0xMDAwIC00NTgxKSB0cmFuc2xhdGUoOTk1IDQ1NzYpIHRyYW5zbGF0ZSg1IDUpIHNjYWxlKDEgLTEpIHJvdGF0ZSg0NSAzNy4yOTMgMCkiLz4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTIuMzI2IDQuOTM0bDEuODIyLjAwMmMxLjQ4NyAwIDIuNjkzIDEuMjI4IDIuNjkzIDIuNzQ0di4xOTJjMCAxLjUxNS0xLjIwNiAyLjc0NC0yLjY5MyAyLjc0NGgtMy44NDVjLTEuNDg3IDAtMi42OTItMS4yMjktMi42OTItMi43NDRWNy42OCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTEwMDAgLTQ1ODEpIHRyYW5zbGF0ZSg5OTUgNDU3NikgdHJhbnNsYXRlKDUgNSkgc2NhbGUoMSAtMSkgcm90YXRlKDQ1IDMwLjk5NiAwKSIvPgogICAgICAgICAgICAgICAgPC9nPgogICAgICAgICAgICA8L2c+CiAgICAgICAgPC9nPgogICAgPC9nPgo8L3N2Zz4K) - no-repeat; - background-position: center; - box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.08); - cursor: pointer; -} - -.toastui-editor-contents .task-list-item { - border: 0; - list-style: none; - padding-left: 24px; - margin-left: -24px; -} - -.toastui-editor-contents .task-list-item::before { - background-repeat: no-repeat; - background-size: 18px 18px; - background-position: center; - content: ''; - margin-left: 0; - margin-top: 0; - border-radius: 2px; - height: 18px; - width: 18px; - position: absolute; - left: 0; - top: 1px; - cursor: pointer; - background: transparent - url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxOCIgaGVpZ2h0PSIxOCIgdmlld0JveD0iMCAwIDE4IDE4Ij4KICAgIDxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+CiAgICAgICAgPGcgZmlsbD0iI0ZGRiIgc3Ryb2tlPSIjQ0NDIj4KICAgICAgICAgICAgPGc+CiAgICAgICAgICAgICAgICA8ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTAzMCAtMjk2KSB0cmFuc2xhdGUoNzg4IDE5MikgdHJhbnNsYXRlKDI0MiAxMDQpIj4KICAgICAgICAgICAgICAgICAgICA8cmVjdCB3aWR0aD0iMTciIGhlaWdodD0iMTciIHg9Ii41IiB5PSIuNSIgcng9IjIiLz4KICAgICAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgPC9nPgogICAgICAgIDwvZz4KICAgIDwvZz4KPC9zdmc+Cg==); -} - -.toastui-editor-contents .task-list-item.checked::before { - background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxOCIgaGVpZ2h0PSIxOCIgdmlld0JveD0iMCAwIDE4IDE4Ij4KICAgIDxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+CiAgICAgICAgPGcgZmlsbD0iIzRCOTZFNiI+CiAgICAgICAgICAgIDxnPgogICAgICAgICAgICAgICAgPGc+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTE2IDBjMS4xMDUgMCAyIC44OTUgMiAydjE0YzAgMS4xMDUtLjg5NSAyLTIgMkgyYy0xLjEwNSAwLTItLjg5NS0yLTJWMkMwIC44OTUuODk1IDAgMiAwaDE0em0tMS43OTMgNS4yOTNjLS4zOS0uMzktMS4wMjQtLjM5LTEuNDE0IDBMNy41IDEwLjU4NSA1LjIwNyA4LjI5M2wtLjA5NC0uMDgzYy0uMzkyLS4zMDUtLjk2LS4yNzgtMS4zMi4wODMtLjM5LjM5LS4zOSAxLjAyNCAwIDEuNDE0bDMgMyAuMDk0LjA4M2MuMzkyLjMwNS45Ni4yNzggMS4zMi0uMDgzbDYtNiAuMDgzLS4wOTRjLjMwNS0uMzkyLjI3OC0uOTYtLjA4My0xLjMyeiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTEwNTAgLTI5NikgdHJhbnNsYXRlKDc4OCAxOTIpIHRyYW5zbGF0ZSgyNjIgMTA0KSIvPgogICAgICAgICAgICAgICAgPC9nPgogICAgICAgICAgICA8L2c+CiAgICAgICAgPC9nPgogICAgPC9nPgo8L3N2Zz4K); -} - -.toastui-editor-custom-block .toastui-editor-custom-block-editor { - background: #f9f7fd; - color: #452d6b; - border: solid 1px #dbd4ea; -} - -.toastui-editor-custom-block .toastui-editor-custom-block-view { - position: relative; - padding: 9px 13px 8px 12px; -} - -.toastui-editor-custom-block.ProseMirror-selectednode - .toastui-editor-custom-block-view { - border: solid 1px #dbd4ea; - border-radius: 2px; -} - -.toastui-editor-custom-block .toastui-editor-custom-block-view .tool { - position: absolute; - right: 10px; - top: 7px; - display: none; -} - -.toastui-editor-custom-block.ProseMirror-selectednode - .toastui-editor-custom-block-view - .tool { - display: block; -} - -.toastui-editor-custom-block-view button { - vertical-align: middle; - width: 15px; - height: 15px; - margin-left: 8px; - padding: 3px; - border: solid 1px #37352f; - background: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjIuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IuugiOydtOyWtF8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4PSIwcHgiCgkgeT0iMHB4IiB2aWV3Qm94PSIwIDAgMzAgMzAiIHN0eWxlPSJlbmFibGUtYmFja2dyb3VuZDpuZXcgMCAwIDMwIDMwOyIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+CjxzdHlsZSB0eXBlPSJ0ZXh0L2NzcyI+Cgkuc3Qwe2ZpbGwtcnVsZTpldmVub2RkO2NsaXAtcnVsZTpldmVub2RkO2ZpbGw6IzU1NTU1NTt9Cjwvc3R5bGU+CjxnPgoJPGc+CgkJPGc+CgkJCTxnPgoJCQkJPGc+CgkJCQkJPHBhdGggY2xhc3M9InN0MCIgZD0iTTE1LjUsMTIuNWwyLDJMMTIsMjBoLTJ2LTJMMTUuNSwxMi41eiBNMTgsMTBsMiwybC0xLjUsMS41bC0yLTJMMTgsMTB6Ii8+CgkJCQk8L2c+CgkJCTwvZz4KCQk8L2c+Cgk8L2c+CjwvZz4KPC9zdmc+Cg==) - no-repeat; - background-position: center; - background-size: 30px 30px; -} - -.toastui-editor-custom-block-view .info { - font-size: 13px; - font-weight: bold; - color: #5200d0; - vertical-align: middle; -} - -.toastui-editor-contents .toastui-editor-ww-code-block { - position: relative; -} - -.toastui-editor-contents .toastui-editor-ww-code-block:after { - content: attr(data-language); - position: absolute; - display: inline-block; - top: 10px; - right: 10px; - height: 24px; - padding: 3px 35px 0 10px; - font-weight: bold; - font-size: 13px; - color: #333; - background: #e5e9ea - url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjIuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IuugiOydtOyWtF8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4PSIwcHgiCgkgeT0iMHB4IiB2aWV3Qm94PSIwIDAgMzAgMzAiIHN0eWxlPSJlbmFibGUtYmFja2dyb3VuZDpuZXcgMCAwIDMwIDMwOyIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+CjxzdHlsZSB0eXBlPSJ0ZXh0L2NzcyI+Cgkuc3Qwe2ZpbGwtcnVsZTpldmVub2RkO2NsaXAtcnVsZTpldmVub2RkO2ZpbGw6IzU1NTU1NTt9Cjwvc3R5bGU+CjxnPgoJPGc+CgkJPGc+CgkJCTxnPgoJCQkJPGc+CgkJCQkJPHBhdGggY2xhc3M9InN0MCIgZD0iTTE1LjUsMTIuNWwyLDJMMTIsMjBoLTJ2LTJMMTUuNSwxMi41eiBNMTgsMTBsMiwybC0xLjUsMS41bC0yLTJMMTgsMTB6Ii8+CgkJCQk8L2c+CgkJCTwvZz4KCQk8L2c+Cgk8L2c+CjwvZz4KPC9zdmc+Cg==) - no-repeat; - background-position: right; - border-radius: 2px; - background-size: 30px 30px; - cursor: pointer; -} - -.toastui-editor-ww-code-block-language { - position: fixed; - display: inline-block; - width: 100px; - height: 27px; - right: 35px; - border: 1px solid #37352f; - border-radius: 2px; - background-color: #fff; - z-index: 30; -} - -.toastui-editor-ww-code-block-language input { - box-sizing: border-box; - margin: 0; - padding: 0 10px; - height: 100%; - width: 100%; - background-color: transparent; - border: none; - outline: none; -} - -.toastui-editor-contents-placeholder::before { - content: attr(data-placeholder); - color: grey; - line-height: 160%; - position: absolute; -} - -.toastui-editor-md-preview .toastui-editor-contents h1 { - min-height: 28px; -} - -.toastui-editor-md-preview .toastui-editor-contents h2 { - min-height: 23px; -} - -.toastui-editor-md-preview .toastui-editor-contents blockquote { - min-height: 20px; -} - -.toastui-editor-md-preview .toastui-editor-contents li { - min-height: 22px; -} - -.toastui-editor-pseudo-clipboard { - position: fixed; - opacity: 0; - width: 0; - height: 0; - left: -1000px; - top: -1000px; - z-index: -1; -} - -.toastui-editor-contents .toastui-editor-md-preview-highlight { - position: relative; - z-index: 0; -} - -.toastui-editor-contents .toastui-editor-md-preview-highlight::after { - content: ''; - background-color: rgba(255, 245, 131, 0.5); - border-radius: 4px; - z-index: -1; - position: absolute; - top: -4px; - right: -4px; - left: -4px; - bottom: -4px; -} - -.toastui-editor-contents h1.toastui-editor-md-preview-highlight::after, -.toastui-editor-contents h2.toastui-editor-md-preview-highlight::after { - bottom: 0; -} - -.toastui-editor-contents td.toastui-editor-md-preview-highlight::after, -.toastui-editor-contents th.toastui-editor-md-preview-highlight::after { - display: none; -} - -.toastui-editor-contents th.toastui-editor-md-preview-highlight, -.toastui-editor-contents td.toastui-editor-md-preview-highlight { - background-color: rgba(255, 245, 131, 0.5); -} - -.toastui-editor-contents th.toastui-editor-md-preview-highlight { - color: #222; -} - -.toastui-editor-md-heading1 { - font-size: 24px; -} - -.toastui-editor-md-heading2 { - font-size: 22px; -} - -.toastui-editor-md-heading3 { - font-size: 20px; -} - -.toastui-editor-md-heading4 { - font-size: 18px; -} - -.toastui-editor-md-heading5 { - font-size: 16px; -} - -.toastui-editor-md-heading6 { - font-size: 14px; -} - -.toastui-editor-md-heading.toastui-editor-md-delimiter.setext { - line-height: 15px; -} - -.toastui-editor-md-strong, -.toastui-editor-md-heading, -.toastui-editor-md-list-item-style, -.toastui-editor-md-list-item .toastui-editor-md-meta { - font-weight: bold; -} - -.toastui-editor-md-emph { - font-style: italic; -} - -.toastui-editor-md-strike { - text-decoration: line-through; -} - -.toastui-editor-md-strike.toastui-editor-md-delimiter { - text-decoration: none; -} - -.toastui-editor-md-delimiter, -.toastui-editor-md-thematic-break, -.toastui-editor-md-link, -.toastui-editor-md-table, -.toastui-editor-md-block-quote { - color: #37352f; -} - -.toastui-editor-md-code.toastui-editor-md-delimiter { - color: #37352f; -} - -.toastui-editor-md-meta, -.toastui-editor-md-html, -.toastui-editor-md-link.toastui-editor-md-link-url.toastui-editor-md-marked-text { - color: #37352f; -} - -.toastui-editor-md-block-quote .toastui-editor-md-marked-text, -.toastui-editor-md-list-item .toastui-editor-md-meta { - color: #555; -} - -.toastui-editor-md-table .toastui-editor-md-table-cell { - color: #222; -} - -.toastui-editor-md-link.toastui-editor-md-link-desc.toastui-editor-md-marked-text, -.toastui-editor-md-list-item-style.toastui-editor-md-list-item-odd { - color: @primary-color; -} - -.toastui-editor-md-list-item-style.toastui-editor-md-list-item-even { - color: #cb4848; -} - -.toastui-editor-md-code.toastui-editor-md-marked-text { - color: #c1798b; -} - -.toastui-editor-md-code { - background-color: rgba(243, 229, 233, 0.5); - padding: 2px 0; - letter-spacing: -0.3px; -} - -.toastui-editor-md-code.toastui-editor-md-start { - padding-left: 2px; - border-top-left-radius: 2px; - border-bottom-left-radius: 2px; -} - -.toastui-editor-md-code.toastui-editor-md-end { - padding-right: 2px; - border-top-right-radius: 2px; - border-bottom-right-radius: 2px; -} - -.toastui-editor-md-code-block-line-background { - background-color: #f5f7f8; -} - -.toastui-editor-md-code-block-line-background.start, -.toastui-editor-md-custom-block-line-background.start { - margin-top: 2px; -} - -.toastui-editor-md-custom-block { - color: #452d6b; -} -.toastui-editor-md-custom-block-line-background { - background-color: #f9f7fd; -} -.toastui-editor-md-custom-block .toastui-editor-md-delimiter { - color: #b8b3c0; -} -.toastui-editor-md-custom-block .toastui-editor-md-meta { - color: #5200d0; -} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/SummaryTagsDescription/SummaryTagsDescription.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/SummaryTagsDescription/SummaryTagsDescription.component.tsx index 1ecce008dfea..87a58c086a76 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/SummaryTagsDescription/SummaryTagsDescription.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/SummaryTagsDescription/SummaryTagsDescription.component.tsx @@ -16,7 +16,7 @@ import { useTranslation } from 'react-i18next'; import TagsViewer from '../../../components/Tag/TagsViewer/TagsViewer'; import { BasicEntityInfo } from '../../Explore/EntitySummaryPanel/SummaryList/SummaryList.interface'; import { EntityUnion } from '../../Explore/ExplorePage.interface'; -import RichTextEditorPreviewer from '../RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../RichTextEditor/RichTextEditorPreviewerV1'; const SummaryTagsDescription = ({ tags = [], @@ -61,7 +61,7 @@ const SummaryTagsDescription = ({
{entityDetail.description?.trim() ? ( - diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/TierCard/TierCard.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/TierCard/TierCard.test.tsx index 2284808a3b8f..cb4c7efd739f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/TierCard/TierCard.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/TierCard/TierCard.test.tsx @@ -72,7 +72,7 @@ jest.mock('antd', () => ({ }), })); -jest.mock('../RichTextEditor/RichTextEditorPreviewer', () => { +jest.mock('../RichTextEditor/RichTextEditorPreviewerV1', () => { return jest.fn().mockReturnValue(
RichTextEditorPreviewer
); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/TierCard/TierCard.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/TierCard/TierCard.tsx index e0cee1a8a603..afc00741c970 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/TierCard/TierCard.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/TierCard/TierCard.tsx @@ -30,7 +30,7 @@ import { getTags } from '../../../rest/tagAPI'; import { getEntityName } from '../../../utils/EntityUtils'; import { showErrorToast } from '../../../utils/ToastUtils'; import Loader from '../Loader/Loader'; -import RichTextEditorPreviewer from '../RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../RichTextEditor/RichTextEditorPreviewerV1'; import './tier-card.style.less'; import { CardWithListItems, TierCardProps } from './TierCard.interface'; @@ -158,7 +158,7 @@ const TierCard = ({ } key={card.id}>
- text?.trim() ? ( - + ) : ( {t('label.no-description')} ), diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/AddNotificationPage/AddNotificationPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/AddNotificationPage/AddNotificationPage.tsx index 3a5524edc642..9b2107723e02 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/AddNotificationPage/AddNotificationPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/AddNotificationPage/AddNotificationPage.tsx @@ -273,7 +273,6 @@ const AddNotificationPage = () => { valuePropName="initialValue"> diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/AddObservabilityPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/AddObservabilityPage.tsx index aa48c36fc632..a65e9f98d468 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/AddObservabilityPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/AddObservabilityPage.tsx @@ -247,7 +247,6 @@ function AddObservabilityPage() { valuePropName="initialValue"> diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/AddQueryPage/AddQueryPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/AddQueryPage/AddQueryPage.component.tsx index 1f92a8c3577e..8464a2b7327a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/AddQueryPage/AddQueryPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/AddQueryPage/AddQueryPage.component.tsx @@ -242,7 +242,6 @@ const AddQueryPage = () => { label={`${t('label.description')}:`} name="description"> ); jest.mock( - '../../components/common/RichTextEditor/RichTextEditorPreviewer', + '../../components/common/RichTextEditor/RichTextEditorPreviewerV1', () => jest.fn().mockImplementation(() =>
RichTextEditorPreviewer
) ); @@ -178,22 +177,6 @@ describe('AlertDetailsPage', () => { expect(screen.getByText('DeleteWidgetModal')).toBeInTheDocument(); }); - it('should not render the description if alert description is not present', async () => { - (getObservabilityAlertByFQN as jest.Mock).mockImplementationOnce(() => - Promise.resolve({ - ...mockAlertDetails, - description: undefined, - }) - ); - await act(async () => { - render(, { - wrapper: MemoryRouter, - }); - }); - - expect(screen.queryByText('RichTextEditorPreviewer')).toBeNull(); - }); - it('should render the description if alert description is present', async () => { await act(async () => { render(, { diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/CustomPageSettings/CustomPageSettings.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/CustomPageSettings/CustomPageSettings.tsx index 448470afd26c..55be89609fd8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/CustomPageSettings/CustomPageSettings.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/CustomPageSettings/CustomPageSettings.tsx @@ -20,7 +20,7 @@ import { Link, useHistory } from 'react-router-dom'; import ErrorPlaceHolder from '../../components/common/ErrorWithPlaceholder/ErrorPlaceHolder'; import NextPrevious from '../../components/common/NextPrevious/NextPrevious'; import { NextPreviousProps } from '../../components/common/NextPrevious/NextPrevious.interface'; -import RichTextEditorPreviewer from '../../components/common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../components/common/RichTextEditor/RichTextEditorPreviewerV1'; import TitleBreadcrumb from '../../components/common/TitleBreadcrumb/TitleBreadcrumb.component'; import { TitleBreadcrumbProps } from '../../components/common/TitleBreadcrumb/TitleBreadcrumb.interface'; import PageHeader from '../../components/PageHeader/PageHeader.component'; @@ -193,7 +193,7 @@ export const CustomPageSettings = () => { } title={getEntityName(persona)}> {persona.description ? ( - ) : ( diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DataInsightPage/KPIList.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DataInsightPage/KPIList.test.tsx index 6fe752145bbe..29b45913dcaf 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/DataInsightPage/KPIList.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/DataInsightPage/KPIList.test.tsx @@ -47,7 +47,7 @@ jest.mock('../../components/common/NextPrevious/NextPrevious', () => ); jest.mock( - '../../components/common/RichTextEditor/RichTextEditorPreviewer', + '../../components/common/RichTextEditor/RichTextEditorPreviewerV1', () => jest.fn().mockReturnValue(
Editor
) ); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DataInsightPage/KPIList.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DataInsightPage/KPIList.tsx index 8d2a93d95e9a..102a80bf1fd6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/DataInsightPage/KPIList.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/DataInsightPage/KPIList.tsx @@ -24,7 +24,7 @@ import DeleteWidgetModal from '../../components/common/DeleteWidget/DeleteWidget import ErrorPlaceHolder from '../../components/common/ErrorWithPlaceholder/ErrorPlaceHolder'; import NextPrevious from '../../components/common/NextPrevious/NextPrevious'; import { PagingHandlerParams } from '../../components/common/NextPrevious/NextPrevious.interface'; -import RichTextEditorPreviewer from '../../components/common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../components/common/RichTextEditor/RichTextEditorPreviewerV1'; import Table from '../../components/common/Table/Table'; import { EmptyGraphPlaceholder } from '../../components/DataInsight/EmptyGraphPlaceholder'; import { @@ -104,7 +104,7 @@ const KPIList = () => { width: 300, render: (description: string | undefined) => description ? ( - + ) : ( {t('label.no-entity', { diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseDetailsPage/DatabaseDetailsPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseDetailsPage/DatabaseDetailsPage.test.tsx index 6f74d1439b39..d300e6f9f1e4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseDetailsPage/DatabaseDetailsPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseDetailsPage/DatabaseDetailsPage.test.tsx @@ -131,7 +131,7 @@ jest.mock('../../context/PermissionProvider/PermissionProvider', () => ({ })); jest.mock( - '../../components/common/RichTextEditor/RichTextEditorPreviewer', + '../../components/common/RichTextEditor/RichTextEditorPreviewerV1', () => { return jest.fn().mockImplementation(({ markdown }) =>

{markdown}

); } diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseSchemaPage/SchemaTablesTab.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseSchemaPage/SchemaTablesTab.tsx index cc397a63a3d9..4dc5d69af1c6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseSchemaPage/SchemaTablesTab.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseSchemaPage/SchemaTablesTab.tsx @@ -23,7 +23,7 @@ import DescriptionV1 from '../../components/common/EntityDescription/Description import ErrorPlaceHolder from '../../components/common/ErrorWithPlaceholder/ErrorPlaceHolder'; import NextPrevious from '../../components/common/NextPrevious/NextPrevious'; import { NextPreviousProps } from '../../components/common/NextPrevious/NextPrevious.interface'; -import RichTextEditorPreviewer from '../../components/common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../components/common/RichTextEditor/RichTextEditorPreviewerV1'; import TableAntd from '../../components/common/Table/Table'; import { EntityName } from '../../components/Modals/EntityNameModal/EntityNameModal.interface'; import { usePermissionProvider } from '../../context/PermissionProvider/PermissionProvider'; @@ -148,7 +148,7 @@ function SchemaTablesTab({ key: 'description', render: (text: string) => text?.trim() ? ( - + ) : ( {t('label.no-description')} ), diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/KPIPage/AddKPIPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/KPIPage/AddKPIPage.tsx index 4181c9b8cf76..f56b4a14787d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/KPIPage/AddKPIPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/KPIPage/AddKPIPage.tsx @@ -360,7 +360,6 @@ const AddKPIPage = () => { trigger="onTextChange" valuePropName="initialValue"> diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/KPIPage/EditKPIPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/KPIPage/EditKPIPage.tsx index e328399e584a..facff972c246 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/KPIPage/EditKPIPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/KPIPage/EditKPIPage.tsx @@ -391,7 +391,6 @@ const EditKPIPage = () => { trigger="onTextChange" valuePropName="initialValue"> diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/MetricsPage/MetricListPage/MetricListPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/MetricsPage/MetricListPage/MetricListPage.tsx index fbc3e0f56307..dee96f5d3270 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/MetricsPage/MetricListPage/MetricListPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/MetricsPage/MetricListPage/MetricListPage.tsx @@ -21,7 +21,7 @@ import Loader from '../../../components/common/Loader/Loader'; import NextPrevious from '../../../components/common/NextPrevious/NextPrevious'; import { PagingHandlerParams } from '../../../components/common/NextPrevious/NextPrevious.interface'; import { OwnerLabel } from '../../../components/common/OwnerLabel/OwnerLabel.component'; -import RichTextEditorPreviewer from '../../../components/common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../../components/common/RichTextEditor/RichTextEditorPreviewerV1'; import TableTags from '../../../components/Database/TableTags/TableTags.component'; import PageHeader from '../../../components/PageHeader/PageHeader.component'; import PageLayoutV1 from '../../../components/PageLayoutV1/PageLayoutV1'; @@ -169,7 +169,7 @@ const MetricListPage = () => { })} ) : ( - + ), }, { 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 c0547857265c..56b60e9f06c4 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 @@ -22,6 +22,7 @@ import DeleteWidgetModal from '../../components/common/DeleteWidget/DeleteWidget import ErrorPlaceHolder from '../../components/common/ErrorWithPlaceholder/ErrorPlaceHolder'; import NextPrevious from '../../components/common/NextPrevious/NextPrevious'; import { PagingHandlerParams } from '../../components/common/NextPrevious/NextPrevious.interface'; +import RichTextEditorPreviewerV1 from '../../components/common/RichTextEditor/RichTextEditorPreviewerV1'; import Table from '../../components/common/Table/Table'; import TitleBreadcrumb from '../../components/common/TitleBreadcrumb/TitleBreadcrumb.component'; import { TitleBreadcrumbProps } from '../../components/common/TitleBreadcrumb/TitleBreadcrumb.interface'; @@ -241,7 +242,7 @@ const NotificationListPage = () => { })} ) : ( - description + ), }, { 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 d82a29e3c1f5..3bb685ff9742 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 @@ -22,6 +22,7 @@ import DeleteWidgetModal from '../../components/common/DeleteWidget/DeleteWidget import ErrorPlaceHolder from '../../components/common/ErrorWithPlaceholder/ErrorPlaceHolder'; import NextPrevious from '../../components/common/NextPrevious/NextPrevious'; import { PagingHandlerParams } from '../../components/common/NextPrevious/NextPrevious.interface'; +import RichTextEditorPreviewerV1 from '../../components/common/RichTextEditor/RichTextEditorPreviewerV1'; import Table from '../../components/common/Table/Table'; import PageHeader from '../../components/PageHeader/PageHeader.component'; import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1'; @@ -221,7 +222,7 @@ const ObservabilityAlertsPage = () => { })} ) : ( - description + ), }, { diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/AddPolicyPage/AddPolicyPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/AddPolicyPage/AddPolicyPage.tsx index 6f504b1427e5..e13d0f2281f1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/AddPolicyPage/AddPolicyPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/AddPolicyPage/AddPolicyPage.tsx @@ -147,7 +147,6 @@ const AddPolicyPage = () => { label={`${t('label.description')}:`} name="description"> jest.fn().mockReturnValue(
Previewer
) ); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesDetailPage/PoliciesDetailPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesDetailPage/PoliciesDetailPage.tsx index cab22454dfed..aa8f34590812 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesDetailPage/PoliciesDetailPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesDetailPage/PoliciesDetailPage.tsx @@ -36,7 +36,7 @@ import { ReactComponent as IconDelete } from '../../../assets/svg/ic-delete.svg' import DescriptionV1 from '../../../components/common/EntityDescription/DescriptionV1'; import ErrorPlaceHolder from '../../../components/common/ErrorWithPlaceholder/ErrorPlaceHolder'; import Loader from '../../../components/common/Loader/Loader'; -import RichTextEditorPreviewer from '../../../components/common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../../components/common/RichTextEditor/RichTextEditorPreviewerV1'; import TitleBreadcrumb from '../../../components/common/TitleBreadcrumb/TitleBreadcrumb.component'; import PageLayoutV1 from '../../../components/PageLayoutV1/PageLayoutV1'; import { @@ -398,7 +398,7 @@ const PoliciesDetailPage = () => {
- diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesDetailPage/PoliciesDetailsList.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesDetailPage/PoliciesDetailsList.component.tsx index 7ecf96d7f275..1eb7d8dd0a92 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesDetailPage/PoliciesDetailsList.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesDetailPage/PoliciesDetailsList.component.tsx @@ -18,7 +18,7 @@ import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { ReactComponent as IconRemove } from '../../../assets/svg/ic-remove.svg'; -import RichTextEditorPreviewer from '../../../components/common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../../components/common/RichTextEditor/RichTextEditorPreviewerV1'; import { EntityReference } from '../../../generated/type/entityReference'; import { getEntityName } from '../../../utils/EntityUtils'; import { @@ -73,7 +73,7 @@ const PoliciesDetailsList = ({ dataIndex: 'description', key: 'description', render: (_, record) => ( - + ), }, { diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesListPage/PoliciesListPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesListPage/PoliciesListPage.test.tsx index 4805972749d5..6900ca2a79e9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesListPage/PoliciesListPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesListPage/PoliciesListPage.test.tsx @@ -33,7 +33,7 @@ jest.mock('../../../components/common/DeleteWidget/DeleteWidgetModal', () => jest.fn().mockReturnValue(
Delete Widget
) ); jest.mock( - '../../../components/common/RichTextEditor/RichTextEditorPreviewer', + '../../../components/common/RichTextEditor/RichTextEditorPreviewerV1', () => jest.fn().mockReturnValue(
Previewer
) ); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesListPage/PoliciesListPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesListPage/PoliciesListPage.tsx index c0fb82b92fbd..aacb0b0fe8a9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesListPage/PoliciesListPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesListPage/PoliciesListPage.tsx @@ -23,7 +23,7 @@ import DeleteWidgetModal from '../../../components/common/DeleteWidget/DeleteWid import ErrorPlaceHolder from '../../../components/common/ErrorWithPlaceholder/ErrorPlaceHolder'; import NextPrevious from '../../../components/common/NextPrevious/NextPrevious'; import { PagingHandlerParams } from '../../../components/common/NextPrevious/NextPrevious.interface'; -import RichTextEditorPreviewer from '../../../components/common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../../components/common/RichTextEditor/RichTextEditorPreviewerV1'; import Table from '../../../components/common/Table/Table'; import TitleBreadcrumb from '../../../components/common/TitleBreadcrumb/TitleBreadcrumb.component'; import { TitleBreadcrumbProps } from '../../../components/common/TitleBreadcrumb/TitleBreadcrumb.interface'; @@ -137,7 +137,7 @@ const PoliciesListPage = () => { isEmpty(record?.description) ? ( NO_DATA_PLACEHOLDER ) : ( - + ), }, { diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/RuleForm/RuleForm.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/RuleForm/RuleForm.tsx index 9c6a801bbeff..f692b7517c55 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/RuleForm/RuleForm.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/RuleForm/RuleForm.tsx @@ -216,7 +216,6 @@ const RuleForm: FC = ({ ruleData, setRuleData }) => { ({ })); jest.mock( - '../../../components/common/RichTextEditor/RichTextEditorPreviewer', + '../../../components/common/RichTextEditor/RichTextEditorPreviewerV1', () => jest.fn().mockReturnValue(
Previewer
) ); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/RolesPage/AddAttributeModal/AddAttributeModal.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/RolesPage/AddAttributeModal/AddAttributeModal.tsx index cc368c9b6829..bc7679846d8f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/RolesPage/AddAttributeModal/AddAttributeModal.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/RolesPage/AddAttributeModal/AddAttributeModal.tsx @@ -20,7 +20,7 @@ import React, { FC, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import ErrorPlaceHolder from '../../../components/common/ErrorWithPlaceholder/ErrorPlaceHolder'; import Loader from '../../../components/common/Loader/Loader'; -import RichTextEditorPreviewer from '../../../components/common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../../components/common/RichTextEditor/RichTextEditorPreviewerV1'; import { ERROR_PLACEHOLDER_TYPE } from '../../../enums/common.enum'; import { EntityType } from '../../../enums/entity.enum'; import { Policy } from '../../../generated/entity/policies/policy'; @@ -180,7 +180,7 @@ const AddAttributeModal: FC = ({ onClick={() => handleValueSelect(option.id)}>
{getEntityName(option)}- diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/RolesPage/AddRolePage/AddRolePage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/RolesPage/AddRolePage/AddRolePage.tsx index c2c6554a8e6d..24c40e60bc50 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/RolesPage/AddRolePage/AddRolePage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/RolesPage/AddRolePage/AddRolePage.tsx @@ -148,7 +148,6 @@ const AddRolePage = () => { label={`${t('label.description')}:`} name="description"> ); jest.mock( - '../../../components/common/RichTextEditor/RichTextEditorPreviewer', + '../../../components/common/RichTextEditor/RichTextEditorPreviewerV1', () => jest.fn().mockReturnValue(
Previewer
) ); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/RolesPage/RolesDetailPage/RolesDetailPageList.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/RolesPage/RolesDetailPage/RolesDetailPageList.component.tsx index 5b5b9082ceb0..703356b17998 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/RolesPage/RolesDetailPage/RolesDetailPageList.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/RolesPage/RolesDetailPage/RolesDetailPageList.component.tsx @@ -18,7 +18,7 @@ import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { ReactComponent as IconRemove } from '../../../assets/svg/ic-remove.svg'; -import RichTextEditorPreviewer from '../../../components/common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../../components/common/RichTextEditor/RichTextEditorPreviewerV1'; import { getUserPath } from '../../../constants/constants'; import { EntityReference } from '../../../generated/type/entityReference'; import { getEntityName } from '../../../utils/EntityUtils'; @@ -78,7 +78,7 @@ const RolesDetailPageList = ({ dataIndex: 'description', key: 'description', render: (_, record) => ( - + ), }, { diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/RolesPage/RolesListPage/RolesListPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/RolesPage/RolesListPage/RolesListPage.test.tsx index 0d783856fd1b..8f954cb182e0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/RolesPage/RolesListPage/RolesListPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/RolesPage/RolesListPage/RolesListPage.test.tsx @@ -42,7 +42,7 @@ jest.mock('../../../components/common/DeleteWidget/DeleteWidgetModal', () => ); jest.mock( - '../../../components/common/RichTextEditor/RichTextEditorPreviewer', + '../../../components/common/RichTextEditor/RichTextEditorPreviewerV1', () => jest.fn().mockReturnValue(
Previewer
) ); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/RolesPage/RolesListPage/RolesListPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/RolesPage/RolesListPage/RolesListPage.tsx index 9a16191150c6..ad2f67620172 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/RolesPage/RolesListPage/RolesListPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/RolesPage/RolesListPage/RolesListPage.tsx @@ -23,7 +23,7 @@ import DeleteWidgetModal from '../../../components/common/DeleteWidget/DeleteWid import ErrorPlaceHolder from '../../../components/common/ErrorWithPlaceholder/ErrorPlaceHolder'; import NextPrevious from '../../../components/common/NextPrevious/NextPrevious'; import { PagingHandlerParams } from '../../../components/common/NextPrevious/NextPrevious.interface'; -import RichTextEditorPreviewer from '../../../components/common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../../components/common/RichTextEditor/RichTextEditorPreviewerV1'; import Table from '../../../components/common/Table/Table'; import TitleBreadcrumb from '../../../components/common/TitleBreadcrumb/TitleBreadcrumb.component'; import { TitleBreadcrumbProps } from '../../../components/common/TitleBreadcrumb/TitleBreadcrumb.interface'; @@ -134,7 +134,7 @@ const RolesListPage = () => { isEmpty(record?.description) ? ( NO_DATA_PLACEHOLDER ) : ( - + ), }, { diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ServiceVersionPage/ServiceVersionMainTabContent.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ServiceVersionPage/ServiceVersionMainTabContent.test.tsx index 7c5e478dde94..29839ead6b29 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ServiceVersionPage/ServiceVersionMainTabContent.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ServiceVersionPage/ServiceVersionMainTabContent.test.tsx @@ -66,7 +66,7 @@ jest.mock('../../components/common/NextPrevious/NextPrevious', () => ); jest.mock( - '../../components/common/RichTextEditor/RichTextEditorPreviewer', + '../../components/common/RichTextEditor/RichTextEditorPreviewerV1', () => jest.fn().mockImplementation(() =>
RichTextEditorPreviewer
) ); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/StoredProcedure/StoredProcedureTab.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/StoredProcedure/StoredProcedureTab.test.tsx index 12b6b0f0fff2..0bbb9c6d0d88 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/StoredProcedure/StoredProcedureTab.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/StoredProcedure/StoredProcedureTab.test.tsx @@ -31,7 +31,7 @@ jest.mock('../../components/common/NextPrevious/NextPrevious', () => { }); jest.mock( - '../../components/common/RichTextEditor/RichTextEditorPreviewer', + '../../components/common/RichTextEditor/RichTextEditorPreviewerV1', () => { return jest .fn() diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/StoredProcedure/StoredProcedureTab.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/StoredProcedure/StoredProcedureTab.tsx index 22cb98305223..5ab8f6368050 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/StoredProcedure/StoredProcedureTab.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/StoredProcedure/StoredProcedureTab.tsx @@ -20,7 +20,7 @@ import { Link } from 'react-router-dom'; import ErrorPlaceHolder from '../../components/common/ErrorWithPlaceholder/ErrorPlaceHolder'; import NextPrevious from '../../components/common/NextPrevious/NextPrevious'; import { PagingHandlerParams } from '../../components/common/NextPrevious/NextPrevious.interface'; -import RichTextEditorPreviewer from '../../components/common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../components/common/RichTextEditor/RichTextEditorPreviewerV1'; import Table from '../../components/common/Table/Table'; import { EntityType } from '../../enums/entity.enum'; import { Include } from '../../generated/type/include'; @@ -115,7 +115,7 @@ const StoredProcedureTab = () => { {t('label.no-description')} ) : ( - + ), }, ], diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TagsPage/TagsForm.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TagsPage/TagsForm.tsx index 7b30836ba823..54898b72aac3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TagsPage/TagsForm.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TagsPage/TagsForm.tsx @@ -140,7 +140,7 @@ const TagsForm = ({ type: FieldTypes.DESCRIPTION, props: { 'data-testid': 'description', - initialValue: '', + initialValue: initialValues?.description ?? '', readonly: disableDescriptionField, }, }, diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TagsPage/TagsPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TagsPage/TagsPage.test.tsx index 4c76a2858fee..bdc6f372016e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TagsPage/TagsPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TagsPage/TagsPage.test.tsx @@ -241,7 +241,7 @@ jest.mock('../../components/common/ResizablePanels/ResizableLeftPanels', () => ); jest.mock( - '../../components/common/RichTextEditor/RichTextEditorPreviewer', + '../../components/common/RichTextEditor/RichTextEditorPreviewerV1', () => { return jest.fn().mockReturnValue(

RichTextEditorPreviewer

); } diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TasksPage/RequestDescriptionPage/RequestDescriptionPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TasksPage/RequestDescriptionPage/RequestDescriptionPage.tsx index 5158464e2cec..3aaa9c214a6b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TasksPage/RequestDescriptionPage/RequestDescriptionPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TasksPage/RequestDescriptionPage/RequestDescriptionPage.tsx @@ -38,6 +38,7 @@ import { useApplicationStore } from '../../../hooks/useApplicationStore'; import useCustomLocation from '../../../hooks/useCustomLocation/useCustomLocation'; import { useFqn } from '../../../hooks/useFqn'; import { postThread } from '../../../rest/feedsAPI'; +import { isDescriptionContentEmpty } from '../../../utils/BlockEditorUtils'; import entityUtilClassBase from '../../../utils/EntityUtilClassBase'; import { ENTITY_LINK_SEPARATOR, @@ -62,7 +63,7 @@ const RequestDescription = () => { const location = useCustomLocation(); const history = useHistory(); const [form] = useForm(); - const markdownRef = useRef(); + const markdownRef = useRef({} as EditorContentRef); const { entityType } = useParams<{ entityType: EntityType }>(); @@ -120,6 +121,7 @@ const RequestDescription = () => { const onCreateTask: FormProps['onFinish'] = (value) => { setIsLoading(true); if (assignees.length) { + const suggestion = markdownRef.current?.getEditorContent?.(); const data: CreateThread = { from: currentUser?.name as string, message: value.title || taskMessage, @@ -129,12 +131,15 @@ const RequestDescription = () => { id: assignee.value, type: assignee.type, })), - suggestion: markdownRef.current?.getEditorContent(), + suggestion: isDescriptionContentEmpty(suggestion) + ? undefined + : suggestion, type: TaskType.RequestDescription, oldValue: '', }, type: ThreadType.Task, }; + postThread(data) .then(() => { showSuccessToast( diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TasksPage/UpdateDescriptionPage/UpdateDescriptionPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TasksPage/UpdateDescriptionPage/UpdateDescriptionPage.tsx index 4594fbf00b47..b3c6145cd81b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TasksPage/UpdateDescriptionPage/UpdateDescriptionPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TasksPage/UpdateDescriptionPage/UpdateDescriptionPage.tsx @@ -39,6 +39,7 @@ import { useApplicationStore } from '../../../hooks/useApplicationStore'; import useCustomLocation from '../../../hooks/useCustomLocation/useCustomLocation'; import { useFqn } from '../../../hooks/useFqn'; import { postThread } from '../../../rest/feedsAPI'; +import { isDescriptionContentEmpty } from '../../../utils/BlockEditorUtils'; import entityUtilClassBase from '../../../utils/EntityUtilClassBase'; import { ENTITY_LINK_SEPARATOR, @@ -149,7 +150,9 @@ const UpdateDescription = () => { id: assignee.value, type: assignee.type, })), - suggestion: value.description, + suggestion: isDescriptionContentEmpty(value.description) + ? '' + : value.description, type: TaskType.UpdateDescription, oldValue: currentDescription, }, diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TasksPage/shared/DescriptionTabs.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TasksPage/shared/DescriptionTabs.test.tsx index b7cd1d669cf0..7ad768a3d0c1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TasksPage/shared/DescriptionTabs.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TasksPage/shared/DescriptionTabs.test.tsx @@ -36,7 +36,7 @@ jest.mock('../../../components/common/RichTextEditor/RichTextEditor', () => { }); jest.mock( - '../../../components/common/RichTextEditor/RichTextEditorPreviewer', + '../../../components/common/RichTextEditor/RichTextEditorPreviewerV1', () => jest .fn() diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TasksPage/shared/DescriptionTabs.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TasksPage/shared/DescriptionTabs.tsx index f480d013fff0..306b35dcec19 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TasksPage/shared/DescriptionTabs.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TasksPage/shared/DescriptionTabs.tsx @@ -18,7 +18,8 @@ import React, { useCallback, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import RichTextEditor from '../../../components/common/RichTextEditor/RichTextEditor'; import { EditorContentRef } from '../../../components/common/RichTextEditor/RichTextEditor.interface'; -import RichTextEditorPreviewer from '../../../components/common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../../components/common/RichTextEditor/RichTextEditorPreviewerV1'; +import { isDescriptionContentEmpty } from '../../../utils/BlockEditorUtils'; import { getDescriptionDiff } from '../../../utils/TasksUtils'; import { DiffView } from './DiffView'; @@ -40,15 +41,19 @@ export const DescriptionTabs = ({ const [description] = useState(value); const [diffs, setDiffs] = useState([]); const [activeTab, setActiveTab] = useState('3'); - const markdownRef = useRef(); + const markdownRef = useRef({} as EditorContentRef); const onTabChange = useCallback( (key: string) => { setActiveTab(key); if (isEqual(key, '2')) { - const newDescription = markdownRef.current?.getEditorContent(); + const newDescription = markdownRef.current?.getEditorContent?.(); + const isEmptyDescription = isDescriptionContentEmpty(newDescription); if (newDescription) { - const diff = getDescriptionDiff(description, newDescription); + const diff = getDescriptionDiff( + description, + isEmptyDescription ? '' : newDescription + ); setDiffs(diff); } } else { @@ -69,7 +74,7 @@ export const DescriptionTabs = ({
{description.trim() ? ( - @@ -89,7 +94,6 @@ export const DescriptionTabs = ({ = ({
{isTaskActionEdit && hasEditAccess ? ( { const { t } = useTranslation(); const elements = diffArr.map((diff) => { + const diffValue = getTextFromHtmlString(diff.value); if (diff.added) { return ( - {diff.value} + {diffValue} ); } @@ -39,14 +41,14 @@ export const DiffView = ({ data-testid="diff-removed" key={uniqueId()} style={{ color: 'grey', textDecoration: 'line-through' }}> - {diff.value} + {diffValue} ); } return ( - {diff.value} + {diffValue} ); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TeamsPage/AddTeamForm.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TeamsPage/AddTeamForm.tsx index cb7a4da5fe20..497f099eca34 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TeamsPage/AddTeamForm.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TeamsPage/AddTeamForm.tsx @@ -16,11 +16,10 @@ import { Button, Form, Input, Modal, Select } from 'antd'; import { useForm } from 'antd/lib/form/Form'; import { AxiosError } from 'axios'; import { toLower, trim } from 'lodash'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { DomainLabel } from '../../components/common/DomainLabel/DomainLabel.component'; import RichTextEditor from '../../components/common/RichTextEditor/RichTextEditor'; -import { EditorContentRef } from '../../components/common/RichTextEditor/RichTextEditor.interface'; import { VALIDATION_MESSAGES } from '../../constants/constants'; import { NAME_FIELD_RULES } from '../../constants/Form.constants'; import { EntityType } from '../../enums/entity.enum'; @@ -52,7 +51,6 @@ const AddTeamForm: React.FC = ({ const [form] = useForm(); const [description, setDescription] = useState(''); const [allTeam, setAllTeam] = useState([]); - const markdownRef = useRef(); const { activeDomainEntityRef } = useDomainStore(); const selectedDomain = Form.useWatch('domains', form) ?? []; @@ -229,7 +227,6 @@ const AddTeamForm: React.FC = ({ setDescription(value)} /> diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorUtils.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorUtils.test.ts new file mode 100644 index 000000000000..f04c638dea51 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorUtils.test.ts @@ -0,0 +1,74 @@ +/* + * 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 { getTextFromHtmlString } from './BlockEditorUtils'; + +describe('getTextFromHtmlString', () => { + it('should return empty string when input is undefined', () => { + expect(getTextFromHtmlString(undefined)).toBe(''); + }); + + it('should return empty string when input is empty string', () => { + expect(getTextFromHtmlString('')).toBe(''); + }); + + it('should return same text when no HTML tags present', () => { + expect(getTextFromHtmlString('Hello World')).toBe('Hello World'); + }); + + it('should remove simple HTML tags', () => { + expect(getTextFromHtmlString('

Hello World

')).toBe('Hello World'); + }); + + it('should remove nested HTML tags', () => { + expect( + getTextFromHtmlString('

Hello World

') + ).toBe('Hello World'); + }); + + it('should remove HTML tags with attributes', () => { + expect( + getTextFromHtmlString( + '

Hello World

' + ) + ).toBe('Hello World'); + }); + + it('should handle multiple spaces and trim result', () => { + expect(getTextFromHtmlString('

Hello World

')).toBe( + 'Hello World' + ); + }); + + it('should preserve special characters', () => { + expect(getTextFromHtmlString('

Hello & World! @ #$%^

')).toBe( + 'Hello & World! @ #$%^' + ); + }); + + it('should handle complex nested structure', () => { + const input = ` +
+

Title

+

First paragraph with emphasis

+
    +
  • Item 1
  • +
  • Item 2
  • +
+
+ `; + + const output = getTextFromHtmlString(input); + + expect(getTextFromHtmlString(input)).toBe(output); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorUtils.ts index 3f22e7291443..1019c519b1f2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorUtils.ts @@ -12,7 +12,11 @@ */ import { EditorState } from '@tiptap/pm/state'; import { Editor } from '@tiptap/react'; +import { isEmpty } from 'lodash'; +import Showdown from 'showdown'; import { FQN_SEPARATOR_CHAR } from '../constants/char.constants'; +import { ENTITY_URL_MAP } from '../constants/Feeds.constants'; +import { getEntityDetail, getHashTagList, getMentionList } from './FeedUtils'; export const getSelectedText = (state: EditorState) => { const { from, to } = state.selection; @@ -33,6 +37,45 @@ export const isInViewport = (ele: HTMLElement, container: HTMLElement) => { return eleTop >= containerTop && eleBottom <= containerBottom; }; +const _convertMarkdownFormatToHtmlString = (markdown: string) => { + let updatedMessage = markdown; + const urlEntries = Object.entries(ENTITY_URL_MAP); + + const mentionList = getMentionList(markdown) ?? []; + const hashTagList = getHashTagList(markdown) ?? []; + + const mentionMap = new Map( + mentionList.map((mention) => [mention, getEntityDetail(mention)]) + ); + + const hashTagMap = new Map( + hashTagList.map((hashTag) => [hashTag, getEntityDetail(hashTag)]) + ); + + mentionMap.forEach((value, key) => { + if (value) { + const [, href, rawEntityType, fqn] = value; + const entityType = urlEntries.find((e) => e[1] === rawEntityType)?.[0]; + + if (entityType) { + const entityLink = `@${fqn}`; + updatedMessage = updatedMessage.replaceAll(key, entityLink); + } + } + }); + + hashTagMap.forEach((value, key) => { + if (value) { + const [, href, rawEntityType, fqn] = value; + + const entityLink = `#${fqn}`; + updatedMessage = updatedMessage.replaceAll(key, entityLink); + } + }); + + return updatedMessage; +}; + export type FormatContentFor = 'server' | 'client'; export const formatContent = ( @@ -41,7 +84,10 @@ export const formatContent = ( ) => { // Create a new DOMParser const parser = new DOMParser(); - const doc = parser.parseFromString(htmlString, 'text/html'); + const doc = parser.parseFromString( + _convertMarkdownFormatToHtmlString(htmlString), + 'text/html' + ); // Use querySelectorAll to find all anchor tags with text content starting with "@" or "#" const anchorTags = doc.querySelectorAll( @@ -84,13 +130,25 @@ export const isHTMLString = (content: string) => { } }; +/** + * Convert a markdown string to an HTML string + */ +const _convertMarkdownStringToHtmlString = new Showdown.Converter({ + ghCodeBlocks: false, + encodeEmails: false, + ellipsis: false, +}); + /** * Set the content of the editor * @param editor The editor instance * @param newContent The new content to set */ export const setEditorContent = (editor: Editor, newContent: string) => { - editor.commands.setContent(newContent); + // Convert the markdown string to an HTML string + const htmlString = _convertMarkdownStringToHtmlString.makeHtml(newContent); + + editor.commands.setContent(htmlString); // Update the editor state to reflect the new content const newEditorState = EditorState.create({ @@ -102,3 +160,26 @@ export const setEditorContent = (editor: Editor, newContent: string) => { }); editor.view.updateState(newEditorState); }; + +/** + * + * @param content The content to check + * @returns Whether the content is empty or not + */ +export const isDescriptionContentEmpty = (content: string) => { + // Check if the content is empty or has only empty paragraph tags + return isEmpty(content) || content === '

'; +}; + +/** + * + * @param description HTML string + * @returns Text from HTML string + */ +export const getTextFromHtmlString = (description?: string): string => { + if (!description) { + return ''; + } + + return description.replace(/<[^>]{1,1000}>/g, '').trim(); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/ClassificationUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/ClassificationUtils.tsx index 73b7c29277f0..fb94b72ea7d3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/ClassificationUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ClassificationUtils.tsx @@ -19,7 +19,7 @@ import { Link } from 'react-router-dom'; import { ReactComponent as IconDisableTag } from '../assets/svg/disable-tag.svg'; import { ReactComponent as EditIcon } from '../assets/svg/edit-new.svg'; import { ManageButtonItemLabel } from '../components/common/ManageButtonContentItem/ManageButtonContentItem.component'; -import RichTextEditorPreviewer from '../components/common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../components/common/RichTextEditor/RichTextEditorPreviewerV1'; import { NO_DATA_PLACEHOLDER } from '../constants/constants'; import { OperationPermission } from '../context/PermissionProvider/PermissionProvider.interface'; import { ProviderType } from '../generated/entity/bot'; @@ -101,7 +101,7 @@ export const getCommonColumns = (): ColumnsType => [
{text ? ( - + ) : ( {t('label.no-entity', { diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/Database/Database.util.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/Database/Database.util.tsx index f298ff562506..060fc69961d7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/Database/Database.util.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/Database/Database.util.tsx @@ -17,7 +17,7 @@ import { isUndefined, toLower } from 'lodash'; import React from 'react'; import { Link } from 'react-router-dom'; import { OwnerLabel } from '../../components/common/OwnerLabel/OwnerLabel.component'; -import RichTextEditorPreviewer from '../../components/common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../../components/common/RichTextEditor/RichTextEditorPreviewerV1'; import { getEntityDetailsPath, NO_DATA_PLACEHOLDER, @@ -84,7 +84,7 @@ export const schemaTableColumns: ColumnsType = [ key: 'description', render: (text: string) => text?.trim() ? ( - + ) : ( {t('label.no-entity', { entity: t('label.description') })} diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/Database/__snapshots__/Database.util.test.tsx.snap b/openmetadata-ui/src/main/resources/ui/src/utils/Database/__snapshots__/Database.util.test.tsx.snap index b722bd4d5a82..7c1b99c693f6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/Database/__snapshots__/Database.util.test.tsx.snap +++ b/openmetadata-ui/src/main/resources/ui/src/utils/Database/__snapshots__/Database.util.test.tsx.snap @@ -15,7 +15,7 @@ exports[`Database Util Database Util - schemaTableColumns should render the corr `; exports[`Database Util Database Util - schemaTableColumns should render the correct columns 2`] = ` - `; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/EntityVersionUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/EntityVersionUtils.tsx index e67551d66224..64840e0cde6c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/EntityVersionUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/EntityVersionUtils.tsx @@ -139,6 +139,7 @@ export const getAddedDiffElement = (text: string) => { return ( {text} @@ -150,6 +151,7 @@ export const getRemovedDiffElement = (text: string) => { return ( {text} @@ -159,7 +161,7 @@ export const getRemovedDiffElement = (text: string) => { export const getNormalDiffElement = (text: string) => { return ( - + {text} ); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/FeedUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/FeedUtils.tsx index e16897e93a94..0545d5ca24ab 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/FeedUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/FeedUtils.tsx @@ -294,15 +294,15 @@ export const userMentionItemWithAvatar = ( return wrapper; }; -const getMentionList = (message: string) => { +export const getMentionList = (message: string) => { return message.match(mentionRegEx); }; -const getHashTagList = (message: string) => { +export const getHashTagList = (message: string) => { return message.match(hashtagRegEx); }; -const getEntityDetail = (item: string) => { +export const getEntityDetail = (item: string) => { if (item.includes('teams')) { return item.match(teamsLinkRegEx); } diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/ServiceMainTabContentUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/ServiceMainTabContentUtils.tsx index 2c58500e1216..30fd46060f2b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/ServiceMainTabContentUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ServiceMainTabContentUtils.tsx @@ -19,7 +19,7 @@ import { ServiceTypes } from 'Models'; import React from 'react'; import DisplayName from '../components/common/DisplayName/DisplayName'; import { OwnerLabel } from '../components/common/OwnerLabel/OwnerLabel.component'; -import RichTextEditorPreviewer from '../components/common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../components/common/RichTextEditor/RichTextEditorPreviewerV1'; import { EntityName } from '../components/Modals/EntityNameModal/EntityNameModal.interface'; import TagsViewer from '../components/Tag/TagsViewer/TagsViewer'; import { NO_DATA_PLACEHOLDER } from '../constants/constants'; @@ -69,7 +69,7 @@ export const getServiceMainTabColumns = ( key: 'description', render: (description: ServicePageData['description']) => !isUndefined(description) && description.trim() ? ( - + ) : ( {t('label.no-entity', { 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 480bcb01dab1..0f03af348feb 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/TagsUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/TagsUtils.tsx @@ -21,7 +21,7 @@ import type { CustomTagProps } from 'rc-select/lib/BaseSelect'; import React from 'react'; import { ReactComponent as DeleteIcon } from '../assets/svg/ic-delete.svg'; import Loader from '../components/common/Loader/Loader'; -import RichTextEditorPreviewer from '../components/common/RichTextEditor/RichTextEditorPreviewer'; +import RichTextEditorPreviewerV1 from '../components/common/RichTextEditor/RichTextEditorPreviewerV1'; import { FQN_SEPARATOR_CHAR } from '../constants/char.constants'; import { getExplorePath } from '../constants/constants'; import { @@ -163,7 +163,7 @@ export const getTagDisplay = (tag?: string) => { export const getTagTooltip = (fqn: string, description?: string) => (
- { internalFormItemProps = { ...internalFormItemProps, trigger: 'onTextChange', - valuePropName: 'initialValue', + initialValue: props?.initialValue ?? '', }; break; From 5fbeff1e0c9da6411fe4864b1e080517296f79f3 Mon Sep 17 00:00:00 2001 From: Sachin Chaurasiya Date: Sat, 28 Dec 2024 12:40:45 +0530 Subject: [PATCH 030/294] refactor: remove unused highlight option from BarMenu and improve focus styles in block editor (#19184) (cherry picked from commit c736f06bbe4023f7e6d931944890883162e7277b) --- .../ui/src/components/BlockEditor/BarMenu/BarMenu.tsx | 9 +-------- .../ui/src/components/BlockEditor/block-editor.less | 8 ++++++++ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BarMenu/BarMenu.tsx b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BarMenu/BarMenu.tsx index d718b61cd9e0..8c6ff087e2da 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BarMenu/BarMenu.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BarMenu/BarMenu.tsx @@ -11,13 +11,12 @@ * limitations under the License. */ import classNames from 'classnames'; -import { noop, uniqueId } from 'lodash'; +import { uniqueId } from 'lodash'; import React, { FC, Fragment } from 'react'; import BlockQuoteIcon from '../../../assets/svg/ic-format-block-quote.svg'; import BoldIcon from '../../../assets/svg/ic-format-bold.svg'; import UnorderedListIcon from '../../../assets/svg/ic-format-bullet-list.svg'; import CodeBlockIcon from '../../../assets/svg/ic-format-code-block.svg'; -import HighlightIcon from '../../../assets/svg/ic-format-highlight.svg'; import HorizontalLineIcon from '../../../assets/svg/ic-format-horizontal-line.svg'; import ImageIcon from '../../../assets/svg/ic-format-image-inline.svg'; import InlineCodeIcon from '../../../assets/svg/ic-format-inline-code.svg'; @@ -57,12 +56,6 @@ const BarMenu: FC = ({ editor, onLinkToggle }) => { command: () => editor.chain().focus().toggleCode().run(), isActive: () => editor.isActive('code'), }, - { - name: 'highlight', - icon: HighlightIcon, - command: () => noop, - isActive: () => editor.isActive('highlight'), - }, ], [ { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/block-editor.less b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/block-editor.less index 6141fa7ff5bc..6dfb77acf427 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/block-editor.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/block-editor.less @@ -219,6 +219,14 @@ } } + &.block-editor-wrapper--bubble-menu { + .om-block-editor { + &:focus-visible { + outline: none; + } + } + } + // if contenteditable is false, remove padding-bottom from last p tag .om-block-editor[contenteditable='false'] { > p:last-child { From b28b08a39e2d0a3ce7636e010d9c84a6969e9a41 Mon Sep 17 00:00:00 2001 From: Imri Paran Date: Thu, 19 Dec 2024 14:03:01 +0100 Subject: [PATCH 031/294] fix(error-apps): reset apps in error state (#19052) (cherry picked from commit 6fab88a3d10a2d6db8c8a45949fcd89ac607641a) --- .../service/apps/scheduler/AppScheduler.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) 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 3163fc9a2e66..542edba0ccd6 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 @@ -38,6 +38,7 @@ import org.quartz.TriggerBuilder; import org.quartz.TriggerKey; import org.quartz.impl.StdSchedulerFactory; +import org.quartz.impl.matchers.GroupMatcher; @Slf4j public class AppScheduler { @@ -93,10 +94,32 @@ private AppScheduler( .getListenerManager() .addJobListener(new OmAppJobListener(dao), jobGroupEquals(APPS_JOB_GROUP)); + this.resetErrorTriggers(); + // Start Scheduler this.scheduler.start(); } + private void resetErrorTriggers() { + try { + scheduler + .getTriggerKeys(GroupMatcher.anyGroup()) + .forEach( + triggerKey -> { + try { + if (scheduler.getTriggerState(triggerKey) == Trigger.TriggerState.ERROR) { + LOG.info("Resetting trigger {} from error state", triggerKey); + scheduler.resetTriggerFromErrorState(triggerKey); + } + } catch (SchedulerException e) { + throw new RuntimeException(e); + } + }); + } catch (SchedulerException ex) { + LOG.error("Failed to reset failed triggers", ex); + } + } + public static void initialize( OpenMetadataApplicationConfig config, CollectionDAO dao, SearchRepository searchClient) throws SchedulerException { From db80d225e900d8477b81bb2ea69c33964624e3a4 Mon Sep 17 00:00:00 2001 From: IceS2 Date: Wed, 18 Dec 2024 10:10:32 +0100 Subject: [PATCH 032/294] Fix checkstyle (#19099) --- .../ingestion/source/database/db2/metadata.py | 6 ++ .../ingestion/source/database/db2/utils.py | 63 +++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 ingestion/src/metadata/ingestion/source/database/db2/utils.py diff --git a/ingestion/src/metadata/ingestion/source/database/db2/metadata.py b/ingestion/src/metadata/ingestion/source/database/db2/metadata.py index 794cb3b85903..44baf862e60d 100644 --- a/ingestion/src/metadata/ingestion/source/database/db2/metadata.py +++ b/ingestion/src/metadata/ingestion/source/database/db2/metadata.py @@ -13,6 +13,7 @@ from typing import Iterable, Optional from ibm_db_sa.base import ischema_names +from ibm_db_sa.reflection import DB2Reflector, OS390Reflector from sqlalchemy.engine.reflection import Inspector from sqlalchemy.engine.row import LegacyRow from sqlalchemy.sql.sqltypes import BOOLEAN @@ -26,6 +27,7 @@ from metadata.ingestion.api.steps import InvalidSourceException from metadata.ingestion.ometa.ometa_api import OpenMetadata from metadata.ingestion.source.database.common_db_source import CommonDbSourceService +from metadata.ingestion.source.database.db2.utils import get_unique_constraints from metadata.utils.logger import ingestion_logger logger = ingestion_logger() @@ -34,6 +36,10 @@ ischema_names.update({"BOOLEAN": BOOLEAN}) +DB2Reflector.get_unique_constraints = get_unique_constraints +OS390Reflector.get_unique_constraints = get_unique_constraints + + class Db2Source(CommonDbSourceService): """ Implements the necessary methods to extract diff --git a/ingestion/src/metadata/ingestion/source/database/db2/utils.py b/ingestion/src/metadata/ingestion/source/database/db2/utils.py new file mode 100644 index 000000000000..657942aca471 --- /dev/null +++ b/ingestion/src/metadata/ingestion/source/database/db2/utils.py @@ -0,0 +1,63 @@ +# Copyright 2021 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. + +""" +Module to define overriden dialect methods +""" +from sqlalchemy import and_, join, sql +from sqlalchemy.engine import reflection + + +@reflection.cache +def get_unique_constraints( + self, connection, table_name, schema=None, **kw +): # pylint: disable=unused-argument + """Small Method to override the Dialect default as it is not filtering properly the Schema and Table Name.""" + current_schema = self.denormalize_name(schema or self.default_schema_name) + table_name = self.denormalize_name(table_name) + syskeycol = self.sys_keycoluse + sysconst = self.sys_tabconst + query = ( + sql.select(syskeycol.c.constname, syskeycol.c.colname) + .select_from( + join( + syskeycol, + sysconst, + and_( + syskeycol.c.constname == sysconst.c.constname, + syskeycol.c.tabschema == sysconst.c.tabschema, + syskeycol.c.tabname == sysconst.c.tabname, + ), + ) + ) + .where( + and_( + sysconst.c.tabname == table_name, + sysconst.c.tabschema == current_schema, + sysconst.c.type == "U", + ) + ) + .order_by(syskeycol.c.constname) + ) + unique_consts = [] + curr_const = None + for r in connection.execute(query): + if curr_const == r[0]: + unique_consts[-1]["column_names"].append(self.normalize_name(r[1])) + else: + curr_const = r[0] + unique_consts.append( + { + "name": self.normalize_name(curr_const), + "column_names": [self.normalize_name(r[1])], + } + ) + return unique_consts From 79511566e4594699c5a4b956c16275423301f5ce Mon Sep 17 00:00:00 2001 From: Ashish Gupta Date: Fri, 3 Jan 2025 10:09:33 +0530 Subject: [PATCH 033/294] #16465 : supported glossary table column resizable (#17918) * supported glossary table column resizable * code cleanup * fix the column resizing affecting other column width * fix the playwright failure around the glossary due to re-sizable added (cherry picked from commit 87c07f3ecc418c30b2edf5f91c51b95d11c2a71d) --- .../resources/ui/playwright/utils/glossary.ts | 6 ++++- .../GlossaryTermTab.component.tsx | 19 +++++++++------- .../ui/src/components/common/Table/Table.tsx | 8 ++++++- .../ui/src/styles/components/table.less | 22 +++++++++++++++++++ 4 files changed, 45 insertions(+), 10 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts index 4d2a85554609..4ee37ae5ca0c 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts @@ -1221,7 +1221,11 @@ export const filterStatus = async ( await saveButton.click(); const glossaryTermsTable = page.getByTestId('glossary-terms-table'); - const rows = glossaryTermsTable.locator('tbody tr'); + // will select all
elements inside the but exclude those with aria-hidden="true" + // since we have added re-sizeable columns, that one entry is present in the tbody + const rows = glossaryTermsTable.locator( + 'tbody.ant-table-tbody > tr:not([aria-hidden="true"])' + ); const statusColumnIndex = 3; for (let i = 0; i < (await rows.count()); i++) { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx index 1c6b5246158a..c0135d64f7b0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx @@ -155,7 +155,7 @@ const GlossaryTermTab = ({ key: 'name', className: 'glossary-name-column', ellipsis: true, - width: '40%', + width: 200, render: (_, record) => { const name = getEntityName(record); @@ -184,7 +184,7 @@ const GlossaryTermTab = ({ title: t('label.description'), dataIndex: 'description', key: 'description', - width: permissions.Create ? '21%' : '33%', + width: 250, render: (description: string) => description.trim() ? ( ( { return isEmpty(synonyms) ? (
{NO_DATA_PLACEHOLDER}
@@ -235,14 +235,18 @@ const GlossaryTermTab = ({ title: t('label.owner-plural'), dataIndex: 'owners', key: 'owners', - width: '17%', + width: 150, render: (owners: EntityReference[]) => , }, { title: t('label.status'), dataIndex: 'status', key: 'status', - width: '12%', + // this check is added to the width, since the last column is optional and to maintain + // the re-sizing of the column should not be affected the others columns width sizes. + ...(permissions.Create && { + width: 100, + }), render: (_, record) => { const status = record.status ?? Status.Approved; @@ -261,7 +265,6 @@ const GlossaryTermTab = ({ data.push({ title: t('label.action-plural'), key: 'new-term', - width: '10%', render: (_, record) => { const status = record.status ?? Status.Approved; const allowAddTerm = status === Status.Approved; @@ -887,6 +890,7 @@ const GlossaryTermTab = ({
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/Table/Table.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/Table/Table.tsx index 85b9b1e38e8b..ec80a3752ee9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/Table/Table.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/Table/Table.tsx @@ -72,7 +72,13 @@ const Table = ({ const resizingTableProps = rest.resizableColumns ? { columns: resizableColumns, - components, + components: { + ...rest.components, + header: { + row: rest.components?.header?.row, + cell: components.header.cell, + }, + }, scroll: { x: tableWidth }, } : {}; diff --git a/openmetadata-ui/src/main/resources/ui/src/styles/components/table.less b/openmetadata-ui/src/main/resources/ui/src/styles/components/table.less index 5bbdd79a0660..73e25daf62c9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/styles/components/table.less +++ b/openmetadata-ui/src/main/resources/ui/src/styles/components/table.less @@ -11,6 +11,8 @@ * limitations under the License. */ +@import (reference) url('../variables.less'); + .ant-table-wrapper { .ant-table-thead { tr > th { @@ -136,3 +138,23 @@ gap: 8px; } } + +// Setting the background color of the resizable column to where dragger is located +.ant-table.ant-table-bordered { + > .ant-table-container { + > .ant-table-content, + > .ant-table-header, + > .ant-table-body, + > .ant-table-summary { + > table { + > thead { + > tr { + > .resizable-container::before { + background-color: @border-color !important; + } + } + } + } + } + } +} From 1721f4c38f112b083ed0445a129b77d8c7c34b6a Mon Sep 17 00:00:00 2001 From: Ashish Gupta Date: Fri, 3 Jan 2025 10:35:26 +0530 Subject: [PATCH 034/294] MINOR: modify and fix some lineage issues (#19107) * modify and fix some lineage issues * minor fix * restrict the node removal in case any error occured * fix flaky playwright test * userDetail flaky fix (cherry picked from commit 9edc078027ae619d289cdb68ad6b998f21da84dd) --- .../playwright/e2e/Pages/UserDetails.spec.ts | 13 +- .../ui/src/assets/svg/ic-edit-circle.svg | 16 +++ .../AppPipelineModel/AddPipeLineModal.tsx | 3 +- .../EntityLineage/CustomEdge.component.tsx | 14 +- .../LineageProvider/LineageProvider.tsx | 17 ++- .../database/cockroachConnection.ts | 124 ++++++++++++++++++ .../entity/services/databaseService.ts | 2 +- 7 files changed, 174 insertions(+), 15 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-edit-circle.svg create mode 100644 openmetadata-ui/src/main/resources/ui/src/generated/entity/services/connections/database/cockroachConnection.ts diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/UserDetails.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/UserDetails.spec.ts index 2c46f488be11..cb96de489273 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/UserDetails.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/UserDetails.spec.ts @@ -130,6 +130,10 @@ test.describe('User with different Roles', () => { .getByTestId('edit-description') .click(); + await userPage.waitForSelector('[role="dialog"].ant-modal', { + state: 'visible', + }); + // Add description content await userPage.locator(descriptionBox).fill(USER_DESCRIPTION); @@ -155,9 +159,16 @@ test.describe('User with different Roles', () => { .getByTestId('edit-description') .click(); - await userPage.click(descriptionBox); + await userPage.waitForSelector('[role="dialog"].ant-modal', { + state: 'visible', + }); + await userPage.locator(descriptionBox).clear(); + await expect(userPage.locator(descriptionBox)).not.toContainText( + 'Name of the User' + ); + const removeUserDescription = userPage.waitForResponse( (response) => response.request().method() === 'PATCH' ); diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-edit-circle.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-edit-circle.svg new file mode 100644 index 000000000000..416e5fbbf474 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-edit-circle.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/AppPipelineModel/AddPipeLineModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/AppPipelineModel/AddPipeLineModal.tsx index 50406275c4cd..97809af2bd56 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/AppPipelineModel/AddPipeLineModal.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/AppPipelineModel/AddPipeLineModal.tsx @@ -125,9 +125,10 @@ const AddPipeLineModal = ({ data-testid="add-edge-modal" footer={[ @@ -897,9 +901,7 @@ const GlossaryTermTab = ({ columns={rearrangedColumns.filter((col) => !col.hidden)} components={TABLE_CONSTANTS} data-testid="glossary-terms-table" - dataSource={glossaryTerms.filter((term) => - selectedStatus.includes(term.status as string) - )} + dataSource={filteredGlossaryTerms} expandable={expandableConfig} loading={isTableLoading} pagination={false} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/useGlossary.store.ts b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/useGlossary.store.ts index f8799cb02a13..9b5c6f2afe46 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/useGlossary.store.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/useGlossary.store.ts @@ -18,6 +18,7 @@ import { findAndUpdateNested } from '../../utils/GlossaryUtils'; export type ModifiedGlossary = Glossary & { children?: GlossaryTermWithChildren[]; + childrenCount?: number; }; export const useGlossaryStore = create<{ diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.test.ts index 11263c62500a..066663638f4f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.test.ts @@ -223,7 +223,7 @@ describe('Glossary Utils', () => { }); }); -describe('findAndUpdateNested', () => { +describe('Glossary Utils - findAndUpdateNested', () => { it('should add new term to the correct parent', () => { const terms: ModifiedGlossary[] = [ { @@ -263,6 +263,7 @@ describe('findAndUpdateNested', () => { const updatedTerms = findAndUpdateNested(terms, newTerm); + expect(updatedTerms[0].childrenCount).toBe(1); expect(updatedTerms[0].children).toHaveLength(1); expect(updatedTerms?.[0].children?.[0]).toEqual(newTerm); }); @@ -310,14 +311,11 @@ describe('findAndUpdateNested', () => { const updatedTerms = findAndUpdateNested(terms, newTerm); - expect( - updatedTerms?.[0].children && updatedTerms?.[0].children[0].children - ).toHaveLength(1); - expect( - updatedTerms?.[0].children && - updatedTerms?.[0].children[0].children && - updatedTerms?.[0].children[0].children[0] - ).toEqual(newTerm); + const modifiedTerms = updatedTerms[0].children?.[0].children ?? []; + + expect(modifiedTerms).toHaveLength(1); + expect(updatedTerms[0].children?.[0].childrenCount).toBe(1); + expect(modifiedTerms[0]).toEqual(newTerm); }); it('should not modify terms if parent is not found', () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.tsx index 96c553405090..ecf5a1e46c42 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.tsx @@ -349,9 +349,13 @@ export const findAndUpdateNested = ( // So we need to find the parent term and update it's children return terms.map((term) => { if (term.fullyQualifiedName === newTerm.parent?.fullyQualifiedName) { + const children = [...(term.children || []), newTerm] as GlossaryTerm[]; + return { ...term, - children: [...(term.children || []), newTerm] as GlossaryTerm[], + children, + // Need to update childrenCount in case of 0 to update expand / collapse icon + childrenCount: children.length, } as ModifiedGlossary; } else if ('children' in term && term.children?.length) { return { From 3543154ef14532ee6f30ca5aaa373da77eef67c6 Mon Sep 17 00:00:00 2001 From: Sachin Chaurasiya Date: Thu, 12 Dec 2024 19:33:56 +0530 Subject: [PATCH 038/294] minor: add support for ttf file extension in pom.xml (#19027) (cherry picked from commit 7af510087a8c448fecf96c378ee73db0c5a4de88) --- openmetadata-ui/pom.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/openmetadata-ui/pom.xml b/openmetadata-ui/pom.xml index 5623c41b7810..da7e671c6ed9 100644 --- a/openmetadata-ui/pom.xml +++ b/openmetadata-ui/pom.xml @@ -182,6 +182,7 @@ gz woff woff2 + ttf eot svg png From 4192f380f223b0472e8c99de846935357349b1c0 Mon Sep 17 00:00:00 2001 From: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> Date: Fri, 3 Jan 2025 15:47:17 +0530 Subject: [PATCH 039/294] chore(ui): ui errors and glossary term api response fix (#19215) --- .../ClassificationDetails/ClassificationDetails.tsx | 6 ++---- .../DataProductsDetailsPage.component.tsx | 3 ++- .../DomainDetailsPage/DomainDetailsPage.component.tsx | 3 ++- .../Glossary/GlossaryHeader/GlossaryHeader.component.tsx | 3 ++- .../src/main/resources/ui/src/pages/TagPage/TagPage.tsx | 3 ++- .../src/main/resources/ui/src/rest/glossaryAPI.ts | 6 ++++-- 6 files changed, 14 insertions(+), 10 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Classifications/ClassificationDetails/ClassificationDetails.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Classifications/ClassificationDetails/ClassificationDetails.tsx index 75c1ad427226..bb96aed3ec67 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Classifications/ClassificationDetails/ClassificationDetails.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Classifications/ClassificationDetails/ClassificationDetails.tsx @@ -65,6 +65,7 @@ import NextPrevious from '../../common/NextPrevious/NextPrevious'; import { NextPreviousProps } from '../../common/NextPrevious/NextPrevious.interface'; import Table from '../../common/Table/Table'; import EntityHeaderTitle from '../../Entity/EntityHeaderTitle/EntityHeaderTitle.component'; +import { EntityName } from '../../Modals/EntityNameModal/EntityNameModal.interface'; import { ClassificationDetailsProps } from './ClassificationDetails.interface'; const ClassificationDetails = forwardRef( @@ -192,10 +193,7 @@ const ClassificationDetails = forwardRef( [currentClassification?.disabled] ); - const handleUpdateDisplayName = async (data: { - name: string; - displayName: string; - }) => { + const handleUpdateDisplayName = async (data: EntityName) => { if ( !isUndefined(currentClassification) && !isUndefined(handleUpdateClassification) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx index 8f14ea508300..b57c43f75036 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx @@ -86,6 +86,7 @@ import AssetsTabs, { import { AssetsOfEntity } from '../../Glossary/GlossaryTerms/tabs/AssetsTabs.interface'; import EntityDeleteModal from '../../Modals/EntityDeleteModal/EntityDeleteModal'; import EntityNameModal from '../../Modals/EntityNameModal/EntityNameModal.component'; +import { EntityName } from '../../Modals/EntityNameModal/EntityNameModal.interface'; import StyleModal from '../../Modals/StyleModal/StyleModal.component'; import './data-products-details-page.less'; import { @@ -318,7 +319,7 @@ const DataProductsDetailsPage = ({ assetTabRef.current?.refreshAssets(); }; - const onNameSave = (obj: { name: string; displayName: string }) => { + const onNameSave = (obj: EntityName) => { if (dataProduct) { const { displayName } = obj; let updatedDetails = cloneDeep(dataProduct); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetailsPage/DomainDetailsPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetailsPage/DomainDetailsPage.component.tsx index 3547195d83bb..f9fe1a18d423 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetailsPage/DomainDetailsPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetailsPage/DomainDetailsPage.component.tsx @@ -102,6 +102,7 @@ import ResizablePanels from '../../common/ResizablePanels/ResizablePanels'; import TabsLabel from '../../common/TabsLabel/TabsLabel.component'; import { AssetSelectionModal } from '../../DataAssets/AssetsSelectionModal/AssetSelectionModal'; import { EntityDetailsObjectInterface } from '../../Explore/ExplorePage.interface'; +import { EntityName } from '../../Modals/EntityNameModal/EntityNameModal.interface'; import StyleModal from '../../Modals/StyleModal/StyleModal.component'; import AddDomainForm from '../AddDomainForm/AddDomainForm.component'; import AddSubDomainModal from '../AddSubDomainModal/AddSubDomainModal.component'; @@ -381,7 +382,7 @@ const DomainDetailsPage = ({ setShowAddDataProductModal(true); }, []); - const onNameSave = (obj: { name: string; displayName: string }) => { + const onNameSave = (obj: EntityName) => { const { displayName } = obj; let updatedDetails = cloneDeep(domain); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryHeader/GlossaryHeader.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryHeader/GlossaryHeader.component.tsx index 9c0851a77ab9..a04e6102cca6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryHeader/GlossaryHeader.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryHeader/GlossaryHeader.component.tsx @@ -71,6 +71,7 @@ import { showErrorToast } from '../../../utils/ToastUtils'; import { TitleBreadcrumbProps } from '../../common/TitleBreadcrumb/TitleBreadcrumb.interface'; import Voting from '../../Entity/Voting/Voting.component'; import ChangeParentHierarchy from '../../Modals/ChangeParentHierarchy/ChangeParentHierarchy.component'; +import { EntityName } from '../../Modals/EntityNameModal/EntityNameModal.interface'; import StyleModal from '../../Modals/StyleModal/StyleModal.component'; import { GlossaryStatusBadge } from '../GlossaryStatusBadge/GlossaryStatusBadge.component'; import { GlossaryHeaderProps } from './GlossaryHeader.interface'; @@ -239,7 +240,7 @@ const GlossaryHeader = ({ setIsDelete(false); }; - const onNameSave = async (obj: { name: string; displayName: string }) => { + const onNameSave = async (obj: EntityName) => { const { name, displayName } = obj; let updatedDetails = cloneDeep(selectedData); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.tsx index cbf30878fe85..40a765ba2c3c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.tsx @@ -58,6 +58,7 @@ import AssetsTabs, { import { AssetsOfEntity } from '../../components/Glossary/GlossaryTerms/tabs/AssetsTabs.interface'; import EntityDeleteModal from '../../components/Modals/EntityDeleteModal/EntityDeleteModal'; import EntityNameModal from '../../components/Modals/EntityNameModal/EntityNameModal.component'; +import { EntityName } from '../../components/Modals/EntityNameModal/EntityNameModal.interface'; import StyleModal from '../../components/Modals/StyleModal/StyleModal.component'; import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1'; import { @@ -258,7 +259,7 @@ const TagPage = () => { } }; - const onNameSave = async (obj: Tag) => { + const onNameSave = async (obj: EntityName) => { if (tagItem) { const { displayName } = obj; let updatedDetails = cloneDeep(tagItem); diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/glossaryAPI.ts b/openmetadata-ui/src/main/resources/ui/src/rest/glossaryAPI.ts index 2b6a4db5cd0f..d95169bc6d4e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/glossaryAPI.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/glossaryAPI.ts @@ -145,12 +145,14 @@ export const getGlossaryTermByFQN = async (fqn = '', params?: ListParams) => { return response.data; }; -export const addGlossaryTerm = ( +export const addGlossaryTerm = async ( data: CreateGlossaryTerm ): Promise => { const url = '/glossaryTerms'; - return APIClient.post(url, data); + const response = await APIClient.post(url, data); + + return response.data; }; export const patchGlossaryTerm = async (id: string, patch: Operation[]) => { From 7c5584e3bf9648fd10bee2aa4825d8aa9c86d62f Mon Sep 17 00:00:00 2001 From: sonika-shah <58761340+sonika-shah@users.noreply.github.com> Date: Sun, 5 Jan 2025 23:16:31 +0530 Subject: [PATCH 040/294] Bug Fix : ApiEndpoint reindexing failure (#19200) (cherry picked from commit 38a970b829f0b094ff6ed7d2d20ea59da7ebb3ed) --- .../openmetadata/service/search/indexes/APIEndpointIndex.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/indexes/APIEndpointIndex.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/indexes/APIEndpointIndex.java index 9fbfa1555395..b55e07b897a8 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/indexes/APIEndpointIndex.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/indexes/APIEndpointIndex.java @@ -75,7 +75,7 @@ public Map buildSearchIndexDocInternal(Map doc) && apiEndpoint.getRequestSchema().getSchemaFields() != null && !apiEndpoint.getRequestSchema().getSchemaFields().isEmpty()) { List flattenFields = new ArrayList<>(); - parseSchemaFields(apiEndpoint.getResponseSchema().getSchemaFields(), flattenFields, null); + parseSchemaFields(apiEndpoint.getRequestSchema().getSchemaFields(), flattenFields, null); for (FlattenSchemaField field : flattenFields) { fieldSuggest.add(SearchSuggest.builder().input(field.getName()).weight(5).build()); From 35b281f9e334f5f2d877f6e12a34bb9bfea5b128 Mon Sep 17 00:00:00 2001 From: sonika-shah <58761340+sonika-shah@users.noreply.github.com> Date: Sun, 15 Dec 2024 00:05:17 +0530 Subject: [PATCH 041/294] Minor : Fix #18606 prevent overwriting style for PUT request (#18864) * Minor : Fix 18606 prevent overwriting domain for PUT request * load the attributes of glossaryTerm first and override with the import * change logging to warn type * revert changes --------- Co-authored-by: Sriharsha Chintalapani --- .../org/openmetadata/service/jdbi3/EntityRepository.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java index d4206190e298..e86f736d444c 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java @@ -131,6 +131,7 @@ import org.openmetadata.schema.entity.feed.Suggestion; import org.openmetadata.schema.entity.teams.Team; import org.openmetadata.schema.entity.teams.User; +import org.openmetadata.schema.entity.type.Style; import org.openmetadata.schema.system.EntityError; import org.openmetadata.schema.type.ApiStatus; import org.openmetadata.schema.type.AssetCertification; @@ -2969,6 +2970,14 @@ private static List getEntityReferences(List r private void updateStyle() { if (supportsStyle) { + Style originalStyle = original.getStyle(); + Style updatedStyle = updated.getStyle(); + + if (originalStyle == updatedStyle) return; + if (operation == Operation.PUT && updatedStyle == null) { + updatedStyle = originalStyle; + updated.setStyle(updatedStyle); + } recordChange(FIELD_STYLE, original.getStyle(), updated.getStyle(), true); } } From bc825f8d59fecdc0913b9bacf339b867b5fd162b Mon Sep 17 00:00:00 2001 From: sonika-shah <58761340+sonika-shah@users.noreply.github.com> Date: Sun, 5 Jan 2025 23:16:14 +0530 Subject: [PATCH 042/294] Fix - Existing custom properties getting removed on import of empty extension column (#19234) --- .../org/openmetadata/service/jdbi3/EntityRepository.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java index e86f736d444c..7eac71fda0a0 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java @@ -2822,6 +2822,13 @@ private void updateExtension() { return; } + if (operation == Operation.PUT && updatedExtension == null) { + // Revert change to non-empty extension if it is being updated by a PUT request + // For PUT operations, existing extension can't be removed. + updated.setExtension(origExtension); + return; + } + List added = new ArrayList<>(); List deleted = new ArrayList<>(); JsonNode origFields = JsonUtils.valueToTree(origExtension); From 3478c37ee6c774047af225d072b04c573c593a66 Mon Sep 17 00:00:00 2001 From: Teddy Date: Mon, 6 Jan 2025 13:37:36 +0100 Subject: [PATCH 043/294] MINOR - fix unity profiler (#19247) (cherry picked from commit 3190411b94aecfa969de866489af09a631173565) --- ingestion/src/metadata/utils/constants.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/ingestion/src/metadata/utils/constants.py b/ingestion/src/metadata/utils/constants.py index e9a20bcce42d..7b3382c6211f 100644 --- a/ingestion/src/metadata/utils/constants.py +++ b/ingestion/src/metadata/utils/constants.py @@ -71,9 +71,6 @@ from metadata.generated.schema.entity.services.connections.database.sasConnection import ( SasType, ) -from metadata.generated.schema.entity.services.connections.database.unityCatalogConnection import ( - DatabricksType, -) from metadata.generated.schema.entity.services.dashboardService import DashboardService from metadata.generated.schema.entity.services.databaseService import DatabaseService from metadata.generated.schema.entity.services.messagingService import MessagingService @@ -165,7 +162,6 @@ DatalakeType.Datalake.value, BigtableType.BigTable.value, CouchbaseType.Couchbase.value, - DatabricksType.UnityCatalog.value, DeltaLakeType.DeltaLake.value, DomoDatabaseType.DomoDatabase.value, DorisType.Doris.value, From 7aa1aa05f523a90573309ddbc67e1894802fc4ed Mon Sep 17 00:00:00 2001 From: Teddy Date: Tue, 7 Jan 2025 08:06:57 +0100 Subject: [PATCH 044/294] ISSUE-19220: remove druid and doris from non sqa (#19250) * fix: remove druid and doris from non sqa * style: ran p[ython styling (cherry picked from commit f40369f59b2caa917707af46bdc823d5d8ffeaac) --- ingestion/src/metadata/utils/constants.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/ingestion/src/metadata/utils/constants.py b/ingestion/src/metadata/utils/constants.py index 7b3382c6211f..02803d955470 100644 --- a/ingestion/src/metadata/utils/constants.py +++ b/ingestion/src/metadata/utils/constants.py @@ -44,12 +44,6 @@ from metadata.generated.schema.entity.services.connections.database.domoDatabaseConnection import ( DomoDatabaseType, ) -from metadata.generated.schema.entity.services.connections.database.dorisConnection import ( - DorisType, -) -from metadata.generated.schema.entity.services.connections.database.druidConnection import ( - DruidType, -) from metadata.generated.schema.entity.services.connections.database.dynamoDBConnection import ( DynamoDBType, ) @@ -164,8 +158,6 @@ CouchbaseType.Couchbase.value, DeltaLakeType.DeltaLake.value, DomoDatabaseType.DomoDatabase.value, - DorisType.Doris.value, - DruidType.Druid.value, DynamoDBType.DynamoDB.value, GlueType.Glue.value, IcebergType.Iceberg.value, From 87de0df9d8974f517c9778e116b35a4e7f2311d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Mu=C3=B1oz=20Vera?= Date: Tue, 7 Jan 2025 11:25:39 +0100 Subject: [PATCH 045/294] chore: adding write permissions to issues (#19262) (cherry picked from commit 34f34ee90abaeaab1143023ccac073b7afa36a65) --- .github/workflows/auto-cherry-pick-labeled-prs.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/auto-cherry-pick-labeled-prs.yaml b/.github/workflows/auto-cherry-pick-labeled-prs.yaml index d3c8529fea42..0519d75fd39e 100644 --- a/.github/workflows/auto-cherry-pick-labeled-prs.yaml +++ b/.github/workflows/auto-cherry-pick-labeled-prs.yaml @@ -12,6 +12,7 @@ on: permissions: contents: write pull-requests: write + issues: write env: CURRENT_RELEASE_ENDPOINT: ${{ vars.CURRENT_RELEASE_ENDPOINT }} # Endpoint that returns the current release version in json format jobs: From c37944a0880fa95adcaf3e36f6800274180930f3 Mon Sep 17 00:00:00 2001 From: Pere Miquel Brull Date: Tue, 7 Jan 2025 07:02:36 +0100 Subject: [PATCH 046/294] FIX #19232 - Correct array type mapping in ORM converter (#19241) --- .../ingestion/source/database/sql_column_handler.py | 6 ++++++ ingestion/src/metadata/profiler/orm/converter/common.py | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/ingestion/src/metadata/ingestion/source/database/sql_column_handler.py b/ingestion/src/metadata/ingestion/source/database/sql_column_handler.py index 0f1ef012f9a3..6fc98ff1859f 100644 --- a/ingestion/src/metadata/ingestion/source/database/sql_column_handler.py +++ b/ingestion/src/metadata/ingestion/source/database/sql_column_handler.py @@ -94,6 +94,12 @@ def _process_col_type(self, column: dict, schema: str) -> Tuple: parsed_string["name"] = column["name"] else: col_type = ColumnTypeParser.get_column_type(column["type"]) + # For arrays, we'll get the item type if possible, or parse the string representation of the column + # if SQLAlchemy does not provide any further information + if col_type == "ARRAY" and getattr(column["type"], "item_type"): + arr_data_type = ColumnTypeParser.get_column_type( + column["type"].item_type + ) if col_type == "ARRAY" and re.match( r"(?:\w*)(?:\()(\w*)(?:.*)", str(column["type"]) ): diff --git a/ingestion/src/metadata/profiler/orm/converter/common.py b/ingestion/src/metadata/profiler/orm/converter/common.py index 2d1e41e13dc4..4c2f1d0997a7 100644 --- a/ingestion/src/metadata/profiler/orm/converter/common.py +++ b/ingestion/src/metadata/profiler/orm/converter/common.py @@ -76,7 +76,9 @@ def map_types(self, col: Column, table_service_type): """returns an ORM type""" if col.arrayDataType: - return self._TYPE_MAP.get(col.dataType)(item_type=col.arrayDataType) + return self._TYPE_MAP.get(col.dataType)( + item_type=self._TYPE_MAP.get(col.arrayDataType) + ) return self.return_custom_type(col, table_service_type) def return_custom_type(self, col: Column, _): From cf3b0e25d4c22d643d4aa9d5bf35449faf185012 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Mu=C3=B1oz=20Vera?= Date: Tue, 7 Jan 2025 11:54:31 +0100 Subject: [PATCH 047/294] chore: updating auto cherry pick wf (#19264) (cherry picked from commit 7f7cfb1e8c474b5950ecd0d7530ebeb71df36bc4) --- .github/workflows/auto-cherry-pick-labeled-prs.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/auto-cherry-pick-labeled-prs.yaml b/.github/workflows/auto-cherry-pick-labeled-prs.yaml index 0519d75fd39e..30168ca04bf1 100644 --- a/.github/workflows/auto-cherry-pick-labeled-prs.yaml +++ b/.github/workflows/auto-cherry-pick-labeled-prs.yaml @@ -5,7 +5,7 @@ run-name: OpenMetadata release cherry-pick PR #${{ github.event.pull_request.num # yamllint disable-line rule:truthy on: - pull_request: + pull_request_target: types: [closed] branches: - main From 7be37c138a3ff2c19d3ab3154994e8272cab3a08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Bry=C5=84ski?= Date: Mon, 6 Jan 2025 18:01:22 +0100 Subject: [PATCH 048/294] Fixes 19217: Add ability to use confluent-kafka version greater than 2.1.1 (#19218) * Add ability to use confluent-kafka version greater than 2.1.1 * fix: spotless --- ingestion/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ingestion/setup.py b/ingestion/setup.py index b117351d6a0f..42b5c4e3bc90 100644 --- a/ingestion/setup.py +++ b/ingestion/setup.py @@ -76,7 +76,7 @@ }, "kafka": { VERSIONS["avro"], - "confluent_kafka==2.1.1", + "confluent_kafka>=2.1.1", "fastavro>=1.2.0", # Due to https://github.com/grpc/grpc/issues/30843#issuecomment-1303816925 # use >= v1.47.2 https://github.com/grpc/grpc/blob/v1.47.2/tools/distrib/python/grpcio_tools/grpc_version.py#L17 From 0efd080264784f66f284ece132b4e587c62be4c1 Mon Sep 17 00:00:00 2001 From: Pranita Fulsundar Date: Tue, 7 Jan 2025 17:19:06 +0530 Subject: [PATCH 049/294] fix: highlight search text for description column (#19248) Co-authored-by: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> (cherry picked from commit 498d9529597f4be1efd776a958ee62413ab29ab5) --- .../BlockEditor/Extensions/index.ts | 2 + .../Extensions/text-highlight-view.ts | 77 +++++++++++++++++++ .../resources/ui/src/utils/EntityUtils.tsx | 5 +- .../ui/src/utils/mocks/EntityUtils.mock.ts | 2 +- 4 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/text-highlight-view.ts diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/index.ts b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/index.ts index 74c796f93d62..008f1565a9a2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/index.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/index.ts @@ -34,6 +34,7 @@ import { mentionSuggestion } from './mention/mentionSuggestions'; import slashCommand from './slash-command'; import { getSuggestionItems } from './slash-command/items'; import renderItems from './slash-command/renderItems'; +import TextHighlightView from './text-highlight-view'; import { TrailingNode } from './trailing-node'; export const extensions = [ @@ -112,6 +113,7 @@ export const extensions = [ suggestion: hashtagSuggestion(), }), DiffView, + TextHighlightView, Image.configure({ allowBase64: true, inline: true, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/text-highlight-view.ts b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/text-highlight-view.ts new file mode 100644 index 000000000000..e66503922771 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/text-highlight-view.ts @@ -0,0 +1,77 @@ +/* + * Copyright 2025 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 { Node } from '@tiptap/core'; + +export default Node.create({ + name: 'textHighLightView', + content: 'inline*', + group: 'inline', + inline: true, + + addAttributes() { + return { + class: { + default: '', + }, + 'data-testid': { + default: '', + parseHTML: (element) => element.getAttribute('data-testid'), + renderHTML: (attributes) => { + if (!attributes['data-testid']) { + return {}; + } + + return { + 'data-testid': attributes['data-testid'], + }; + }, + }, + 'data-highlight': { + default: true, + parseHTML: (element) => element.getAttribute('data-highlight'), + renderHTML: (attributes) => { + if (!attributes['data-highlight']) { + return {}; + } + + return { + 'data-highlight': attributes['data-highlight'], + }; + }, + }, + }; + }, + + parseHTML() { + return [ + { + tag: 'span[data-highlight]', + }, + ]; + }, + + renderHTML({ HTMLAttributes, node }) { + const textHighlightNode = document.createElement('span'); + + Object.keys(HTMLAttributes).forEach((key) => { + textHighlightNode.setAttribute(key, HTMLAttributes[key]); + }); + + textHighlightNode.setAttribute('data-highlight', 'true'); + textHighlightNode.innerHTML = node.textContent; + + return { + dom: textHighlightNode, + }; + }, +}); 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 1a613534a46a..abc539c226a4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/EntityUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/EntityUtils.tsx @@ -2551,7 +2551,10 @@ export const highlightSearchText = ( const regex = new RegExp(`(${searchText})`, 'gi'); - return text.replace(regex, `$1`); + return text.replace( + regex, + `$1` + ); }; /** diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/mocks/EntityUtils.mock.ts b/openmetadata-ui/src/main/resources/ui/src/utils/mocks/EntityUtils.mock.ts index 7d6a4a520e7d..842e91f513d7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/mocks/EntityUtils.mock.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/mocks/EntityUtils.mock.ts @@ -143,4 +143,4 @@ export const mockText = export const mockSearchText = 'test'; export const mockHighlightedResult = - 'This is a test description to verify highlightText method.'; + 'This is a test description to verify highlightText method.'; From 2109501c87be146b371ce8b7b8aa5dfa355dd177 Mon Sep 17 00:00:00 2001 From: Ashish Gupta Date: Tue, 7 Jan 2025 17:36:02 +0530 Subject: [PATCH 050/294] #13913: supported test connection api cancellation (#19190) * supported test connection api cancellation * minor improvement * fix the error handling in the intervalAPI calls * added the useAbortController hook and minor changes * supported delete workflow if anu api is cancelled * fix sonar issue and updated the modal button * fix sonar and playwright issue * minor fix around test * reverted the modal textual changes as per comments (cherry picked from commit a6f81a90fb6061c2d9a927a295c706ea929d9ef0) --- .../TestConnection/TestConnection.test.tsx | 35 +++- .../common/TestConnection/TestConnection.tsx | 154 +++++++++++------- .../TestConnectionModal.tsx | 2 +- .../useAbortController.test.tsx | 77 +++++++++ .../AbortController/useAbortController.ts | 35 ++++ .../main/resources/ui/src/rest/workflowAPI.ts | 33 +++- .../main/resources/ui/src/utils/ToastUtils.ts | 11 +- 7 files changed, 274 insertions(+), 73 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/hooks/AbortController/useAbortController.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/hooks/AbortController/useAbortController.ts diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/TestConnection/TestConnection.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/TestConnection/TestConnection.test.tsx index 127b73437583..2baaf3abef2d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/TestConnection/TestConnection.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/TestConnection/TestConnection.test.tsx @@ -173,6 +173,7 @@ describe('Test Connection Component', () => { await act(async () => { render(); }); + const controller = new AbortController(); const testConnectionButton = screen.getByTestId('test-connection-btn'); @@ -180,13 +181,22 @@ describe('Test Connection Component', () => { userEvent.click(testConnectionButton); }); - expect(addWorkflow).toHaveBeenCalledWith(CREATE_WORKFLOW_PAYLOAD); + expect(addWorkflow).toHaveBeenCalledWith( + CREATE_WORKFLOW_PAYLOAD, + controller.signal + ); - expect(triggerWorkflowById).toHaveBeenCalledWith(WORKFLOW_DETAILS.id); + expect(triggerWorkflowById).toHaveBeenCalledWith( + WORKFLOW_DETAILS.id, + controller.signal + ); jest.advanceTimersByTime(2000); - expect(getWorkflowById).toHaveBeenCalledWith(WORKFLOW_DETAILS.id); + expect(getWorkflowById).toHaveBeenCalledWith( + WORKFLOW_DETAILS.id, + controller.signal + ); }); it('Should show success message if test connection successful', async () => { @@ -293,6 +303,13 @@ describe('Test Connection Component', () => { it('Should timeout message after two minutes', async () => { jest.useFakeTimers(); + (addWorkflow as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ + ...WORKFLOW_DETAILS, + status: WorkflowStatus.Pending, + }) + ); + (getWorkflowById as jest.Mock).mockImplementationOnce(() => Promise.resolve({ ...WORKFLOW_DETAILS, @@ -485,15 +502,23 @@ describe('Test Connection Component', () => { render(); }); + const controller = new AbortController(); + const testConnectionButton = screen.getByTestId('test-connection-btn'); await act(async () => { userEvent.click(testConnectionButton); }); - expect(addWorkflow).toHaveBeenCalledWith(CREATE_WORKFLOW_PAYLOAD); + expect(addWorkflow).toHaveBeenCalledWith( + CREATE_WORKFLOW_PAYLOAD, + controller.signal + ); - expect(triggerWorkflowById).toHaveBeenCalledWith(WORKFLOW_DETAILS.id); + expect(triggerWorkflowById).toHaveBeenCalledWith( + WORKFLOW_DETAILS.id, + controller.signal + ); jest.advanceTimersByTime(2000); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/TestConnection/TestConnection.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/TestConnection/TestConnection.tsx index 733f7eca853f..cab55452ab7c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/TestConnection/TestConnection.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/TestConnection/TestConnection.tsx @@ -42,6 +42,7 @@ import { WorkflowType, } from '../../../generated/entity/automations/workflow'; import { TestConnectionStep } from '../../../generated/entity/services/connections/testConnectionDefinition'; +import useAbortController from '../../../hooks/AbortController/useAbortController'; import { useAirflowStatus } from '../../../hooks/useAirflowStatus'; import { addWorkflow, @@ -108,6 +109,8 @@ const TestConnection: FC = ({ */ const currentWorkflowRef = useRef(currentWorkflow); + const { controller } = useAbortController(); + const serviceType = useMemo(() => { return getServiceType(serviceCategory); }, [serviceCategory]); @@ -138,9 +141,12 @@ const TestConnection: FC = ({ } }; - const getWorkflowData = async (workflowId: string) => { + const getWorkflowData = async ( + workflowId: string, + apiCancelSignal: AbortSignal + ) => { try { - const response = await getWorkflowById(workflowId); + const response = await getWorkflowById(workflowId, apiCancelSignal); const testConnectionStepResult = response.response?.steps ?? []; setTestConnectionStepResult(testConnectionStepResult); @@ -169,11 +175,87 @@ const TestConnection: FC = ({ try { await deleteWorkflowById(workflowId, true); + setCurrentWorkflow(undefined); } catch (error) { // do not throw error for this API } }; + const handleCompletionStatus = async ( + isTestConnectionSuccess: boolean, + steps: TestConnectionStepResult[] + ) => { + setProgress(TEST_CONNECTION_PROGRESS_PERCENTAGE.HUNDRED); + if (isTestConnectionSuccess) { + setTestStatus(StatusType.Successful); + setMessage(TEST_CONNECTION_SUCCESS_MESSAGE); + } else { + const isMandatoryStepsFailing = steps.some( + (step) => step.mandatory && !step.passed + ); + setTestStatus(isMandatoryStepsFailing ? StatusType.Failed : 'Warning'); + setMessage( + isMandatoryStepsFailing + ? TEST_CONNECTION_FAILURE_MESSAGE + : TEST_CONNECTION_WARNING_MESSAGE + ); + } + }; + + const handleWorkflowPolling = async ( + response: Workflow, + intervalId: number | undefined + ) => { + // return a promise that wraps the interval and handles errors inside it + return new Promise((resolve, reject) => { + /** + * fetch workflow repeatedly with 2s interval + * until status is either Failed or Successful + */ + intervalId = toNumber( + setInterval(async () => { + setProgress((prev) => prev + TEST_CONNECTION_PROGRESS_PERCENTAGE.ONE); + try { + const workflowResponse = await getWorkflowData( + response.id, + controller.signal + ); + const { response: testConnectionResponse } = workflowResponse; + const { status: testConnectionStatus, steps = [] } = + testConnectionResponse || {}; + + const isWorkflowCompleted = WORKFLOW_COMPLETE_STATUS.includes( + workflowResponse.status as WorkflowStatus + ); + + const isTestConnectionSuccess = + testConnectionStatus === StatusType.Successful; + + if (!isWorkflowCompleted) { + return; + } + + // Handle completion status + await handleCompletionStatus(isTestConnectionSuccess, steps); + + // clear the current interval + clearInterval(intervalId); + + // set testing connection to false + setIsTestingConnection(false); + + // delete the workflow once it's finished + await handleDeleteWorkflow(workflowResponse.id); + + resolve(); + } catch (error) { + reject(error as AxiosError); + } + }, FETCH_INTERVAL) + ); + }); + }; + // handlers const testConnection = async () => { setIsTestingConnection(true); @@ -203,12 +285,14 @@ const TestConnection: FC = ({ setProgress(TEST_CONNECTION_PROGRESS_PERCENTAGE.TEN); // create the workflow - const response = await addWorkflow(createWorkflowData); + const response = await addWorkflow(createWorkflowData, controller.signal); + + setCurrentWorkflow(response); setProgress(TEST_CONNECTION_PROGRESS_PERCENTAGE.TWENTY); // trigger the workflow - const status = await triggerWorkflowById(response.id); + const status = await triggerWorkflowById(response.id, controller.signal); setProgress(TEST_CONNECTION_PROGRESS_PERCENTAGE.FORTY); @@ -223,58 +307,6 @@ const TestConnection: FC = ({ return; } - /** - * fetch workflow repeatedly with 2s interval - * until status is either Failed or Successful - */ - intervalId = toNumber( - setInterval(async () => { - setProgress((prev) => prev + TEST_CONNECTION_PROGRESS_PERCENTAGE.ONE); - const workflowResponse = await getWorkflowData(response.id); - const { response: testConnectionResponse } = workflowResponse; - const { status: testConnectionStatus, steps = [] } = - testConnectionResponse || {}; - - const isWorkflowCompleted = WORKFLOW_COMPLETE_STATUS.includes( - workflowResponse.status as WorkflowStatus - ); - - const isTestConnectionSuccess = - testConnectionStatus === StatusType.Successful; - - if (!isWorkflowCompleted) { - return; - } - - setProgress(TEST_CONNECTION_PROGRESS_PERCENTAGE.HUNDRED); - if (isTestConnectionSuccess) { - setTestStatus(StatusType.Successful); - setMessage(TEST_CONNECTION_SUCCESS_MESSAGE); - } else { - const isMandatoryStepsFailing = steps.some( - (step) => step.mandatory && !step.passed - ); - setTestStatus( - isMandatoryStepsFailing ? StatusType.Failed : 'Warning' - ); - setMessage( - isMandatoryStepsFailing - ? TEST_CONNECTION_FAILURE_MESSAGE - : TEST_CONNECTION_WARNING_MESSAGE - ); - } - - // clear the current interval - clearInterval(intervalId); - - // set testing connection to false - setIsTestingConnection(false); - - // delete the workflow once it's finished - await handleDeleteWorkflow(workflowResponse.id); - }, FETCH_INTERVAL) - ); - // stop fetching the workflow after 2 minutes setTimeout(() => { // clear the current interval @@ -296,6 +328,9 @@ const TestConnection: FC = ({ setIsTestingConnection(false); setProgress(TEST_CONNECTION_PROGRESS_PERCENTAGE.HUNDRED); }, FETCHING_EXPIRY_TIME); + + // Handle workflow polling and completion + await handleWorkflowPolling(response, intervalId); } catch (error) { setProgress(TEST_CONNECTION_PROGRESS_PERCENTAGE.HUNDRED); clearInterval(intervalId); @@ -324,6 +359,11 @@ const TestConnection: FC = ({ } }; + const handleCancelTestConnectionModal = () => { + controller.abort(); + setDialogOpen(false); + }; + useEffect(() => { currentWorkflowRef.current = currentWorkflow; // update ref with latest value of currentWorkflow state variable }, [currentWorkflow]); @@ -435,7 +475,7 @@ const TestConnection: FC = ({ progress={progress} testConnectionStep={testConnectionStep} testConnectionStepResult={testConnectionStepResult} - onCancel={() => setDialogOpen(false)} + onCancel={handleCancelTestConnectionModal} onConfirm={() => setDialogOpen(false)} onTestConnection={handleTestConnection} /> diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/TestConnection/TestConnectionModal/TestConnectionModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/TestConnection/TestConnectionModal/TestConnectionModal.tsx index bade4e45467b..c0c5c126a848 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/TestConnection/TestConnectionModal/TestConnectionModal.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/TestConnection/TestConnectionModal/TestConnectionModal.tsx @@ -44,8 +44,8 @@ const TestConnectionModal: FC = ({ isTestingConnection, testConnectionStep, testConnectionStepResult, - onCancel, onConfirm, + onCancel, isConnectionTimeout, onTestConnection, }) => { diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/AbortController/useAbortController.test.tsx b/openmetadata-ui/src/main/resources/ui/src/hooks/AbortController/useAbortController.test.tsx new file mode 100644 index 000000000000..1b91a8998201 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/AbortController/useAbortController.test.tsx @@ -0,0 +1,77 @@ +/* + * Copyright 2025 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, renderHook } from '@testing-library/react-hooks'; +import axios from 'axios'; +import useAbortController from './useAbortController'; // Path to the custom hook + +jest.mock('axios'); + +// Mock the global AbortController +beforeAll(() => { + global.AbortController = jest.fn().mockImplementation(() => ({ + signal: {}, + abort: jest.fn(), + })); +}); + +describe('useAbortController', () => { + it('should call abort on cleanup or component unmount', () => { + const abortMock = jest.fn(); + + const { result, unmount } = renderHook(() => useAbortController()); + + // assign the mock to the abort method of the controller + result.current.controller.abort = abortMock; + + act(() => { + unmount(); + }); + + expect(abortMock).toHaveBeenCalled(); + }); + + // Test that the signal is passed correctly to Axios for canceling requests + it('should pass signal to Axios request for cancellation', async () => { + const { result, unmount } = renderHook(() => useAbortController()); + + // mock axios and track calls + axios.get = jest.fn().mockResolvedValue({ data: 'test' }); + + act(() => { + const fetchData = async () => { + try { + await axios.get('/api/test', { + signal: result.current.controller.signal, + }); + } catch (error) { + // Request aborted + } + }; + + fetchData(); + }); + + // Simulate the component unmounting to trigger abortion + act(() => { + unmount(); + }); + + // Check that Axios's cancel logic was triggered + expect(axios.get).toHaveBeenCalledWith( + '/api/test', + expect.objectContaining({ + signal: new AbortController().signal, + }) + ); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/AbortController/useAbortController.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/AbortController/useAbortController.ts new file mode 100644 index 000000000000..5b37263d492c --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/AbortController/useAbortController.ts @@ -0,0 +1,35 @@ +/* + * 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 { useEffect, useRef } from 'react'; + +const useAbortController = () => { + const controllerRef = useRef(new AbortController()); + + useEffect(() => { + if (controllerRef.current.signal.aborted) { + controllerRef.current = new AbortController(); + } + }, [controllerRef.current.signal.aborted]); + + useEffect(() => { + return () => { + controllerRef.current.abort(); + }; + }, []); + + return { + controller: controllerRef.current, + }; +}; + +export default useAbortController; diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/workflowAPI.ts b/openmetadata-ui/src/main/resources/ui/src/rest/workflowAPI.ts index 1b72d127900c..8f46d62d3806 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/workflowAPI.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/workflowAPI.ts @@ -29,11 +29,16 @@ export const getTestConnectionDefinitionByName = async ( return response.data; }; -export const addWorkflow = async (data: CreateWorkflow) => { +export const addWorkflow = async ( + data: CreateWorkflow, + apiCancelSignal: AbortSignal +) => { const response = await APIClient.post< CreateWorkflow, AxiosResponse - >(`automations/workflows`, data); + >(`automations/workflows`, data, { + signal: apiCancelSignal, + }); return response.data; }; @@ -43,17 +48,33 @@ export const addWorkflow = async (data: CreateWorkflow) => { * @param workflowId workflow to run * @returns status code like 200, 400, etc. */ -export const triggerWorkflowById = async (workflowId: string) => { +export const triggerWorkflowById = async ( + workflowId: string, + apiCancelSignal: AbortSignal +) => { const response = await APIClient.post( - `automations/workflows/trigger/${workflowId}` + `automations/workflows/trigger/${workflowId}`, + null, + { + signal: apiCancelSignal, + headers: { + 'Content-Type': 'application/json', + }, + } ); return response.status; }; -export const getWorkflowById = async (workflowId: string) => { +export const getWorkflowById = async ( + workflowId: string, + apiCancelSignal: AbortSignal +) => { const response = await APIClient.get( - `automations/workflows/${workflowId}` + `automations/workflows/${workflowId}`, + { + signal: apiCancelSignal, + } ); return response.data; 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..a48bda23e206 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/ToastUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ToastUtils.ts @@ -11,7 +11,7 @@ * limitations under the License. */ -import { AxiosError } from 'axios'; +import { AxiosError, isCancel } from 'axios'; import { isEmpty, isString } from 'lodash'; import React from 'react'; import { toast } from 'react-toastify'; @@ -47,11 +47,14 @@ export const showErrorToast = ( autoCloseTimer?: number, callback?: (value: React.SetStateAction) => void ) => { + if (isCancel(error)) { + return; + } let errorMessage; if (isString(error)) { errorMessage = error.toString(); } else { - const method = error.config?.method?.toUpperCase(); + const method = (error as AxiosError).config?.method?.toUpperCase(); const fallback = fallbackText && fallbackText.length > 0 ? fallbackText @@ -62,8 +65,8 @@ export const showErrorToast = ( // except for principal domain mismatch errors if ( error && - (error.response?.status === ClientErrors.UNAUTHORIZED || - (error.response?.status === ClientErrors.FORBIDDEN && + ((error as AxiosError).response?.status === ClientErrors.UNAUTHORIZED || + ((error as AxiosError).response?.status === ClientErrors.FORBIDDEN && method === 'GET')) && !errorMessage.includes('principal domain') ) { From 08da6d09a18f290fb9209161df9af4f256ffdf00 Mon Sep 17 00:00:00 2001 From: IceS2 Date: Fri, 3 Jan 2025 18:32:18 +0100 Subject: [PATCH 051/294] MINOR: Remove Delete Events from Workflow Consumer (#19219) * Remove Delete Events from Workflow Consumer * Remove Delete Events from Workflow Consumer --- .../service/governance/workflows/WorkflowEventConsumer.java | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/WorkflowEventConsumer.java b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/WorkflowEventConsumer.java index 7cbc974a4d25..0167230c8330 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/WorkflowEventConsumer.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/WorkflowEventConsumer.java @@ -27,11 +27,7 @@ public class WorkflowEventConsumer implements Destination { // TODO: Understand if we need to consider ENTITY_NO_CHANGE, ENTITY_FIELDS_CHANGED or // ENTITY_RESTORED. private static List validEventTypes = - List.of( - EventType.ENTITY_CREATED, - EventType.ENTITY_UPDATED, - EventType.ENTITY_SOFT_DELETED, - EventType.ENTITY_DELETED); + List.of(EventType.ENTITY_CREATED, EventType.ENTITY_UPDATED); public WorkflowEventConsumer( EventSubscription eventSubscription, SubscriptionDestination subscriptionDestination) { From e5a193d40d4033056c09bc41c1c6082f39ad7809 Mon Sep 17 00:00:00 2001 From: IceS2 Date: Tue, 7 Jan 2025 07:55:41 +0100 Subject: [PATCH 052/294] MINOR: Add Governance Bot to the workflow automated tasks (#19222) * Add Governance Bot to the workflow automated tasks * Fix tests --- .../impl/SetEntityCertificationImpl.java | 2 +- .../impl/SetGlossaryTermStatusImpl.java | 2 +- .../service/jdbi3/FeedRepository.java | 3 +- .../glossary/GlossaryTermResourceTest.java | 32 +++++++++++++------ .../openmetadata/service/util/TestUtils.java | 3 ++ 5 files changed, 29 insertions(+), 13 deletions(-) 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 1de5a955412c..122714e4d99a 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 @@ -40,7 +40,7 @@ public void execute(DelegateExecution execution) { .orElse(null); String user = Optional.ofNullable((String) execution.getVariable(RESOLVED_BY_VARIABLE)) - .orElse(entity.getUpdatedBy()); + .orElse("governance-bot"); setStatus(entity, entityType, user, certification); } catch (Exception exc) { 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 6d2d4060cfd4..cfe38e652771 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 @@ -35,7 +35,7 @@ public void execute(DelegateExecution execution) { String status = (String) statusExpr.getValue(execution); String user = Optional.ofNullable((String) execution.getVariable(RESOLVED_BY_VARIABLE)) - .orElse(glossaryTerm.getUpdatedBy()); + .orElse("governance-bot"); setStatus(glossaryTerm, user, status); } catch (Exception exc) { 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 17103ba77a0c..bd95cdfd7c9b 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 @@ -961,7 +961,8 @@ private void validateAssignee(Thread thread) { String createdByUserName = thread.getCreatedBy(); User createdByUser = Entity.getEntityByName(USER, createdByUserName, TEAMS_FIELD, NON_DELETED); - if (Boolean.TRUE.equals(createdByUser.getIsBot())) { + if (Boolean.TRUE.equals(createdByUser.getIsBot()) + && !createdByUser.getName().equals("governance-bot")) { throw new IllegalArgumentException("Task cannot be created by bot only by user or teams"); } diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/glossary/GlossaryTermResourceTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/glossary/GlossaryTermResourceTest.java index c2e15538a3aa..7b0e5f801514 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/resources/glossary/GlossaryTermResourceTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/glossary/GlossaryTermResourceTest.java @@ -68,6 +68,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInfo; import org.junit.jupiter.api.TestMethodOrder; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; import org.openmetadata.schema.api.data.CreateGlossary; import org.openmetadata.schema.api.data.CreateGlossaryTerm; import org.openmetadata.schema.api.data.CreateTable; @@ -88,6 +90,7 @@ import org.openmetadata.schema.type.TaskDetails; import org.openmetadata.schema.type.TaskStatus; import org.openmetadata.service.Entity; +import org.openmetadata.service.governance.workflows.WorkflowHandler; import org.openmetadata.service.resources.EntityResourceTest; import org.openmetadata.service.resources.databases.TableResourceTest; import org.openmetadata.service.resources.feeds.FeedResource.ThreadList; @@ -264,7 +267,11 @@ void test_commonPrefixTagLabelCount(TestInfo test) throws IOException { } @Test + @Execution(ExecutionMode.SAME_THREAD) void patch_addDeleteReviewers(TestInfo test) throws IOException { + // Note: We are disabling the GlossaryTermApprovalWorkflow to avoid the Workflow Kicking it and + // adding extra ChangeDescriptions. + WorkflowHandler.getInstance().suspendWorkflow("GlossaryTermApprovalWorkflow"); CreateGlossaryTerm create = createRequest(getEntityName(test), "desc", "", null).withReviewers(null).withSynonyms(null); GlossaryTerm term = createEntity(create, ADMIN_AUTH_HEADERS); @@ -281,14 +288,11 @@ void patch_addDeleteReviewers(TestInfo test) throws IOException { term.withReviewers(List.of(USER1_REF)) .withSynonyms(List.of("synonym1")) .withReferences(List.of(reference1)); - patchEntity(term.getId(), origJson, term, ADMIN_AUTH_HEADERS); - waitForTaskToBeCreated(term.getFullyQualifiedName()); ChangeDescription change = getChangeDescription(term, MINOR_UPDATE); fieldAdded(change, "reviewers", List.of(USER1_REF)); fieldAdded(change, "synonyms", List.of("synonym1")); fieldAdded(change, "references", List.of(reference1)); - fieldUpdated(change, "status", Status.APPROVED, Status.IN_REVIEW); term = patchEntityAndCheck(term, origJson, ADMIN_AUTH_HEADERS, MINOR_UPDATE, change); // Add reviewer USER2, synonym2, reference2 in PATCH request @@ -303,7 +307,6 @@ void patch_addDeleteReviewers(TestInfo test) throws IOException { fieldAdded(change, "reviewers", List.of(USER1_REF, USER2_REF)); fieldAdded(change, "synonyms", List.of("synonym1", "synonym2")); fieldAdded(change, "references", List.of(reference1, reference2)); - fieldUpdated(change, "status", Status.APPROVED, Status.IN_REVIEW); term = patchEntityAndCheck(term, origJson, ADMIN_AUTH_HEADERS, CHANGE_CONSOLIDATED, change); // Remove a reviewer USER1, synonym1, reference1 in PATCH request @@ -316,8 +319,10 @@ void patch_addDeleteReviewers(TestInfo test) throws IOException { fieldAdded(change, "reviewers", List.of(USER2_REF)); fieldAdded(change, "synonyms", List.of("synonym2")); fieldAdded(change, "references", List.of(reference2)); - fieldUpdated(change, "status", Status.APPROVED, Status.IN_REVIEW); patchEntityAndCheck(term, origJson, ADMIN_AUTH_HEADERS, CHANGE_CONSOLIDATED, change); + + // Note: We are re-enabling the GlossaryTermApprovalWorkflow. + WorkflowHandler.getInstance().resumeWorkflow("GlossaryTermApprovalWorkflow"); } @Test @@ -485,12 +490,19 @@ taskId2, new ResolveTask().withNewValue("rejected"), ADMIN_AUTH_HEADERS), String origJson = JsonUtils.pojoToJson(g2t5); // Add reviewer DATA_CONSUMER in PATCH request - g2t5.withReviewers(List.of(DATA_CONSUMER_REF, USER1_REF, USER2_REF)); + List newReviewers = List.of(DATA_CONSUMER_REF, USER1_REF, USER2_REF); + g2t5.withReviewers(newReviewers); + + double previousVersion = g2t5.getVersion(); + g2t5 = patchEntityUsingFqn(g2t5.getFullyQualifiedName(), origJson, g2t5, ADMIN_AUTH_HEADERS); - ChangeDescription change = getChangeDescription(g2t5, MINOR_UPDATE); - fieldAdded(change, "reviewers", List.of(DATA_CONSUMER_REF)); - fieldUpdated(change, "status", Status.DRAFT.value(), Status.IN_REVIEW.value()); - g2t5 = patchEntityUsingFqnAndCheck(g2t5, origJson, ADMIN_AUTH_HEADERS, MINOR_UPDATE, change); + // Due to the Glossary Workflow changing the Status from 'DRAFT' to 'IN_REVIEW' as a + // GovernanceBot, two changes are created. + assertEquals(g2t5.getVersion(), previousVersion + 0.2, 0.0001); + assertTrue( + g2t5.getReviewers().containsAll(newReviewers) + && newReviewers.containsAll(g2t5.getReviewers())); + assertEquals(g2t5.getStatus(), Status.IN_REVIEW); Thread approvalTask1 = assertApprovalTask(g2t5, TaskStatus.Open); // A Request Approval task is opened diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/util/TestUtils.java b/openmetadata-service/src/test/java/org/openmetadata/service/util/TestUtils.java index e39f2d05a6e5..534315afa9b0 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/util/TestUtils.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/util/TestUtils.java @@ -105,6 +105,9 @@ public final class TestUtils { public static String LONG_ENTITY_NAME = "a".repeat(256 + 1); public static final Map ADMIN_AUTH_HEADERS = authHeaders(ADMIN_USER_NAME + "@open-metadata.org"); + public static final String GOVERNANCE_BOT = "governance-bot"; + public static final Map GOVERNANCE_BOT_AUTH_HEADERS = + authHeaders(GOVERNANCE_BOT + "@open-metadata.org"); public static final String INGESTION_BOT = "ingestion-bot"; public static final Map INGESTION_BOT_AUTH_HEADERS = authHeaders(INGESTION_BOT + "@open-metadata.org"); From 50ee1e906aa88ef33d26ec3207de292095524fae Mon Sep 17 00:00:00 2001 From: Abdallah Serghine <76706155+KylixSerg@users.noreply.github.com> Date: Fri, 3 Jan 2025 10:51:15 +0100 Subject: [PATCH 053/294] Fixes ISSUE-19095: auto refresh boto credentials (#19098) When using using the assumeRole connection option with jobs involving AWS services, the default 1 hour of boto might not cut it and the job fails, one way of solving this is to refresh the credentials when they expire, this ensures there is always valid credentials for the job regardless for how long it runs. Co-authored-by: Abdallah Serghine Co-authored-by: IceS2 --- ingestion/src/metadata/clients/aws_client.py | 63 +++++++++++++++----- 1 file changed, 47 insertions(+), 16 deletions(-) diff --git a/ingestion/src/metadata/clients/aws_client.py b/ingestion/src/metadata/clients/aws_client.py index 8e87c600f96c..ca40ff1bb679 100644 --- a/ingestion/src/metadata/clients/aws_client.py +++ b/ingestion/src/metadata/clients/aws_client.py @@ -12,11 +12,14 @@ Module containing AWS Client """ from enum import Enum -from typing import Any, Optional +from functools import partial +from typing import Any, Callable, Dict, Optional, Type, TypeVar import boto3 from boto3 import Session -from pydantic import BaseModel +from botocore.credentials import RefreshableCredentials +from botocore.session import get_session +from pydantic import BaseModel, Field from metadata.generated.schema.security.credentials.awsCredentials import AWSCredentials from metadata.ingestion.models.custom_pydantic import CustomSecretStr @@ -45,9 +48,15 @@ class AWSAssumeRoleException(Exception): class AWSAssumeRoleCredentialWrapper(BaseModel): - accessKeyId: str - secretAccessKey: CustomSecretStr - sessionToken: Optional[str] = None + accessKeyId: str = Field(alias="access_key") + secretAccessKey: CustomSecretStr = Field(alias="secret_key") + sessionToken: Optional[str] = Field(default=None, alias="token") + expiryTime: Optional[str] = Field(alias="expiry_time") + + +AWSAssumeRoleCredentialFormat = TypeVar( + "AWSAssumeRoleCredentialFormat", AWSAssumeRoleCredentialWrapper, Dict +) class AWSClient: @@ -65,7 +74,10 @@ def __init__(self, config: "AWSCredentials"): @staticmethod def get_assume_role_config( config: AWSCredentials, - ) -> Optional[AWSAssumeRoleCredentialWrapper]: + return_type: Type[ + AWSAssumeRoleCredentialFormat + ] = AWSAssumeRoleCredentialWrapper, + ) -> Optional[AWSAssumeRoleCredentialFormat]: """ Get temporary credentials from assumed role """ @@ -91,11 +103,16 @@ def get_assume_role_config( if resp: credentials = resp.get("Credentials", {}) - return AWSAssumeRoleCredentialWrapper( + creds_wrapper = AWSAssumeRoleCredentialWrapper( accessKeyId=credentials.get("AccessKeyId"), secretAccessKey=credentials.get("SecretAccessKey"), sessionToken=credentials.get("SessionToken"), + expiryTime=credentials.get("Expiration").isformat(), ) + if return_type == Dict: + return creds_wrapper.model_dump(by_alias=True) + return creds_wrapper + return None @staticmethod @@ -105,12 +122,25 @@ def _get_session( aws_session_token: Optional[str], aws_region: str, profile=None, + refresh_using: Optional[Callable] = None, ) -> Session: """ The only required param for boto3 is the region. The rest of credentials will have fallback strategies based on https://boto3.amazonaws.com/v1/documentation/api/latest/guide/credentials.html#configuring-credentials """ + if refresh_using: + refreshable_creds = RefreshableCredentials.create_from_metadata( + metadata=refresh_using(), + refresh_using=refresh_using, + method="sts-assume-role", + ) + session = get_session() + session._credentials = refreshable_creds # pylint: disable=protected-access + return Session( + botocore_session=session, region_name=aws_region, profile_name=profile + ) + return Session( aws_access_key_id=aws_access_key_id, aws_secret_access_key=aws_secret_access_key.get_secret_value() @@ -123,15 +153,16 @@ def _get_session( def create_session(self) -> Session: if self.config.assumeRoleArn: - assume_creds = AWSClient.get_assume_role_config(self.config) - if assume_creds: - return AWSClient._get_session( - assume_creds.accessKeyId, - assume_creds.secretAccessKey, - assume_creds.sessionToken, - self.config.awsRegion, - self.config.profileName, - ) + return AWSClient._get_session( + None, + None, + None, + self.config.awsRegion, + self.config.profileName, + refresh_using=partial( + AWSClient.get_assume_role_config, self.config, Dict + ), + ) return AWSClient._get_session( self.config.awsAccessKeyId, From 678160fbccd614b8374496f5db30f1dc06dad7ad Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Tue, 7 Jan 2025 06:00:36 -0800 Subject: [PATCH 054/294] upgrade logback versions (#19259) (cherry picked from commit 3678d1c3d66b2818a417de3122cf27da1028df37) --- openmetadata-service/pom.xml | 58 ++++++++++++++++++++++++++++++++++++ pom.xml | 43 ++++++++++++++++++++++++-- 2 files changed, 98 insertions(+), 3 deletions(-) diff --git a/openmetadata-service/pom.xml b/openmetadata-service/pom.xml index 5299851b9021..0554f03811fc 100644 --- a/openmetadata-service/pom.xml +++ b/openmetadata-service/pom.xml @@ -147,6 +147,24 @@ ru.vyarus.guicey guicey-jdbi3 5.9.2 + + + ch.qos.logback + logback-core + + + ch.qos.logback + logback-classic + + + ch.qos.logback + logback-access + + + org.slf4j + log4j-over-slf4j + + com.smoketurner @@ -182,10 +200,46 @@ io.github.maksymdolgykh.dropwizard dropwizard-micrometer-core + + + ch.qos.logback + logback-core + + + ch.qos.logback + logback-classic + + + ch.qos.logback + logback-access + + + org.slf4j + log4j-over-slf4j + + io.github.maksymdolgykh.dropwizard dropwizard-micrometer-jdbi + + + ch.qos.logback + logback-core + + + ch.qos.logback + logback-classic + + + ch.qos.logback + logback-access + + + org.slf4j + log4j-over-slf4j + + io.dropwizard @@ -264,6 +318,10 @@ ch.qos.logback logback-classic + + ch.qos.logback + logback-access + com.jayway.jsonpath json-path diff --git a/pom.xml b/pom.xml index cf005a62d8be..725acae72502 100644 --- a/pom.xml +++ b/pom.xml @@ -76,7 +76,7 @@ UTF-8 5.14.2 - 1.7.36 + 2.0.4 2.17.2 2.1.11 2.1.6 @@ -152,8 +152,8 @@ 1.29.2 2.41.1 4.7.6 - 1.2.13 - 1.2.13 + 1.3.15 + 1.3.15 2.9.0 1.14.4 1.13 @@ -262,6 +262,11 @@ logback-classic ${logback-classic.version} + + ch.qos.logback + logback-access + ${logback-classic.version} + io.dropwizard dropwizard-json-logging @@ -384,11 +389,43 @@ io.github.maksymdolgykh.dropwizard dropwizard-micrometer-core ${dropwizard-micrometer.version} + + + ch.qos.logback + logback-core + + + ch.qos.logback + logback-classic + + + org.slf4j + log4j-over-slf4j + + io.github.maksymdolgykh.dropwizard dropwizard-micrometer-jdbi ${dropwizard-micrometer.version} + + + ch.qos.logback + logback-core + + + ch.qos.logback + logback-classic + + + ch.qos.logback + logback-access + + + org.slf4j + log4j-over-slf4j + + From 6b230f3440bf447474e35df49bbd4efdde01c031 Mon Sep 17 00:00:00 2001 From: Aniket Katkar Date: Tue, 7 Jan 2025 08:57:15 +0530 Subject: [PATCH 055/294] Improvements: Automator requirements oss (#19128) * Fix the ingestion success screen text overflow issue * Fix the icon alignment issue in the in the entity header * Fix the permissions API calls for the ingestion pipelines to use fqn instead of names * Introduce the moreActionButtonProps in the PipelineActions component * Add the "Database" field in the advanced search for the "Database Schema" asset. Change the fields listing behavior in the advanced search modal, to show the common fields for the multiple assets instead of union of the fields. Change the search index for the "Name" and "Display Name" field suggestions to use the selected data assets instead of the all "Data Assets" index. * Remove the unnecessary ellipsis prop * Scan and add the missing filters for all the asset to have hierarchical parent filters. * Fix unit test * Fix the advanced search filter field search --- .../EntityHeaderTitle.component.tsx | 2 +- .../IngestionListTable.test.tsx | 38 ++++- .../IngestionListTable/IngestionListTable.tsx | 5 +- .../PipelineActions.interface.ts | 10 +- .../PipelineActions/PipelineActions.tsx | 4 +- .../PipelineActionsDropdown.interface.ts | 2 + .../PipelineActionsDropdown.test.tsx | 22 +++ .../PipelineActionsDropdown.tsx | 2 + .../common/SuccessScreen/SuccessScreen.tsx | 5 +- .../src/constants/AdvancedSearch.constants.ts | 6 + .../ui/src/enums/AdvancedSearch.enum.ts | 1 + .../ui/src/mocks/IngestionListTable.mock.ts | 2 +- .../src/utils/AdvancedSearchClassBase.test.ts | 137 +++++++++++++++++- .../ui/src/utils/AdvancedSearchClassBase.ts | 121 ++++++++++++++-- .../resources/ui/src/utils/IngestionUtils.tsx | 2 +- 15 files changed, 329 insertions(+), 30 deletions(-) rename openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/{ => IngestionListTable/PipelineActions}/PipelineActions.interface.ts (72%) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityHeaderTitle/EntityHeaderTitle.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityHeaderTitle/EntityHeaderTitle.component.tsx index cc1f0df695b3..34fba24d6c80 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityHeaderTitle/EntityHeaderTitle.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityHeaderTitle/EntityHeaderTitle.component.tsx @@ -55,7 +55,7 @@ const EntityHeaderTitle = ({ data-testid={`${serviceName}-${name}`} gutter={12} wrap={false}> - {icon && {icon}} + {icon && {icon}} ({ useApplicationStore: jest.fn(() => ({ theme: { primaryColor: '#fff' }, @@ -260,4 +267,33 @@ describe('Ingestion', () => { expect(deleteIngestionPipelineById).toHaveBeenCalledWith('id'); }); + + it('should fetch the permissions for all the ingestion pipelines', async () => { + (usePermissionProvider as jest.Mock).mockImplementation(() => ({ + getEntityPermissionByFqn: mockGetEntityPermissionByFqn, + })); + + await act(async () => { + render( + , + { + wrapper: MemoryRouter, + } + ); + }); + + expect(mockGetEntityPermissionByFqn).toHaveBeenNthCalledWith( + 1, + 'ingestionPipeline', + mockESIngestionData.fullyQualifiedName + ); + expect(mockGetEntityPermissionByFqn).toHaveBeenNthCalledWith( + 2, + 'ingestionPipeline', + mockIngestionData.fullyQualifiedName + ); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/IngestionListTable.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/IngestionListTable.tsx index d576ed3b6317..c01231631036 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/IngestionListTable.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/IngestionListTable.tsx @@ -157,7 +157,10 @@ function IngestionListTable({ const fetchIngestionPipelinesPermission = useCallback(async () => { try { const promises = ingestionData.map((item) => - getEntityPermissionByFqn(ResourceEntity.INGESTION_PIPELINE, item.name) + getEntityPermissionByFqn( + ResourceEntity.INGESTION_PIPELINE, + item.fullyQualifiedName ?? '' + ) ); const response = await Promise.allSettled(promises); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/PipelineActions.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/PipelineActions/PipelineActions.interface.ts similarity index 72% rename from openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/PipelineActions.interface.ts rename to openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/PipelineActions/PipelineActions.interface.ts index 0ea302cd0061..075361568eec 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/PipelineActions.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/PipelineActions/PipelineActions.interface.ts @@ -11,10 +11,11 @@ * limitations under the License. */ -import { IngestionServicePermission } from '../../../../context/PermissionProvider/PermissionProvider.interface'; -import { ServiceCategory } from '../../../../enums/service.enum'; -import { IngestionPipeline } from '../../../../generated/entity/services/ingestionPipelines/ingestionPipeline'; -import { SelectedRowDetails } from './ingestion.interface'; +import { ButtonProps } from 'antd'; +import { IngestionServicePermission } from '../../../../../../context/PermissionProvider/PermissionProvider.interface'; +import { ServiceCategory } from '../../../../../../enums/service.enum'; +import { IngestionPipeline } from '../../../../../../generated/entity/services/ingestionPipelines/ingestionPipeline'; +import { SelectedRowDetails } from '../../ingestion.interface'; export interface PipelineActionsProps { pipeline: IngestionPipeline; @@ -28,4 +29,5 @@ export interface PipelineActionsProps { handleEnableDisableIngestion?: (id: string) => Promise; handleIsConfirmationModalOpen: (value: boolean) => void; onIngestionWorkflowsUpdate?: () => void; + moreActionButtonProps?: ButtonProps; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/PipelineActions/PipelineActions.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/PipelineActions/PipelineActions.tsx index 90651e429845..2aab18773b56 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/PipelineActions/PipelineActions.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/PipelineActions/PipelineActions.tsx @@ -22,8 +22,8 @@ import { Operation } from '../../../../../../generated/entity/policies/accessCon import { PipelineType } from '../../../../../../generated/entity/services/ingestionPipelines/ingestionPipeline'; import { getLoadingStatus } from '../../../../../../utils/CommonUtils'; import { getLogsViewerPath } from '../../../../../../utils/RouterUtils'; -import { PipelineActionsProps } from '../../PipelineActions.interface'; import './pipeline-actions.less'; +import { PipelineActionsProps } from './PipelineActions.interface'; import PipelineActionsDropdown from './PipelineActionsDropdown'; function PipelineActions({ @@ -38,6 +38,7 @@ function PipelineActions({ handleIsConfirmationModalOpen, onIngestionWorkflowsUpdate, handleEditClick, + moreActionButtonProps, }: Readonly) { const history = useHistory(); const { t } = useTranslation(); @@ -168,6 +169,7 @@ function PipelineActions({ handleIsConfirmationModalOpen={handleIsConfirmationModalOpen} ingestion={pipeline} ingestionPipelinePermissions={ingestionPipelinePermissions} + moreActionButtonProps={moreActionButtonProps} serviceCategory={serviceCategory} serviceName={serviceName} triggerIngestion={triggerIngestion} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/PipelineActions/PipelineActionsDropdown.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/PipelineActions/PipelineActionsDropdown.interface.ts index a572a7b4c3c5..b38d19a6ba8a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/PipelineActions/PipelineActionsDropdown.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/PipelineActions/PipelineActionsDropdown.interface.ts @@ -11,6 +11,7 @@ * limitations under the License. */ +import { ButtonProps } from 'antd'; import { IngestionServicePermission } from '../../../../../../context/PermissionProvider/PermissionProvider.interface'; import { IngestionPipeline } from '../../../../../../generated/entity/services/ingestionPipelines/ingestionPipeline'; import { SelectedRowDetails } from '../../ingestion.interface'; @@ -26,4 +27,5 @@ export interface PipelineActionsDropdownProps { handleDeleteSelection: (row: SelectedRowDetails) => void; handleIsConfirmationModalOpen: (value: boolean) => void; onIngestionWorkflowsUpdate?: () => void; + moreActionButtonProps?: ButtonProps; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/PipelineActions/PipelineActionsDropdown.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/PipelineActions/PipelineActionsDropdown.test.tsx index 708d91ae94db..93834477741a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/PipelineActions/PipelineActionsDropdown.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/PipelineActions/PipelineActionsDropdown.test.tsx @@ -324,4 +324,26 @@ describe('PipelineActionsDropdown', () => { expect(screen.queryByText('KillIngestionPipelineModal')).toBeNull(); }); + + it('should pass the moreActionButtonProps to the more action button', async () => { + const mockOnClick = jest.fn(); + + await act(async () => { + render( + , + { + wrapper: MemoryRouter, + } + ); + }); + + await clickOnMoreActions(); + + expect(mockOnClick).toHaveBeenCalled(); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/PipelineActions/PipelineActionsDropdown.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/PipelineActions/PipelineActionsDropdown.tsx index 61baee49f856..a6b5db9165ec 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/PipelineActions/PipelineActionsDropdown.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/PipelineActions/PipelineActionsDropdown.tsx @@ -50,6 +50,7 @@ function PipelineActionsDropdown({ handleIsConfirmationModalOpen, onIngestionWorkflowsUpdate, ingestionPipelinePermissions, + moreActionButtonProps, }: Readonly) { const history = useHistory(); const { t } = useTranslation(); @@ -270,6 +271,7 @@ function PipelineActionsDropdown({ icon={} type="link" onClick={() => setIsOpen((value) => !value)} + {...moreActionButtonProps} /> {isKillModalOpen && selectedPipeline && id === selectedPipeline?.id && ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/SuccessScreen/SuccessScreen.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/SuccessScreen/SuccessScreen.tsx index 3f9bc9ce950a..d5c554a45dcb 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/SuccessScreen/SuccessScreen.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/SuccessScreen/SuccessScreen.tsx @@ -101,10 +101,7 @@ const SuccessScreen = ({ - + {isUndefined(successMessage) ? ( diff --git a/openmetadata-ui/src/main/resources/ui/src/constants/AdvancedSearch.constants.ts b/openmetadata-ui/src/main/resources/ui/src/constants/AdvancedSearch.constants.ts index 6d93765a0270..74a10290ad13 100644 --- a/openmetadata-ui/src/main/resources/ui/src/constants/AdvancedSearch.constants.ts +++ b/openmetadata-ui/src/main/resources/ui/src/constants/AdvancedSearch.constants.ts @@ -351,3 +351,9 @@ export const EXPLORE_ROOT_INDEX_MAPPING = { ], Governance: [SearchIndex.GLOSSARY_TERM], }; +export const SEARCH_INDICES_WITH_COLUMNS_FIELD = [ + SearchIndex.TABLE, + SearchIndex.DASHBOARD_DATA_MODEL, + SearchIndex.DATA_ASSET, + SearchIndex.ALL, +]; diff --git a/openmetadata-ui/src/main/resources/ui/src/enums/AdvancedSearch.enum.ts b/openmetadata-ui/src/main/resources/ui/src/enums/AdvancedSearch.enum.ts index 355f7d8894f9..f138dad2994b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/enums/AdvancedSearch.enum.ts +++ b/openmetadata-ui/src/main/resources/ui/src/enums/AdvancedSearch.enum.ts @@ -62,6 +62,7 @@ export enum EntityFields { DATABASE_DISPLAY_NAME = 'database.displayName', DATABASE_SCHEMA_DISPLAY_NAME = 'databaseSchema.displayName', COLUMN = 'columns.name.keyword', + API_COLLECTION = 'apiCollection.displayName.keyword', CHART = 'charts.displayName.keyword', TASK = 'tasks.displayName.keyword', GLOSSARY_TERM_STATUS = 'status', diff --git a/openmetadata-ui/src/main/resources/ui/src/mocks/IngestionListTable.mock.ts b/openmetadata-ui/src/main/resources/ui/src/mocks/IngestionListTable.mock.ts index e0ae76f1857f..9e3a3865f529 100644 --- a/openmetadata-ui/src/main/resources/ui/src/mocks/IngestionListTable.mock.ts +++ b/openmetadata-ui/src/main/resources/ui/src/mocks/IngestionListTable.mock.ts @@ -13,8 +13,8 @@ import { AddIngestionButtonProps } from '../components/Settings/Services/Ingestion/AddIngestionButton.interface'; import { IngestionListTableProps } from '../components/Settings/Services/Ingestion/IngestionListTable/IngestionListTable.interface'; +import { PipelineActionsProps } from '../components/Settings/Services/Ingestion/IngestionListTable/PipelineActions/PipelineActions.interface'; import { PipelineActionsDropdownProps } from '../components/Settings/Services/Ingestion/IngestionListTable/PipelineActions/PipelineActionsDropdown.interface'; -import { PipelineActionsProps } from '../components/Settings/Services/Ingestion/PipelineActions.interface'; import { ServiceCategory } from '../enums/service.enum'; import { DatabaseServiceType } from '../generated/entity/data/database'; import { ConfigType } from '../generated/entity/services/databaseService'; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.test.ts index f9fee0938250..537fdc377d61 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.test.ts @@ -11,7 +11,10 @@ * limitations under the License. */ import { EntityFields } from '../enums/AdvancedSearch.enum'; -import { AdvancedSearchClassBase } from './AdvancedSearchClassBase'; +import { SearchIndex } from '../enums/search.enum'; +import advancedSearchClassBase, { + AdvancedSearchClassBase, +} from './AdvancedSearchClassBase'; jest.mock('../rest/miscAPI', () => ({ getAggregateFieldOptions: jest.fn().mockImplementation(() => @@ -50,3 +53,135 @@ describe('AdvancedSearchClassBase', () => { ]); }); }); + +describe('getEntitySpecificQueryBuilderFields', () => { + it('should return table specific fields', () => { + const result = advancedSearchClassBase.getEntitySpecificQueryBuilderFields([ + SearchIndex.TABLE, + ]); + + expect(Object.keys(result)).toEqual([ + EntityFields.DATABASE, + EntityFields.DATABASE_SCHEMA, + EntityFields.TABLE_TYPE, + EntityFields.COLUMN_DESCRIPTION_STATUS, + ]); + }); + + it('should return pipeline specific fields', () => { + const result = advancedSearchClassBase.getEntitySpecificQueryBuilderFields([ + SearchIndex.PIPELINE, + ]); + + expect(Object.keys(result)).toEqual([EntityFields.TASK]); + }); + + it('should return dashboard specific fields', () => { + const result = advancedSearchClassBase.getEntitySpecificQueryBuilderFields([ + SearchIndex.DASHBOARD, + ]); + + expect(Object.keys(result)).toEqual([ + EntityFields.DATA_MODEL, + EntityFields.CHART, + EntityFields.PROJECT, + ]); + }); + + it('should return topic specific fields', () => { + const result = advancedSearchClassBase.getEntitySpecificQueryBuilderFields([ + SearchIndex.TOPIC, + ]); + + expect(Object.keys(result)).toEqual([EntityFields.SCHEMA_FIELD]); + }); + + it('should return mlModel specific fields', () => { + const result = advancedSearchClassBase.getEntitySpecificQueryBuilderFields([ + SearchIndex.MLMODEL, + ]); + + expect(Object.keys(result)).toEqual([EntityFields.FEATURE]); + }); + + it('should return container specific fields', () => { + const result = advancedSearchClassBase.getEntitySpecificQueryBuilderFields([ + SearchIndex.CONTAINER, + ]); + + expect(Object.keys(result)).toEqual([EntityFields.CONTAINER_COLUMN]); + }); + + it('should return searchIndex specific fields', () => { + const result = advancedSearchClassBase.getEntitySpecificQueryBuilderFields([ + SearchIndex.SEARCH_INDEX, + ]); + + expect(Object.keys(result)).toEqual([EntityFields.FIELD]); + }); + + it('should return dataModel specific fields', () => { + const result = advancedSearchClassBase.getEntitySpecificQueryBuilderFields([ + SearchIndex.DASHBOARD_DATA_MODEL, + ]); + + expect(Object.keys(result)).toEqual([ + EntityFields.DATA_MODEL_TYPE, + EntityFields.PROJECT, + ]); + }); + + it('should return apiEndpoint specific fields', () => { + const result = advancedSearchClassBase.getEntitySpecificQueryBuilderFields([ + SearchIndex.API_ENDPOINT_INDEX, + ]); + + expect(Object.keys(result)).toEqual([ + EntityFields.API_COLLECTION, + EntityFields.REQUEST_SCHEMA_FIELD, + EntityFields.RESPONSE_SCHEMA_FIELD, + ]); + }); + + it('should return glossary specific fields', () => { + const result = advancedSearchClassBase.getEntitySpecificQueryBuilderFields([ + SearchIndex.GLOSSARY_TERM, + ]); + + expect(Object.keys(result)).toEqual([EntityFields.GLOSSARY_TERM_STATUS]); + }); + + it('should return databaseSchema specific fields', () => { + const result = advancedSearchClassBase.getEntitySpecificQueryBuilderFields([ + SearchIndex.DATABASE_SCHEMA, + ]); + + expect(Object.keys(result)).toEqual([EntityFields.DATABASE]); + }); + + it('should return empty fields for multiple indices with no common fields', () => { + const result = advancedSearchClassBase.getEntitySpecificQueryBuilderFields([ + SearchIndex.TABLE, + SearchIndex.PIPELINE, + ]); + + expect(Object.keys(result)).toEqual([]); + }); + + it('should return combined fields for multiple indices with common fields', () => { + const result = advancedSearchClassBase.getEntitySpecificQueryBuilderFields([ + SearchIndex.TABLE, + SearchIndex.DATABASE_SCHEMA, + ]); + + expect(Object.keys(result)).toEqual([EntityFields.DATABASE]); + }); + + it('should return empty object for unknown index', () => { + const result = advancedSearchClassBase.getEntitySpecificQueryBuilderFields([ + 'UNKNOWN_INDEX' as SearchIndex, + ]); + + expect(Object.keys(result)).toEqual([]); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.ts b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.ts index 05da5daaffd5..12ed537d1c29 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.ts @@ -22,6 +22,7 @@ import { SelectFieldSettings, } from 'react-awesome-query-builder'; import AntdConfig from 'react-awesome-query-builder/lib/config/antd'; +import { SEARCH_INDICES_WITH_COLUMNS_FIELD } from '../constants/AdvancedSearch.constants'; import { EntityFields, SuggestionField } from '../enums/AdvancedSearch.enum'; import { SearchIndex } from '../enums/search.enum'; import { getAggregateFieldOptions } from '../rest/miscAPI'; @@ -143,6 +144,24 @@ class AdvancedSearchClassBase { }; }; + /** + * Fields specific to database schema + */ + databaseSchemaQueryBuilderFields: Fields = { + [EntityFields.DATABASE]: { + label: t('label.database'), + type: 'select', + mainWidgetProps: this.mainWidgetProps, + fieldSettings: { + asyncFetch: this.autocomplete({ + searchIndex: SearchIndex.DATABASE_SCHEMA, + entityField: EntityFields.DATABASE, + }), + useAsyncSearch: true, + }, + }, + }; + /** * Fields specific to tables */ @@ -206,6 +225,37 @@ class AdvancedSearchClassBase { }, }; + /** + * Fields specific to stored procedures + */ + storedProcedureQueryBuilderFields: Fields = { + [EntityFields.DATABASE]: { + label: t('label.database'), + type: 'select', + mainWidgetProps: this.mainWidgetProps, + fieldSettings: { + asyncFetch: this.autocomplete({ + searchIndex: SearchIndex.STORED_PROCEDURE, + entityField: EntityFields.DATABASE, + }), + useAsyncSearch: true, + }, + }, + + [EntityFields.DATABASE_SCHEMA]: { + label: t('label.database-schema'), + type: 'select', + mainWidgetProps: this.mainWidgetProps, + fieldSettings: { + asyncFetch: this.autocomplete({ + searchIndex: SearchIndex.STORED_PROCEDURE, + entityField: EntityFields.DATABASE_SCHEMA, + }), + useAsyncSearch: true, + }, + }, + }; + /** * Fields specific to pipelines */ @@ -246,6 +296,18 @@ class AdvancedSearchClassBase { * Fields specific to API endpoints */ apiEndpointQueryBuilderFields: Fields = { + [EntityFields.API_COLLECTION]: { + label: t('label.api-collection'), + type: 'select', + mainWidgetProps: this.mainWidgetProps, + fieldSettings: { + asyncFetch: this.autocomplete({ + searchIndex: SearchIndex.API_ENDPOINT_INDEX, + entityField: EntityFields.API_COLLECTION, + }), + useAsyncSearch: true, + }, + }, [EntityFields.REQUEST_SCHEMA_FIELD]: { label: t('label.request-schema-field'), type: 'select', @@ -438,6 +500,17 @@ class AdvancedSearchClassBase { renderButton: isExplorePage ? renderAdvanceSearchButtons : renderQueryBuilderFilterButtons, + + customFieldSelectProps: { + ...this.baseConfig.settings.customFieldSelectProps, + // Adding filterOption to search by label + // Since the default search behavior is by value which gives incorrect results + // Ex. for search term 'name', it will return 'Task' in results as well + // since value for 'Task' is 'tasks.displayName.keyword' + filterOption: (input: string, option: { label: string }) => { + return option.label.toLowerCase().includes(input.toLowerCase()); + }, + }, }, }; @@ -477,7 +550,7 @@ class AdvancedSearchClassBase { mainWidgetProps: this.mainWidgetProps, fieldSettings: { asyncFetch: this.autocomplete({ - searchIndex: SearchIndex.DATA_ASSET, + searchIndex: entitySearchIndex, entityField: EntityFields.DISPLAY_NAME_KEYWORD, }), useAsyncSearch: true, @@ -498,7 +571,7 @@ class AdvancedSearchClassBase { mainWidgetProps: this.mainWidgetProps, fieldSettings: { asyncFetch: this.autocomplete({ - searchIndex: SearchIndex.DATA_ASSET, + searchIndex: entitySearchIndex, entityField: EntityFields.NAME_KEYWORD, }), useAsyncSearch: true, @@ -627,18 +700,14 @@ class AdvancedSearchClassBase { } // Since the column field key 'columns.name.keyword` is common in table and data model, - // Following function is used to get the column field config based on the search index - // or if it is an explore page + // Following function is used to get the column field config if all the search Indices have columns field + // or for ALL and DATA_ASSET search indices public getColumnConfig = (entitySearchIndex: SearchIndex[]) => { - const searchIndexWithColumns = entitySearchIndex.filter( - (index) => - index === SearchIndex.TABLE || - index === SearchIndex.DASHBOARD_DATA_MODEL || - index === SearchIndex.DATA_ASSET || - index === SearchIndex.ALL + const shouldAddColumnField = entitySearchIndex.every((index) => + SEARCH_INDICES_WITH_COLUMNS_FIELD.includes(index) ); - return !isEmpty(searchIndexWithColumns) + return shouldAddColumnField ? { [EntityFields.COLUMN]: { label: t('label.column'), @@ -646,7 +715,7 @@ class AdvancedSearchClassBase { mainWidgetProps: this.mainWidgetProps, fieldSettings: { asyncFetch: this.autocomplete({ - searchIndex: searchIndexWithColumns, + searchIndex: entitySearchIndex, entityField: EntityFields.COLUMN, }), useAsyncSearch: true, @@ -674,6 +743,8 @@ class AdvancedSearchClassBase { [SearchIndex.DASHBOARD_DATA_MODEL]: this.dataModelQueryBuilderFields, [SearchIndex.API_ENDPOINT_INDEX]: this.apiEndpointQueryBuilderFields, [SearchIndex.GLOSSARY_TERM]: this.glossaryQueryBuilderFields, + [SearchIndex.DATABASE_SCHEMA]: this.databaseSchemaQueryBuilderFields, + [SearchIndex.STORED_PROCEDURE]: this.storedProcedureQueryBuilderFields, [SearchIndex.ALL]: { ...this.tableQueryBuilderFields, ...this.pipelineQueryBuilderFields, @@ -699,9 +770,29 @@ class AdvancedSearchClassBase { }, }; - entitySearchIndex.forEach((index) => { - configs = { ...configs, ...(configIndexMapping[index] ?? {}) }; - }); + // Find out the common fields between the selected indices + if (!isEmpty(entitySearchIndex)) { + const firstIndex = entitySearchIndex[0]; + + // Fields config for the first index + configs = { ...configIndexMapping[firstIndex] }; + + // Iterate over the rest of the indices to see the common fields + entitySearchIndex.slice(1).forEach((index) => { + // Get the current config for the current iteration index + const currentConfig = configIndexMapping[index] ?? {}; + + // Filter out the fields that are not common between the current and previous configs + configs = Object.keys(configs).reduce((acc, key) => { + // If the key exists in the current config, add it to the accumulator + if (currentConfig[key]) { + acc[key] = configs[key]; + } + + return acc; + }, {} as Fields); + }); + } return configs; } diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/IngestionUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/IngestionUtils.tsx index 9e50c736e007..86b84c97da2e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/IngestionUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/IngestionUtils.tsx @@ -359,7 +359,7 @@ export const getSuccessMessage = ( return ( - {`"${ingestionName}"`} + {`"${ingestionName}"`} {status === FormSubmitType.ADD ? createMessage : updateMessage} From f0c610f5a8040296fa5e493ecc50e1a9a1ec8341 Mon Sep 17 00:00:00 2001 From: Sachin Chaurasiya Date: Thu, 2 Jan 2025 20:44:58 +0530 Subject: [PATCH 056/294] Minor: Deprecate RichTextEditorPreviewer component and recommend alternative (#19204) (cherry picked from commit 6542123a52c22df7e8cf98856b1af70b007bd48f) --- .../common/RichTextEditor/RichTextEditorPreviewer.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditorPreviewer.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditorPreviewer.tsx index a0cc205b9876..259e53b5cbd6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditorPreviewer.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditorPreviewer.tsx @@ -27,6 +27,10 @@ import { import './rich-text-editor-previewer.less'; import { PreviewerProp } from './RichTextEditor.interface'; +/** + * @deprecated This component is deprecated and will be removed in future releases. + * Please use {@link https://github.com/open-metadata/OpenMetadata/blob/main/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditorPreviewerV1.tsx|RichTextEditorPreviewerV1} instead of this component. + */ const RichTextEditorPreviewer = ({ markdown = '', className = '', From 22bc3b1ab8a6c3f94a4da4937350651356f210ba Mon Sep 17 00:00:00 2001 From: Sachin Chaurasiya Date: Tue, 7 Jan 2025 20:10:09 +0530 Subject: [PATCH 057/294] Minor: Enhance Block Editor Adjust drag-and-drop handles and improve bar menu layout (#19228) * Minor: Enhance Block Editor Adjust drag-and-drop handles and improve bar menu layout * Refactor: Replace RichTextEditor with dynamic description fields in user and team forms * Enhance AddNotificationPage and AddObservabilityPage to use RichTextEditor for description input * Enhance AddNotificationPage and AddObservabilityPage to initialize RichTextEditor with alert description (cherry picked from commit aceadb1cdeb61cc0a649822d54cee0accb7e79f1) --- .../BlockEditor/BarMenu/bar-menu.less | 1 + .../BlockAndDragDrop/BlockAndDragHandle.ts | 93 ++++++++++++------- .../components/BlockEditor/block-editor.less | 10 +- .../AddDataQualityTest/EditTestCaseModal.tsx | 32 ++++--- .../components/TestCaseForm.tsx | 32 ++++--- .../TestCaseStatusModal.component.tsx | 64 ++++++------- .../AddTestSuiteForm/AddTestSuiteForm.tsx | 32 +++++-- .../Entity/Task/TaskTab/TaskTab.component.tsx | 52 +++++++---- .../AddAnnouncementModal.tsx | 31 ++++--- .../EditAnnouncementModal.tsx | 31 ++++--- .../Users/CreateUser/CreateUser.component.tsx | 25 +++-- .../rich-text-editor-previewerV1.less | 11 +++ .../AddNotificationPage.tsx | 5 +- .../AddObservabilityPage.tsx | 5 +- .../AddQueryPage/AddQueryPage.component.tsx | 36 ++++--- .../ui/src/pages/KPIPage/AddKPIPage.tsx | 49 ++++++---- .../ui/src/pages/KPIPage/EditKPIPage.tsx | 33 ++++--- .../AddPolicyPage/AddPolicyPage.tsx | 36 ++++--- .../PoliciesDetailPage/EditRulePage.tsx | 9 +- .../pages/PoliciesPage/RuleForm/RuleForm.tsx | 42 ++++++--- .../RolesPage/AddRolePage/AddRolePage.tsx | 37 +++++--- .../ui/src/pages/TeamsPage/AddTeamForm.tsx | 33 ++++--- 22 files changed, 447 insertions(+), 252 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BarMenu/bar-menu.less b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BarMenu/bar-menu.less index 1dcb7bcef7c1..779e072c7014 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BarMenu/bar-menu.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/BarMenu/bar-menu.less @@ -21,6 +21,7 @@ border-top-right-radius: 4px; background-color: @menu-bg-color; display: flex; + flex-wrap: wrap; flex-direction: row; padding: 8px; border-bottom: 1px solid @format-group-separator-color; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/BlockAndDragDrop/BlockAndDragHandle.ts b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/BlockAndDragDrop/BlockAndDragHandle.ts index a807211858cc..57bcaee06f20 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/BlockAndDragDrop/BlockAndDragHandle.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/BlockAndDragDrop/BlockAndDragHandle.ts @@ -197,42 +197,63 @@ export const BlockAndDragHandle = (options: BlockAndDragHandleOptions) => { return new Plugin({ view: (view) => { - // drag handle initialization - dragHandleElement = document.createElement('div'); - dragHandleElement.draggable = true; - dragHandleElement.dataset.dragHandle = ''; - dragHandleElement.title = 'Drag to move\nClick to open menu'; - dragHandleElement.classList.add('om-drag-handle'); - dragHandleElement.addEventListener('dragstart', (e) => { - handleDragStart(e, view); - }); - dragHandleElement.addEventListener('click', (e) => { - handleDragClick(e, view); - }); - - hideDragHandle(); - - // block handle initialization - blockHandleElement = document.createElement('div'); - blockHandleElement.draggable = false; - blockHandleElement.dataset.blockHandle = ''; - blockHandleElement.title = 'Add new node'; - blockHandleElement.classList.add('om-block-handle'); - - hideBlockHandle(); - - view?.dom?.parentElement?.appendChild(dragHandleElement); - view?.dom?.parentElement?.appendChild(blockHandleElement); - - return { - destroy: () => { - dragHandleElement?.remove?.(); - dragHandleElement = null; - - blockHandleElement?.remove?.(); - blockHandleElement = null; - }, - }; + try { + const isBarMenu = + view.dom?.parentElement?.parentElement?.classList.contains( + 'block-editor-wrapper--bar-menu' + ); + + if (isBarMenu) { + return { + destroy: () => { + // do nothing + }, + }; + } + + // drag handle initialization + dragHandleElement = document.createElement('div'); + dragHandleElement.draggable = true; + dragHandleElement.dataset.dragHandle = ''; + dragHandleElement.title = 'Drag to move\nClick to open menu'; + dragHandleElement.classList.add('om-drag-handle'); + dragHandleElement.addEventListener('dragstart', (e) => { + handleDragStart(e, view); + }); + dragHandleElement.addEventListener('click', (e) => { + handleDragClick(e, view); + }); + + hideDragHandle(); + + // block handle initialization + blockHandleElement = document.createElement('div'); + blockHandleElement.draggable = false; + blockHandleElement.dataset.blockHandle = ''; + blockHandleElement.title = 'Add new node'; + blockHandleElement.classList.add('om-block-handle'); + + hideBlockHandle(); + + view?.dom?.parentElement?.appendChild(dragHandleElement); + view?.dom?.parentElement?.appendChild(blockHandleElement); + + return { + destroy: () => { + dragHandleElement?.remove?.(); + dragHandleElement = null; + + blockHandleElement?.remove?.(); + blockHandleElement = null; + }, + }; + } catch (error) { + return { + destroy: () => { + // do nothing + }, + }; + } }, props: { handleDOMEvents: { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/block-editor.less b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/block-editor.less index 6dfb77acf427..d9c0dc8b1252 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/block-editor.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/block-editor.less @@ -207,11 +207,17 @@ &.block-editor-wrapper--bar-menu { border: 1px solid #dadde6; border-radius: 4px; + + // do not show drag handle and block handle in bar menu mode + .om-drag-handle, + .om-block-handle { + display: none; + } .om-block-editor { max-height: 300px; overflow: auto; - padding-left: 50px; - padding-right: 50px; + padding-left: 24px; + padding-right: 24px; padding-top: 8px; &:focus-visible { outline: none; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/EditTestCaseModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/EditTestCaseModal.tsx index d81dd6a40776..9999c1e63f87 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/EditTestCaseModal.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/EditTestCaseModal.tsx @@ -48,7 +48,6 @@ import { generateFormFields } from '../../../utils/formUtils'; import { isValidJSONString } from '../../../utils/StringsUtils'; import { showErrorToast, showSuccessToast } from '../../../utils/ToastUtils'; import Loader from '../../common/Loader/Loader'; -import RichTextEditor from '../../common/RichTextEditor/RichTextEditor'; import { EditTestCaseModalProps } from './AddDataQualityTest.interface'; import ParameterForm from './components/ParameterForm'; @@ -214,6 +213,24 @@ const EditTestCaseModal: React.FC = ({ } }; + const descriptionField: FieldProp = useMemo( + () => ({ + name: 'description', + required: false, + label: t('label.description'), + id: 'root/description', + type: FieldTypes.DESCRIPTION, + props: { + 'data-testid': 'description', + initialValue: testCase?.description ?? '', + style: { + margin: 0, + }, + }, + }), + [testCase?.description] + ); + useEffect(() => { if (testCase) { fetchTestDefinitionById(); @@ -317,18 +334,7 @@ const EditTestCaseModal: React.FC = ({ {!showOnlyParameter && ( <> - - - + {generateFormFields([descriptionField])} {isComputeRowCountFieldVisible ? generateFormFields(formFields) : null} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/components/TestCaseForm.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/components/TestCaseForm.tsx index 7de1ef7e6bdc..c4f3add108e7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/components/TestCaseForm.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/components/TestCaseForm.tsx @@ -63,7 +63,6 @@ import { getEntityName } from '../../../../utils/EntityUtils'; import { generateFormFields } from '../../../../utils/formUtils'; import { generateEntityLink } from '../../../../utils/TableUtils'; import { showErrorToast } from '../../../../utils/ToastUtils'; -import RichTextEditor from '../../../common/RichTextEditor/RichTextEditor'; import { TestCaseFormProps, TestCaseFormType, @@ -249,6 +248,24 @@ const TestCaseForm: React.FC = ({ ); }; + const descriptionField: FieldProp = useMemo( + () => ({ + name: 'description', + required: false, + label: t('label.description'), + id: 'root/description', + type: FieldTypes.DESCRIPTION, + props: { + 'data-testid': 'description', + initialValue: initialValue?.description ?? '', + style: { + margin: 0, + }, + }, + }), + [initialValue?.description] + ); + useEffect(() => { const selectedColumn = table.columns.find( (column) => column.name === columnName @@ -418,17 +435,8 @@ const TestCaseForm: React.FC = ({ getFieldValue('useDynamicAssertion') ? null : generateParamsField } - - - + + {generateFormFields([descriptionField])} {isComputeRowCountFieldVisible ? generateFormFields(formFields) : null} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCaseStatusModal/TestCaseStatusModal.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCaseStatusModal/TestCaseStatusModal.component.tsx index 1f41d22aa0b4..6680398a8319 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCaseStatusModal/TestCaseStatusModal.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCaseStatusModal/TestCaseStatusModal.component.tsx @@ -15,7 +15,6 @@ import { AxiosError } from 'axios'; import { startCase, unionBy } from 'lodash'; import React, { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import RichTextEditor from '../../../components/common/RichTextEditor/RichTextEditor'; import { EntityType } from '../../../enums/entity.enum'; import { CreateTestCaseResolutionStatus } from '../../../generated/api/tests/createTestCaseResolutionStatus'; import { TestCaseFailureReasonType } from '../../../generated/tests/resolved'; @@ -29,6 +28,8 @@ import { showErrorToast } from '../../../utils/ToastUtils'; import { VALIDATION_MESSAGES } from '../../../constants/constants'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; +import { FieldProp, FieldTypes } from '../../../interface/FormUtils.interface'; +import { generateFormFields } from '../../../utils/formUtils'; import { TestCaseStatusModalProps } from './TestCaseStatusModal.interface'; export const TestCaseStatusModal = ({ @@ -125,6 +126,36 @@ export const TestCaseStatusModal = ({ } }; + const descriptionField: FieldProp = useMemo( + () => ({ + name: ['testCaseResolutionStatusDetails', 'testCaseFailureComment'], + required: true, + label: t('label.comment'), + id: 'root/description', + type: FieldTypes.DESCRIPTION, + rules: [ + { + required: true, + }, + ], + props: { + 'data-testid': 'description', + initialValue: + data?.testCaseResolutionStatusDetails?.testCaseFailureComment ?? '', + placeHolder: t('message.write-your-text', { + text: t('label.comment'), + }), + + onTextChange: (value: string) => + form.setFieldValue( + ['testCaseResolutionStatusDetails', 'testCaseFailureComment'], + value + ), + }, + }), + [data?.testCaseResolutionStatusDetails?.testCaseFailureComment] + ); + useEffect(() => { const assignee = data?.testCaseResolutionStatusDetails?.assignee; if ( @@ -202,36 +233,7 @@ export const TestCaseStatusModal = ({ ))} - - - form.setFieldValue( - [ - 'testCaseResolutionStatusDetails', - 'testCaseFailureComment', - ], - value - ) - } - /> - + {generateFormFields([descriptionField])} )} {statusType === TestCaseResolutionStatusTypes.Assigned && ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestSuite/AddTestSuiteForm/AddTestSuiteForm.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestSuite/AddTestSuiteForm/AddTestSuiteForm.tsx index 2b587d39bd50..2800555ceb92 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestSuite/AddTestSuiteForm/AddTestSuiteForm.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestSuite/AddTestSuiteForm/AddTestSuiteForm.tsx @@ -12,7 +12,7 @@ */ import { Button, Form, Input, Space } from 'antd'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useHistory } from 'react-router-dom'; import { @@ -22,11 +22,15 @@ import { import { NAME_FIELD_RULES } from '../../../../constants/Form.constants'; import { TabSpecificField } from '../../../../enums/entity.enum'; import { TestSuite } from '../../../../generated/tests/testSuite'; +import { + FieldProp, + FieldTypes, +} from '../../../../interface/FormUtils.interface'; import { DataQualityPageTabs } from '../../../../pages/DataQuality/DataQualityPage.interface'; import { getListTestSuites } from '../../../../rest/testAPI'; +import { getField } from '../../../../utils/formUtils'; import { getDataQualityPagePath } from '../../../../utils/RouterUtils'; import Loader from '../../../common/Loader/Loader'; -import RichTextEditor from '../../../common/RichTextEditor/RichTextEditor'; import { AddTestSuiteFormProps } from '../TestSuiteStepper/TestSuiteStepper.interface'; const AddTestSuiteForm: React.FC = ({ @@ -58,6 +62,21 @@ const AddTestSuiteForm: React.FC = ({ history.push(getDataQualityPagePath(DataQualityPageTabs.TEST_SUITES)); }; + const descriptionField: FieldProp = useMemo( + () => ({ + name: 'description', + required: false, + label: `${t('label.description')}:`, + id: 'root/description', + type: FieldTypes.DESCRIPTION, + props: { + 'data-testid': 'test-suite-description', + initialValue: testSuite?.description ?? '', + }, + }), + [testSuite?.description] + ); + useEffect(() => { fetchTestSuites(); }, []); @@ -101,14 +120,7 @@ const AddTestSuiteForm: React.FC = ({ })} /> - - form.setFieldsValue({ description: value })} - /> - - + {getField(descriptionField)} - - - + {getField(descriptionField)} ) : ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Modals/AnnouncementModal/AddAnnouncementModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Modals/AnnouncementModal/AddAnnouncementModal.tsx index a98a30577611..f2ac332d6900 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Modals/AnnouncementModal/AddAnnouncementModal.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Modals/AnnouncementModal/AddAnnouncementModal.tsx @@ -14,7 +14,7 @@ import { DatePicker, Form, Input, Modal, Space } from 'antd'; import { AxiosError } from 'axios'; import { Moment } from 'moment'; -import React, { FC, useState } from 'react'; +import React, { FC, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { VALIDATION_MESSAGES } from '../../../constants/constants'; import { @@ -27,7 +27,8 @@ import { getEntityFeedLink } from '../../../utils/EntityUtils'; import { showErrorToast, showSuccessToast } from '../../../utils/ToastUtils'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; -import RichTextEditor from '../../common/RichTextEditor/RichTextEditor'; +import { FieldProp, FieldTypes } from '../../../interface/FormUtils.interface'; +import { getField } from '../../../utils/formUtils'; import './announcement-modal.less'; interface Props { @@ -96,6 +97,22 @@ const AddAnnouncementModal: FC = ({ } }; + const descriptionField: FieldProp = useMemo( + () => ({ + name: 'description', + required: false, + label: `${t('label.description')}:`, + id: 'root/description', + type: FieldTypes.DESCRIPTION, + props: { + 'data-testid': 'description', + initialValue: '', + placeHolder: t('message.write-your-announcement-lowercase'), + }, + }), + [] + ); + return ( = ({ - - - + {getField(descriptionField)} ); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Modals/AnnouncementModal/EditAnnouncementModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Modals/AnnouncementModal/EditAnnouncementModal.tsx index 12bb09076356..b47a818c5a14 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Modals/AnnouncementModal/EditAnnouncementModal.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Modals/AnnouncementModal/EditAnnouncementModal.tsx @@ -13,13 +13,14 @@ import { DatePicker, Form, Input, Modal, Space } from 'antd'; import moment from 'moment'; -import React, { FC } from 'react'; +import React, { FC, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { VALIDATION_MESSAGES } from '../../../constants/constants'; import { AnnouncementDetails } from '../../../generated/entity/feed/thread'; +import { FieldProp, FieldTypes } from '../../../interface/FormUtils.interface'; import { getTimeZone } from '../../../utils/date-time/DateTimeUtils'; +import { getField } from '../../../utils/formUtils'; import { showErrorToast } from '../../../utils/ToastUtils'; -import RichTextEditor from '../../common/RichTextEditor/RichTextEditor'; import { CreateAnnouncement } from './AddAnnouncementModal'; import './announcement-modal.less'; @@ -63,6 +64,22 @@ const EditAnnouncementModal: FC = ({ } }; + const descriptionField: FieldProp = useMemo( + () => ({ + name: 'description', + required: false, + label: `${t('label.description')}:`, + id: 'root/description', + type: FieldTypes.DESCRIPTION, + props: { + 'data-testid': 'description', + initialValue: announcement.description, + placeHolder: t('message.write-your-announcement-lowercase'), + }, + }), + [announcement.description] + ); + return ( = ({ - - - + {getField(descriptionField)} ); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/CreateUser/CreateUser.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/CreateUser/CreateUser.component.tsx index e8fca9ec4565..0d21bd811296 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/CreateUser/CreateUser.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/CreateUser/CreateUser.component.tsx @@ -60,7 +60,6 @@ import CopyToClipboardButton from '../../../common/CopyToClipboardButton/CopyToC import { DomainLabel } from '../../../common/DomainLabel/DomainLabel.component'; import InlineAlert from '../../../common/InlineAlert/InlineAlert'; import Loader from '../../../common/Loader/Loader'; -import RichTextEditor from '../../../common/RichTextEditor/RichTextEditor'; import TeamsSelectable from '../../Team/TeamsSelectable/TeamsSelectable'; import { CreateUserProps } from './CreateUser.interface'; @@ -195,6 +194,21 @@ const CreateUser = ({ onSave(userProfile); }; + const descriptionField: FieldProp = useMemo( + () => ({ + name: 'description', + required: false, + label: t('label.description'), + id: 'root/description', + type: FieldTypes.DESCRIPTION, + props: { + 'data-testid': 'description', + initialValue: '', + }, + }), + [] + ); + useEffect(() => { generateRandomPassword(); }, []); @@ -272,13 +286,8 @@ const CreateUser = ({ )} - - - + + {getField(descriptionField)} {!forceBot && ( <> diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/rich-text-editor-previewerV1.less b/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/rich-text-editor-previewerV1.less index d15bb69436ee..32831c1628a8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/rich-text-editor-previewerV1.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/rich-text-editor-previewerV1.less @@ -37,4 +37,15 @@ } } } + + .markdown-parser.white { + .block-editor-wrapper .tiptap.ProseMirror.om-block-editor { + p { + color: @white; + } + ul li::before { + background-color: @white; + } + } + } } diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/AddNotificationPage/AddNotificationPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/AddNotificationPage/AddNotificationPage.tsx index 9b2107723e02..161edece6493 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/AddNotificationPage/AddNotificationPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/AddNotificationPage/AddNotificationPage.tsx @@ -269,11 +269,10 @@ const AddNotificationPage = () => { label={t('label.description')} labelCol={{ span: 24 }} name="description" - trigger="onTextChange" - valuePropName="initialValue"> + trigger="onTextChange"> diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/AddObservabilityPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/AddObservabilityPage.tsx index a65e9f98d468..3c680ad0db59 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/AddObservabilityPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/AddObservabilityPage.tsx @@ -243,11 +243,10 @@ function AddObservabilityPage() { label={t('label.description')} labelCol={{ span: 24 }} name="description" - trigger="onTextChange" - valuePropName="initialValue"> + trigger="onTextChange"> diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/AddQueryPage/AddQueryPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/AddQueryPage/AddQueryPage.component.tsx index 8464a2b7327a..23c5f1a2c3e8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/AddQueryPage/AddQueryPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/AddQueryPage/AddQueryPage.component.tsx @@ -14,12 +14,11 @@ import { Button, Form, FormProps, Space, Tooltip, Typography } from 'antd'; import { DefaultOptionType } from 'antd/lib/select'; import { AxiosError } from 'axios'; import { filter, isEmpty } from 'lodash'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { AsyncSelect } from '../../components/common/AsyncSelect/AsyncSelect'; import ResizablePanels from '../../components/common/ResizablePanels/ResizablePanels'; -import RichTextEditor from '../../components/common/RichTextEditor/RichTextEditor'; import TitleBreadcrumb from '../../components/common/TitleBreadcrumb/TitleBreadcrumb.component'; import { TitleBreadcrumbProps } from '../../components/common/TitleBreadcrumb/TitleBreadcrumb.interface'; import SchemaEditor from '../../components/Database/SchemaEditor/SchemaEditor'; @@ -39,6 +38,7 @@ import { CreateQuery } from '../../generated/api/data/createQuery'; import { Table } from '../../generated/entity/data/table'; import { useApplicationStore } from '../../hooks/useApplicationStore'; import { useFqn } from '../../hooks/useFqn'; +import { FieldProp, FieldTypes } from '../../interface/FormUtils.interface'; import { searchData } from '../../rest/miscAPI'; import { postQuery } from '../../rest/queryAPI'; import { getTableDetailsByFQN } from '../../rest/tableAPI'; @@ -49,6 +49,7 @@ import { getEntityLabel, getEntityName, } from '../../utils/EntityUtils'; +import { getField } from '../../utils/formUtils'; import { showErrorToast, showSuccessToast } from '../../utils/ToastUtils'; const AddQueryPage = () => { @@ -196,6 +197,26 @@ const AddQueryPage = () => { } }; + const descriptionField: FieldProp = useMemo( + () => ({ + name: 'description', + required: false, + label: `${t('label.description')}:`, + id: 'root/description', + type: FieldTypes.DESCRIPTION, + props: { + 'data-testid': 'description', + initialValue: '', + style: { + margin: 0, + }, + placeHolder: t('message.write-your-description'), + onTextChange: (value: string) => setDescription(value), + }, + }), + [] + ); + return ( { showCopyButton={false} /> - - setDescription(value)} - /> - + {getField(descriptionField)} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/KPIPage/AddKPIPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/KPIPage/AddKPIPage.tsx index f56b4a14787d..04fa75e3e372 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/KPIPage/AddKPIPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/KPIPage/AddKPIPage.tsx @@ -34,7 +34,6 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useHistory } from 'react-router-dom'; import ResizablePanels from '../../components/common/ResizablePanels/ResizablePanels'; -import RichTextEditor from '../../components/common/RichTextEditor/RichTextEditor'; import TitleBreadcrumb from '../../components/common/TitleBreadcrumb/TitleBreadcrumb.component'; import { ROUTES, VALIDATION_MESSAGES } from '../../constants/constants'; import { KPI_DATE_PICKER_FORMAT } from '../../constants/DataInsight.constants'; @@ -44,11 +43,13 @@ import { KpiTargetType, } from '../../generated/api/dataInsight/kpi/createKpiRequest'; import { Kpi } from '../../generated/dataInsight/kpi/kpi'; +import { FieldProp, FieldTypes } from '../../interface/FormUtils.interface'; import { getListKPIs, postKPI } from '../../rest/KpiAPI'; import { getDataInsightPathWithFqn, getDisabledDates, } from '../../utils/DataInsightUtils'; +import { getField } from '../../utils/formUtils'; import { filterChartOptions, getDataInsightChartForKPI, @@ -158,6 +159,33 @@ const AddKPIPage = () => { } }; + const descriptionField: FieldProp = useMemo( + () => ({ + name: 'description', + required: true, + label: t('label.description'), + id: 'root/description', + type: FieldTypes.DESCRIPTION, + rules: [ + { + required: true, + message: t('label.field-required', { + field: t('label.description-kpi'), + }), + }, + ], + props: { + 'data-testid': 'description', + initialValue: '', + style: { + margin: 0, + }, + placeHolder: t('message.write-your-description'), + }, + }), + [] + ); + useEffect(() => { fetchKpiList(); }, []); @@ -346,24 +374,7 @@ const AddKPIPage = () => { - - - + {getField(descriptionField)} } @@ -225,9 +238,7 @@ const TestSuitePipelineTab = ({ testSuite }: Props) => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestSuite/TestSuitePipelineTab/TestSuitePipelineTab.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestSuite/TestSuitePipelineTab/TestSuitePipelineTab.test.tsx index 72c7303512a8..d1cb54014a4b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestSuite/TestSuitePipelineTab/TestSuitePipelineTab.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestSuite/TestSuitePipelineTab/TestSuitePipelineTab.test.tsx @@ -38,8 +38,8 @@ const mockTestSuite = { updatedAt: 1692766701920, updatedBy: 'admin', deleted: false, - executable: true, - executableEntityReference: { + basic: true, + basicEntityReference: { id: 'e926d275-441e-49ee-a073-ad509f625a14', type: 'table', name: 'web_analytic_event', diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestSuite/TestSuiteStepper/TestSuiteStepper.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestSuite/TestSuiteStepper/TestSuiteStepper.tsx index e7c0fc86efce..68c6d66bc1dc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestSuite/TestSuiteStepper/TestSuiteStepper.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestSuite/TestSuiteStepper/TestSuiteStepper.tsx @@ -38,6 +38,7 @@ import TitleBreadcrumb from '../../../common/TitleBreadcrumb/TitleBreadcrumb.com import IngestionStepper from '../../../Settings/Services/Ingestion/IngestionStepper/IngestionStepper.component'; import RightPanel from '../../AddDataQualityTest/components/RightPanel'; import { getRightPanelForAddTestSuitePage } from '../../AddDataQualityTest/rightPanelData'; +import TestSuiteIngestion from '../../AddDataQualityTest/TestSuiteIngestion'; import { AddTestCaseList } from '../../AddTestCaseList/AddTestCaseList.component'; import AddTestSuiteForm from '../AddTestSuiteForm/AddTestSuiteForm'; @@ -47,6 +48,7 @@ const TestSuiteStepper = () => { const { currentUser } = useApplicationStore(); const [activeServiceStep, setActiveServiceStep] = useState(1); const [testSuiteResponse, setTestSuiteResponse] = useState(); + const [addIngestion, setAddIngestion] = useState(false); const handleViewTestSuiteClick = () => { history.push(getTestSuitePath(testSuiteResponse?.fullyQualifiedName ?? '')); @@ -114,9 +116,10 @@ const TestSuiteStepper = () => { } else if (activeServiceStep === 3) { return ( setAddIngestion(true)} handleViewServiceClick={handleViewTestSuiteClick} name={testSuiteResponse?.name || ''} - showIngestionButton={false} state={FormSubmitType.ADD} viewServiceText="View Test Suite" /> @@ -142,25 +145,33 @@ const TestSuiteStepper = () => { data-testid="test-suite-stepper-container"> - - - - {t('label.add-entity', { - entity: t('label.test-suite'), - })} - - - - - - {RenderSelectedTab()} - + {addIngestion ? ( + setAddIngestion(false)} + onViewServiceClick={handleViewTestSuiteClick} + /> + ) : ( + + + + {t('label.add-entity', { + entity: t('label.test-suite'), + })} + + + + + + {RenderSelectedTab()} + + )} ), diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/tests/createTestSuite.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/tests/createTestSuite.ts index 2b393503b01c..a62d69b4dc13 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/api/tests/createTestSuite.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/tests/createTestSuite.ts @@ -1,5 +1,5 @@ /* - * Copyright 2024 Collate. + * Copyright 2025 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 @@ -10,12 +10,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - - - /** +/** * Schema corresponding to a Test Suite */ export interface CreateTestSuite { + /** + * Entity reference the test suite needs to execute the test against. Only applicable if the + * test suite is basic. + */ + basicEntityReference?: string; /** * Description of the test suite. */ @@ -28,11 +31,6 @@ export interface CreateTestSuite { * Fully qualified name of the domain the Table belongs to. */ domain?: string; - /** - * FQN of the entity the test suite is executed against. Only applicable for executable test - * suites. - */ - executableEntityReference?: string; /** * Name that identifies this test suite. */ diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/connections/dashboard/mstrConnection.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/connections/dashboard/mstrConnection.ts new file mode 100644 index 000000000000..568b914b856a --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/connections/dashboard/mstrConnection.ts @@ -0,0 +1,62 @@ +/* + * Copyright 2025 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. + */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* + * Copyright 2021 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. + */ + + /** + * Mstr Connection Config + */ +export interface MstrConnection { + /** + * Host and Port of the Metabase instance. + */ + hostPort: string; + /** + * Password to connect to MSTR. + */ + password?: string; + /** + * MSTR Project Name + */ + projectName?: string; + supportsMetadataExtraction?: boolean; + /** + * Service Type + */ + type?: MstrType; + /** + * Username to connect to MSTR. This user should have privileges to read all the metadata in + * MSTR. + */ + username: string; +} + +/** + * Service Type + * + * Mstr service type + */ +export enum MstrType { + Mstr = "Mstr", +} diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/metadataIngestion/testSuitePipeline.ts b/openmetadata-ui/src/main/resources/ui/src/generated/metadataIngestion/testSuitePipeline.ts index edb969120ae7..14f867f8847f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/metadataIngestion/testSuitePipeline.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/metadataIngestion/testSuitePipeline.ts @@ -1,5 +1,5 @@ /* - * Copyright 2024 Collate. + * Copyright 2025 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 @@ -10,22 +10,24 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - - - /** +/** * TestSuite Pipeline Configuration. */ export interface TestSuitePipeline { /** - * Fully qualified name of the entity to be tested. + * Fully qualified name of the entity to be tested, if we're working with a basic suite. */ - entityFullyQualifiedName: string; + entityFullyQualifiedName?: string; /** * Percentage of data or no. of rows we want to execute the profiler and tests on */ profileSample?: number; profileSampleType?: ProfileSampleType; samplingMethodType?: SamplingMethodType; + /** + * Service connections to be used for the logical test suite. + */ + serviceConnections?: ServiceConnections[]; /** * List of test cases to be executed on the entity. If null, all test cases will be executed. */ @@ -52,6 +54,3361 @@ export enum SamplingMethodType { System = "SYSTEM", } +/** + * Service connections available for the logical test suite. + */ +export interface ServiceConnections { + /** + * Connection configuration for the source. ex: mysql , tableau connection. + */ + serviceConnection: ServiceConnection; + serviceName: string; +} + +/** + * Connection configuration for the source. ex: mysql , tableau connection. + * + * Supported services + * + * API Service Connection. + * + * Dashboard Connection. + * + * Database Connection. + * + * Metadata Service Connection. + * + * Pipeline Connection. + * + * MlModel Connection. + * + * Storage Connection. + * + * search Connection. + */ +export interface ServiceConnection { + config?: ConfigClass; +} + +/** + * REST Connection Config + * + * Looker Connection Config + * + * Metabase Connection Config + * + * PowerBI Connection Config + * + * PowerBIReportServer Connection Config + * + * Redash Connection Config + * + * Superset Connection Config + * + * Tableau Connection Config + * + * Mode Connection Config + * + * Custom Dashboard Service connection to build a source that is not supported by + * OpenMetadata yet. + * + * Domo Dashboard Connection Config + * + * QuickSight Connection Config + * + * Qlik Sense Connection Config + * + * Lightdash Connection Config + * + * MicroStrategy Connection Config + * + * Qlik Cloud Connection Config + * + * Sigma Connection Config + * + * Google BigQuery Connection Config + * + * Google BigTable Connection Config + * + * AWS Athena Connection Config + * + * Azure SQL Connection Config + * + * Clickhouse Connection Config + * + * Databricks Connection Config + * + * Db2 Connection Config + * + * DeltaLake Database Connection Config + * + * Druid Connection Config + * + * DynamoDB Connection Config + * + * Glue Connection Config + * + * Hive SQL Connection Config + * + * Impala SQL Connection Config + * + * MariaDB Database Connection Config + * + * Mssql Database Connection Config + * + * Mysql Database Connection Config + * + * SQLite Database Connection Config + * + * Oracle Database Connection Config + * + * Postgres Database Connection Config + * + * Presto Database Connection Config + * + * Redshift Connection Config + * + * Salesforce Connection Config + * + * SingleStore Database Connection Config + * + * Snowflake Connection Config + * + * Trino Connection Config + * + * Vertica Connection Config + * + * PinotDB Database Connection Config + * + * Datalake Connection Config + * + * Domo Database Connection Config + * + * Custom Database Service connection to build a source that is not supported by + * OpenMetadata yet. + * + * Sap Hana Database Connection Config + * + * MongoDB Connection Config + * + * Couchbase Connection Config + * + * Greenplum Database Connection Config + * + * Doris Database Connection Config + * + * UnityCatalog Connection Config + * + * SAS Connection Config + * + * Iceberg Catalog Connection Config + * + * Teradata Database Connection Config + * + * Sap ERP Database Connection Config + * + * Synapse Database Connection Config + * + * Exasol Database Connection Config + * + * Kafka Connection Config + * + * Redpanda Connection Config + * + * Kinesis Connection Config + * + * Custom Messaging Service Connection to build a source that is not supported by + * OpenMetadata yet. + * + * Amundsen Connection Config + * + * Metadata to ElasticSearch Connection Config + * + * OpenMetadata Connection Config + * + * Atlas Connection Config + * + * Alation Connection Config + * + * Alation Sink Connection Config + * + * Airflow Metadata Database Connection Config + * + * Glue Pipeline Connection Config + * + * Airbyte Metadata Database Connection Config + * + * Fivetran Metadata Database Connection Config + * + * Flink Metadata Connection Config + * + * Dagster Metadata Database Connection Config + * + * Nifi Metadata Pipeline Connection Config + * + * Domo Pipeline Connection Config + * + * Custom Pipeline Service connection to build a source that is not supported by + * OpenMetadata yet. + * + * Spline Metadata Database Connection Config + * + * Spark Metadata Pipeline Connection Config + * + * OpenLineage Connection Config + * + * KafkaConnect Connection Config + * + * DBTCloud Connection Config + * + * Matillion Connection + * + * Azure Data Factory Connection Config + * + * Stitch Connection + * + * MlFlow Connection Config + * + * Sklearn Connection Config + * + * Custom MlModel Service connection to build a source that is not supported by OpenMetadata + * yet. + * + * SageMaker Connection Config + * + * Google VertexAI Connection Config + * + * S3 Connection. + * + * ADLS Connection. + * + * GCS Connection. + * + * Custom Storage Service connection to build a source that is not supported by OpenMetadata + * yet. + * + * ElasticSearch Connection. + * + * OpenSearch Connection. + * + * Custom Search Service connection to build a source that is not supported by OpenMetadata + * yet. + */ +export interface ConfigClass { + /** + * Open API Schema URL. + */ + openAPISchemaURL?: string; + /** + * Supports Metadata Extraction. + */ + supportsMetadataExtraction?: boolean; + /** + * Generated Token to connect to OpenAPI Schema. + * + * token to connect to Qlik Cloud. + * + * Generated Token to connect to Databricks. + * + * To Connect to Dagster Cloud + * + * Generated Token to connect to DBTCloud. + * + * Token to connect to Stitch api doc + */ + token?: string; + /** + * REST API Type + * + * Service Type + * + * Custom dashboard service type + * + * Custom database service type + * + * Custom messaging service type + * + * Custom pipeline service type + * + * Custom Ml model service type + * + * Custom storage service type + * + * ElasticSearch Type + * + * Custom search service type + */ + type?: RESTType; + /** + * User's Client ID. This user should have privileges to read all the metadata in Looker. + * + * client_id for PowerBI. + * + * Client ID for DOMO + * + * client_id for Sigma. + */ + clientId?: string; + /** + * User's Client Secret. + * + * clientSecret for PowerBI. + * + * clientSecret for Sigma. + */ + clientSecret?: string; + /** + * Credentials to extract the .lkml files from a repository. This is required to get all the + * lineage and definitions. + */ + gitCredentials?: GitHubCredentials; + /** + * URL to the Looker instance. + * + * Host and Port of the Metabase instance. + * + * Dashboard URL for PowerBI service. + * + * Dashboard URL for PowerBI Report Server. + * + * URL for the Redash instance + * + * URL for the superset instance. + * + * Tableau Server. + * + * URL for the mode instance. + * + * URL for the Qlik instance. + * + * Address for your running Lightdash instance + * + * Host and Port of the MicroStrategy instance. + * + * Host and Port of the Qlik Cloud instance. + * + * Sigma API url. + * + * BigQuery APIs URL. + * + * Host and port of the AzureSQL service. + * + * Host and port of the Clickhouse service. + * + * Host and port of the Databricks service. + * + * Host and port of the DB2 service. + * + * Host and port of the Druid service. + * + * Host and port of the Hive service. + * + * Host and port of the Impala service. + * + * Host and port of the MariaDB service. + * + * Host and port of the MSSQL service. + * + * Host and port of the MySQL service. + * + * Host and port of the SQLite service. Blank for in-memory database. + * + * Host and port of the Oracle service. + * + * Host and port of the source service. + * + * Host and port of the Presto service. + * + * Host and port of the Redshift service. + * + * Host and port of the SingleStore service. + * + * Host and port of the Trino service. + * + * Host and port of the Vertica service. + * + * Host and port of the PinotDB Broker service. + * + * Host and port of the MongoDB service when using the `mongodb` connection scheme. Only + * host when using the `mongodb+srv` scheme. + * + * Host and port of the Doris service. + * + * Host and port of the Teradata service. + * + * Host and Port of the SAP ERP instance. + * + * Host and port of the Azure Synapse service. + * + * Host and port of the Amundsen Neo4j Connection. This expect a URI format like: + * bolt://localhost:7687. + * + * OpenMetadata Server Config. Must include API end point ex: http://localhost:8585/api + * + * Host and port of the Atlas service. + * + * Host and port of the Alation service. + * + * Pipeline Service Management/UI URI. + * + * Pipeline Service Management/UI URL. + * + * Spline REST Server Host & Port. + * + * KafkaConnect Service Management/UI URI. + * + * Host and port of the Stitch API host + * + * Host and port of the ElasticSearch service. + * + * Host and port of the OpenSearch service. + */ + hostPort?: string; + /** + * Password to connect to Metabase. + * + * Password to connect to PowerBI report server. + * + * Password to connect to MicroStrategy. + * + * Password to connect to AzureSQL. + * + * Password to connect to Clickhouse. + * + * Password to connect to DB2. + * + * Password to connect to Druid. + * + * Password to connect to Hive. + * + * Password to connect to Impala. + * + * Password to connect to MariaDB. + * + * Password to connect to MSSQL. + * + * Password to connect to SQLite. Blank for in-memory database. + * + * Password to connect to Oracle. + * + * Password to connect to Presto. + * + * Password to connect to Redshift. + * + * Password to connect to the Salesforce. + * + * Password to connect to SingleStore. + * + * Password to connect to Snowflake. + * + * Password to connect to Vertica. + * + * password to connect to the PinotDB. + * + * Password to connect to MongoDB. + * + * Password to connect to Couchbase. + * + * Password to connect to Doris. + * + * Password to connect to SAS Viya + * + * Password to connect to Teradata. + * + * Password to connect to Azure Synapse. + * + * Password to connect to Exasol. + * + * password to connect to the Amundsen Neo4j Connection. + * + * password to connect to the Atlas. + * + * Password to connect to Airbyte. + * + * OpenSearch Password for Login + */ + password?: string; + /** + * Username to connect to Metabase. This user should have privileges to read all the + * metadata in Metabase. + * + * Username to connect to PowerBI report server. + * + * Username for Redash + * + * Username to connect to MicroStrategy. This user should have privileges to read all the + * metadata in MicroStrategy. + * + * Username to connect to AzureSQL. This user should have privileges to read the metadata. + * + * Username to connect to Clickhouse. This user should have privileges to read all the + * metadata in Clickhouse. + * + * Username to connect to DB2. This user should have privileges to read all the metadata in + * DB2. + * + * Username to connect to Druid. This user should have privileges to read all the metadata + * in Druid. + * + * Username to connect to Hive. This user should have privileges to read all the metadata in + * Hive. + * + * Username to connect to Impala. This user should have privileges to read all the metadata + * in Impala. + * + * Username to connect to MariaDB. This user should have privileges to read all the metadata + * in MariaDB. + * + * Username to connect to MSSQL. This user should have privileges to read all the metadata + * in MsSQL. + * + * Username to connect to MySQL. This user should have privileges to read all the metadata + * in Mysql. + * + * Username to connect to SQLite. Blank for in-memory database. + * + * Username to connect to Oracle. This user should have privileges to read all the metadata + * in Oracle. + * + * Username to connect to Postgres. This user should have privileges to read all the + * metadata in Postgres. + * + * Username to connect to Presto. This user should have privileges to read all the metadata + * in Postgres. + * + * Username to connect to Redshift. This user should have privileges to read all the + * metadata in Redshift. + * + * Username to connect to the Salesforce. This user should have privileges to read all the + * metadata in Redshift. + * + * Username to connect to SingleStore. This user should have privileges to read all the + * metadata in MySQL. + * + * Username to connect to Snowflake. This user should have privileges to read all the + * metadata in Snowflake. + * + * Username to connect to Trino. This user should have privileges to read all the metadata + * in Trino. + * + * Username to connect to Vertica. This user should have privileges to read all the metadata + * in Vertica. + * + * username to connect to the PinotDB. This user should have privileges to read all the + * metadata in PinotDB. + * + * Username to connect to MongoDB. This user should have privileges to read all the metadata + * in MongoDB. + * + * Username to connect to Couchbase. This user should have privileges to read all the + * metadata in Couchbase. + * + * Username to connect to Greenplum. This user should have privileges to read all the + * metadata in Greenplum. + * + * Username to connect to Doris. This user should have privileges to read all the metadata + * in Doris. + * + * Username to connect to SAS Viya. + * + * Username to connect to Teradata. This user should have privileges to read all the + * metadata in Teradata. + * + * Username to connect to Azure Synapse. This user should have privileges to read all the + * metadata in Azure Synapse. + * + * Username to connect to Exasol. This user should have privileges to read all the metadata + * in Exasol. + * + * username to connect to the Amundsen Neo4j Connection. + * + * username to connect to the Atlas. This user should have privileges to read all the + * metadata in Atlas. + * + * Username to connect to Airbyte. + * + * OpenSearch Username for Login + */ + username?: string; + /** + * Authority URI for the PowerBI service. + */ + authorityURI?: string; + /** + * Entity Limit set here will be used to paginate the PowerBi APIs + */ + pagination_entity_per_page?: number; + /** + * Source to get the .pbit files to extract lineage information + */ + pbitFilesSource?: PowerBIPbitFilesSource; + /** + * PowerBI secrets. + */ + scope?: string[]; + /** + * Tenant ID for PowerBI. + */ + tenantId?: string; + /** + * Fetch the PowerBI metadata using admin APIs + */ + useAdminApis?: boolean; + /** + * Web Portal Virtual Directory Name. + */ + webPortalVirtualDirectory?: string; + /** + * API key of the redash instance to access. + * + * The personal access token you can generate in the Lightdash app under the user settings + * + * API key to authenticate with the SAP ERP APIs. + * + * Fivetran API Secret. + */ + apiKey?: string; + /** + * Version of the Redash instance + */ + redashVersion?: string; + /** + * Choose between API or database connection fetch metadata from superset. + * + * Choose between Database connection or HDB User Store connection. + * + * Choose between mysql and postgres connection for alation database + * + * Underlying database connection. See + * https://airflow.apache.org/docs/apache-airflow/stable/howto/set-up-database.html for + * supported backends. + * + * Matillion Auth Configuration + */ + connection?: ConnectionObject; + /** + * Tableau API version. + * + * Sigma API version. + * + * OpenMetadata server API version to use. + */ + apiVersion?: string; + /** + * Types of methods used to authenticate to the tableau instance + * + * Choose Auth Config Type. + * + * Types of methods used to authenticate to the alation instance + */ + authType?: AuthenticationTypeForTableau | NoConfigAuthenticationTypes; + /** + * Tableau Environment Name. + */ + env?: string; + /** + * Pagination limit used while querying the tableau metadata API for getting data sources + * + * Pagination limit used while querying the SAP ERP API for fetching the entities + * + * Pagination limit used for Alation APIs pagination + */ + paginationLimit?: number; + /** + * Tableau Site Name. + */ + siteName?: string; + /** + * Tableau Site Url. + */ + siteUrl?: string; + /** + * SSL Configuration details. + * + * SSL Configuration for OpenMetadata Server + */ + sslConfig?: SSLConfigObject; + /** + * Flag to verify SSL Certificate for OpenMetadata Server. + * + * Boolean marking if we need to verify the SSL certs for KafkaConnect REST API. True by + * default. + */ + verifySSL?: boolean | VerifySSL; + /** + * Access Token for Mode Dashboard + * + * Access token to connect to DOMO + */ + accessToken?: string; + /** + * Access Token Password for Mode Dashboard + */ + accessTokenPassword?: string; + /** + * Filter query parameter for some of the Mode API calls + */ + filterQueryParam?: string; + /** + * Mode Workspace Name + */ + workspaceName?: string; + connectionOptions?: { [key: string]: string }; + /** + * Source Python Class Name to instantiated by the ingestion workflow + */ + sourcePythonClass?: string; + /** + * API Host to connect to DOMO instance + */ + apiHost?: string; + /** + * URL of your Domo instance, e.g., https://openmetadata.domo.com + */ + instanceDomain?: string; + /** + * Secret Token to connect DOMO + * + * Secret token to connect to DOMO + */ + secretToken?: string; + /** + * AWS Account ID + */ + awsAccountId?: string; + awsConfig?: AWSCredentials; + /** + * The authentication method that the user uses to sign in. + */ + identityType?: IdentityType; + /** + * The Amazon QuickSight namespace that contains the dashboard IDs in this request ( To be + * provided when identityType is `ANONYMOUS` ) + */ + namespace?: string; + certificates?: QlikCertificatesBy; + /** + * Qlik Sense Base URL, used for genrating dashboard & chat url + */ + displayUrl?: string; + /** + * User Directory. + */ + userDirectory?: string; + /** + * User ID. + */ + userId?: string; + /** + * Validate Host Name + */ + validateHostName?: boolean; + /** + * The Project UUID for your Lightdash instance + */ + projectUUID?: string; + /** + * Use if your Lightdash instance is behind a proxy like (Cloud IAP) + */ + proxyAuthentication?: string; + /** + * The Space UUID for your Lightdash instance + */ + spaceUUID?: string; + /** + * Login Mode for Microstrategy's REST API connection. You can authenticate with one of the + * following authentication modes: `Standard (1)`, `Anonymous (8)`. Default will be + * `Standard (1)`. If you're using demo account for Microstrategy, it will be needed to + * authenticate through loginMode `8`. + */ + loginMode?: string; + /** + * MicroStrategy Project Name + * + * Project name to create the refreshToken. Can be anything + */ + projectName?: string; + /** + * If using Metastore, Key-Value pairs that will be used to add configs to the SparkSession. + */ + connectionArguments?: { [key: string]: any }; + /** + * GCP Credentials + * + * Azure Credentials + */ + credentials?: GCPCredentials; + sampleDataStorageConfig?: SampleDataStorageConfig; + /** + * SQLAlchemy driver scheme options. + * + * Mongo connection scheme options. + * + * Couchbase driver scheme options. + * + * Http/Https connection scheme + */ + scheme?: string; + supportsDatabase?: boolean; + supportsDataDiff?: boolean; + supportsDBTExtraction?: boolean; + /** + * Supports Lineage Extraction. + */ + supportsLineageExtraction?: boolean; + supportsProfiler?: boolean; + supportsQueryComment?: boolean; + supportsSystemProfile?: boolean; + /** + * Supports Usage Extraction. + */ + supportsUsageExtraction?: boolean; + /** + * Taxonomy location used to fetch policy tags + */ + taxonomyLocation?: string; + /** + * Project IDs used to fetch policy tags + */ + taxonomyProjectID?: string[]; + /** + * Location used to query INFORMATION_SCHEMA.JOBS_BY_PROJECT to fetch usage data. You can + * pass multi-regions, such as `us` or `eu`, or you specific region. Australia and Asia + * multi-regions are not yet in GA. + */ + usageLocation?: string; + /** + * Optional name to give to the database in OpenMetadata. If left blank, we will use default + * as the database name. + */ + databaseName?: string; + /** + * S3 Staging Directory. Example: s3://postgres/input/ + */ + s3StagingDir?: string; + /** + * Athena workgroup. + */ + workgroup?: string; + /** + * This parameter determines the mode of authentication for connecting to AzureSQL using + * ODBC. If 'Active Directory Password' is selected, you need to provide the password. If + * 'Active Directory Integrated' is selected, password is not required as it uses the + * logged-in user's credentials. This mode is useful for establishing secure and seamless + * connections with AzureSQL. + * + * This parameter determines the mode of authentication for connecting to Azure Synapse + * using ODBC. If 'Active Directory Password' is selected, you need to provide the password. + * If 'Active Directory Integrated' is selected, password is not required as it uses the + * logged-in user's credentials. This mode is useful for establishing secure and seamless + * connections with Azure Synapse. + */ + authenticationMode?: any[] | boolean | number | null | AuthenticationModeObject | string; + /** + * Database of the data source. This is optional parameter, if you would like to restrict + * the metadata reading to a single database. When left blank, OpenMetadata Ingestion + * attempts to scan all the databases. + * + * Database of the data source. + * + * Initial Redshift database to connect to. If you want to ingest all databases, set + * ingestAllDatabases to true. + */ + database?: string; + /** + * SQLAlchemy driver for AzureSQL. + * + * ODBC driver version in case of pyodbc connection. + */ + driver?: string; + /** + * Ingest data from all databases in Azuresql. You can use databaseFilterPattern on top of + * this. + * + * Ingest data from all databases in Mssql. You can use databaseFilterPattern on top of + * this. + * + * Ingest data from all databases in Postgres. You can use databaseFilterPattern on top of + * this. + * + * Ingest data from all databases in Redshift. You can use databaseFilterPattern on top of + * this. + * + * Ingest data from all databases in Greenplum. You can use databaseFilterPattern on top of + * this. + * + * Ingest data from all databases in Azure Synapse. You can use databaseFilterPattern on top + * of this. + */ + ingestAllDatabases?: boolean; + /** + * Database Schema of the data source. This is optional parameter, if you would like to + * restrict the metadata reading to a single schema. When left blank, OpenMetadata Ingestion + * attempts to scan all the schemas. + * + * databaseSchema of the data source. This is optional parameter, if you would like to + * restrict the metadata reading to a single databaseSchema. When left blank, OpenMetadata + * Ingestion attempts to scan all the databaseSchema. + * + * Optional name to give to the schema in OpenMetadata. If left blank, we will use default + * as the schema name + */ + databaseSchema?: string; + /** + * Clickhouse SQL connection duration. + */ + duration?: number; + /** + * Use HTTPS Protocol for connection with clickhouse + */ + https?: boolean; + /** + * Path to key file for establishing secure connection + */ + keyfile?: string; + /** + * Establish secure connection with clickhouse + */ + secure?: boolean; + /** + * Catalog of the data source(Example: hive_metastore). This is optional parameter, if you + * would like to restrict the metadata reading to a single catalog. When left blank, + * OpenMetadata Ingestion attempts to scan all the catalog. + * + * Presto catalog + * + * Catalog of the data source. + */ + catalog?: IcebergCatalog | string; + /** + * The maximum amount of time (in seconds) to wait for a successful connection to the data + * source. If the connection attempt takes longer than this timeout period, an error will be + * returned. + */ + connectionTimeout?: number; + /** + * Databricks compute resources URL. + */ + httpPath?: string; + /** + * License to connect to DB2. + */ + license?: string; + /** + * License file name to connect to DB2. + */ + licenseFileName?: string; + supportsViewLineageExtraction?: boolean; + /** + * Available sources to fetch the metadata. + * + * Available sources to fetch files. + * + * Available sources to fetch metadata. + */ + configSource?: DeltaLakeConfigurationSource; + /** + * Authentication mode to connect to hive. + */ + auth?: AuthEnum; + /** + * Authentication options to pass to Hive connector. These options are based on SQLAlchemy. + * + * Authentication options to pass to Impala connector. These options are based on SQLAlchemy. + */ + authOptions?: string; + /** + * If authenticating with Kerberos specify the Kerberos service name + */ + kerberosServiceName?: string; + /** + * Hive Metastore Connection Details + */ + metastoreConnection?: HiveMetastoreConnectionDetails; + /** + * Authentication mode to connect to Impala. + */ + authMechanism?: AuthMechanismEnum; + /** + * Establish secure connection with Impala + */ + useSSL?: boolean; + /** + * How to run the SQLite database. :memory: by default. + */ + databaseMode?: string; + /** + * This directory will be used to set the LD_LIBRARY_PATH env variable. It is required if + * you need to enable thick connection mode. By default, we bring instant client 19 and + * point to /instantclient. + */ + instantClientDirectory?: string; + /** + * Connect with oracle by either passing service name or database schema name. + */ + oracleConnectionType?: OracleConnectionType; + /** + * Custom OpenMetadata Classification name for Postgres policy tags. + */ + classificationName?: string; + sslMode?: SSLMode; + /** + * Protocol ( Connection Argument ) to connect to Presto. + */ + protocol?: string; + /** + * Verify ( Connection Argument for SSL ) to connect to Presto. + * + * Verify ( Connection Argument for SSL ) to connect to Trino. + */ + verify?: string; + /** + * Salesforce Organization ID is the unique identifier for your Salesforce identity + */ + organizationId?: string; + /** + * API version of the Salesforce instance + */ + salesforceApiVersion?: string; + /** + * Domain of Salesforce instance + */ + salesforceDomain?: string; + /** + * Salesforce Security Token. + */ + securityToken?: string; + /** + * Salesforce Object Name. + */ + sobjectName?: string; + /** + * If the Snowflake URL is https://xyz1234.us-east-1.gcp.snowflakecomputing.com, then the + * account is xyz1234.us-east-1.gcp + * + * Specifies an account string to override the default account string defined for the + * database user. Accounts are used by the database for workload management and resource + * usage monitoring. + */ + account?: string; + /** + * Optional configuration for ingestion to keep the client session active in case the + * ingestion process runs for longer durations. + */ + clientSessionKeepAlive?: boolean; + /** + * Optional configuration for ingestion of TRANSIENT tables, By default, it will skip the + * TRANSIENT tables. + */ + includeTransientTables?: boolean; + /** + * Connection to Snowflake instance via Private Key + */ + privateKey?: string; + /** + * Session query tag used to monitor usage on snowflake. To use a query tag snowflake user + * should have enough privileges to alter the session. + */ + queryTag?: string; + /** + * Snowflake Role. + */ + role?: string; + /** + * Snowflake Passphrase Key used with Private Key + */ + snowflakePrivatekeyPassphrase?: string; + /** + * Snowflake warehouse. + */ + warehouse?: string; + /** + * Proxies for the connection to Trino data source + */ + proxies?: { [key: string]: string }; + /** + * Pinot Controller Host and Port of the data source. + */ + pinotControllerHost?: string; + /** + * Bucket Name of the data source. + */ + bucketName?: string; + /** + * Prefix of the data source. + */ + prefix?: string; + /** + * Couchbase connection Bucket options. + */ + bucket?: string; + /** + * Hostname of the Couchbase service. + */ + hostport?: string; + /** + * Enable dataflow for ingestion + */ + dataflows?: boolean; + /** + * Custom filter for dataflows + */ + dataflowsCustomFilter?: { [key: string]: any } | string; + /** + * Enable datatables for ingestion + */ + datatables?: boolean; + /** + * Custom filter for datatables + */ + dataTablesCustomFilter?: { [key: string]: any } | string; + /** + * Enable report for ingestion + */ + reports?: boolean; + /** + * Custom filter for reports + */ + reportsCustomFilter?: { [key: string]: any } | string; + /** + * Hostname of SAS Viya deployment. + */ + serverHost?: string; + /** + * Table property to look for the Owner. + */ + ownershipProperty?: string; + /** + * Specifies additional data needed by a logon mechanism, such as a secure token, + * Distinguished Name, or a domain/realm name. LOGDATA values are specific to each logon + * mechanism. + */ + logdata?: string; + /** + * Specifies the logon authentication method. Possible values are TD2 (the default), JWT, + * LDAP, KRB5 for Kerberos, or TDNEGO + */ + logmech?: Logmech; + /** + * Specifies the transaction mode for the connection + */ + tmode?: TransactionMode; + /** + * Client SSL/TLS settings. + */ + tls?: SSLTLSSettings; + /** + * basic.auth.user.info schema registry config property, Client HTTP credentials in the form + * of username:password. + */ + basicAuthUserInfo?: string; + /** + * Kafka bootstrap servers. add them in comma separated values ex: host1:9092,host2:9092 + * + * Redpanda bootstrap servers. add them in comma separated values ex: host1:9092,host2:9092 + */ + bootstrapServers?: string; + /** + * Confluent Kafka Consumer Config. From + * https://github.com/edenhill/librdkafka/blob/master/CONFIGURATION.md + * + * Confluent Redpanda Consumer Config + */ + consumerConfig?: { [key: string]: any }; + /** + * sasl.mechanism Consumer Config property + */ + saslMechanism?: SaslMechanismType; + /** + * sasl.password consumer config property + */ + saslPassword?: string; + /** + * sasl.username consumer config property + */ + saslUsername?: string; + /** + * Confluent Kafka Schema Registry Config. From + * https://docs.confluent.io/5.5.1/clients/confluent-kafka-python/index.html#confluent_kafka.schema_registry.SchemaRegistryClient + * + * Confluent Redpanda Schema Registry Config. + */ + schemaRegistryConfig?: { [key: string]: any }; + /** + * Schema Registry SSL Config. Configuration for enabling SSL for the Schema Registry + * connection. + */ + schemaRegistrySSL?: SchemaRegistrySSLClass; + /** + * Schema Registry Topic Suffix Name. The suffix to be appended to the topic name to get + * topic schema from registry. + */ + schemaRegistryTopicSuffixName?: string; + /** + * Confluent Kafka Schema Registry URL. + * + * Confluent Redpanda Schema Registry URL. + */ + schemaRegistryURL?: string; + /** + * security.protocol consumer config property + * + * Kafka security protocol config + */ + securityProtocol?: KafkaSecurityProtocol; + /** + * Enable encryption for the Amundsen Neo4j Connection. + */ + encrypted?: boolean; + /** + * Maximum connection lifetime for the Amundsen Neo4j Connection. + */ + maxConnectionLifeTime?: number; + /** + * Enable SSL validation for the Amundsen Neo4j Connection. + */ + validateSSL?: boolean; + /** + * Maximum number of events sent in a batch (Default 100). + */ + batchSize?: number; + /** + * List of entities that you need to reindex + */ + entities?: string[]; + recreateIndex?: boolean; + runMode?: RunMode; + /** + * Recreate Indexes with updated Language + */ + searchIndexMappingLanguage?: SearchIndexMappingLanguage; + /** + * OpenMetadata Server Authentication Provider. + */ + authProvider?: AuthProvider; + /** + * Cluster name to differentiate OpenMetadata Server instance + */ + clusterName?: string; + /** + * Configuration for Sink Component in the OpenMetadata Ingestion Framework. + */ + elasticsSearch?: ElasticsSearch; + /** + * Validate Openmetadata Server & Client Version. + */ + enableVersionValidation?: boolean; + extraHeaders?: { [key: string]: string }; + /** + * Force the overwriting of any entity during the ingestion. + */ + forceEntityOverwriting?: boolean; + /** + * Include Dashboards for Indexing + */ + includeDashboards?: boolean; + /** + * Include Database Services for Indexing + */ + includeDatabaseServices?: boolean; + /** + * Include Glossary Terms for Indexing + */ + includeGlossaryTerms?: boolean; + /** + * Include Messaging Services for Indexing + */ + includeMessagingServices?: boolean; + /** + * Include MlModels for Indexing + */ + includeMlModels?: boolean; + /** + * Include Pipelines for Indexing + */ + includePipelines?: boolean; + /** + * Include Pipeline Services for Indexing + */ + includePipelineServices?: boolean; + /** + * Include Tags for Policy + */ + includePolicy?: boolean; + /** + * Include Tables for Indexing + */ + includeTables?: boolean; + /** + * Include Tags for Indexing + */ + includeTags?: boolean; + /** + * Include Teams for Indexing + */ + includeTeams?: boolean; + /** + * Include Topics for Indexing + */ + includeTopics?: boolean; + /** + * Include Users for Indexing + */ + includeUsers?: boolean; + /** + * Limit the number of records for Indexing. + */ + limitRecords?: number; + /** + * Secrets Manager Loader for the Pipeline Service Client. + */ + secretsManagerLoader?: SecretsManagerClientLoader; + /** + * Secrets Manager Provider for OpenMetadata Server. + */ + secretsManagerProvider?: SecretsManagerProvider; + /** + * OpenMetadata Client security configuration. + */ + securityConfig?: OpenMetadataJWTClientConfig; + /** + * If set to true, when creating a service during the ingestion we will store its Service + * Connection. Otherwise, the ingestion will create a bare service without connection + * details. + */ + storeServiceConnection?: boolean; + /** + * Flag to enable Data Insight Extraction + */ + supportsDataInsightExtraction?: boolean; + /** + * Flag to enable ElasticSearch Reindexing Extraction + */ + supportsElasticSearchReindexingExtraction?: boolean; + /** + * service type of the data source. + */ + databaseServiceName?: string[]; + /** + * Name of the Entity Type available in Atlas. + */ + entity_type?: string; + /** + * service type of the messaging source + * + * Name of the Kafka Messaging Service associated with this KafkaConnect Pipeline Service. + * e.g. local_kafka + */ + messagingServiceName?: string[] | string; + /** + * Custom OpenMetadata Classification name for alation tags. + */ + alationTagClassificationName?: string; + /** + * Specifies if hidden datasources should be included while ingesting. + */ + includeHiddenDatasources?: boolean; + /** + * Specifies if undeployed datasources should be included while ingesting. + */ + includeUndeployedDatasources?: boolean; + /** + * Specifies if Dashboards are to be ingested while running the ingestion job. + */ + ingestDashboards?: boolean; + /** + * Specifies if Datasources are to be ingested while running the ingestion job. + */ + ingestDatasources?: boolean; + /** + * Specifies if Domains are to be ingested while running the ingestion job. + */ + ingestDomains?: boolean; + /** + * Specifies if Knowledge Articles are to be ingested while running the ingestion job. + */ + ingestKnowledgeArticles?: boolean; + /** + * Specifies if Users and Groups are to be ingested while running the ingestion job. + */ + ingestUsersAndGroups?: boolean; + datasourceLinks?: { [key: string]: string }; + /** + * Pipeline Service Number Of Status + */ + numberOfStatus?: number; + /** + * Fivetran API Secret. + */ + apiSecret?: string; + /** + * Fivetran API Limit For Pagination. + */ + limit?: number; + /** + * URL to the Dagster instance + * + * DBT cloud Access URL. + */ + host?: string; + /** + * Connection Time Limit Between OM and Dagster Graphql API in second + */ + timeout?: number; + /** + * We support username/password or client certificate authentication + */ + nifiConfig?: NifiCredentialsConfiguration; + /** + * Spline UI Host & Port. + */ + uiHostPort?: string; + /** + * service type of the messaging source + */ + brokersUrl?: string; + /** + * consumer group name + */ + consumerGroupName?: string; + /** + * initial Kafka consumer offset + */ + consumerOffsets?: InitialConsumerOffsets; + /** + * max allowed wait time + */ + poolTimeout?: number; + /** + * SASL Configuration details. + */ + saslConfig?: SASLClientConfig; + /** + * max allowed inactivity time + */ + sessionTimeout?: number; + /** + * topic from where Open lineage events will be pulled + */ + topicName?: string; + /** + * We support username/password or No Authentication + */ + KafkaConnectConfig?: UsernamePasswordAuthentication; + /** + * ID of your DBT cloud account + */ + accountId?: string; + /** + * DBT cloud Metadata API URL. + */ + discoveryAPI?: string; + /** + * List of IDs of your DBT cloud jobs seperated by comma `,` + */ + jobIds?: string[]; + /** + * List of IDs of your DBT cloud projects seperated by comma `,` + */ + projectIds?: string[]; + /** + * The name of your azure data factory. + */ + factory_name?: string; + /** + * The name of your resource group the data factory is associated with. + */ + resource_group_name?: string; + /** + * Number of days in the past to filter pipeline runs. + */ + run_filter_days?: number; + /** + * The azure subscription identifier. + */ + subscription_id?: string; + /** + * Mlflow Model registry backend. E.g., + * mysql+pymysql://mlflow:password@localhost:3307/experiments + */ + registryUri?: string; + /** + * Mlflow Experiment tracking URI. E.g., http://localhost:5000 + */ + trackingUri?: string; + /** + * location/region of google cloud project + */ + location?: string; + /** + * Bucket Names of the data source. + */ + bucketNames?: string[]; + /** + * Connection Timeout in Seconds + */ + connectionTimeoutSecs?: number; + /** + * Keep Alive Timeout in Seconds + */ + keepAliveTimeoutSecs?: number; + /** + * Socket Timeout in Seconds + */ + socketTimeoutSecs?: number; + /** + * Truststore Password + */ + truststorePassword?: string; + /** + * Truststore Path + */ + truststorePath?: string; +} + +/** + * We support username/password or No Authentication + * + * username/password auth + */ +export interface UsernamePasswordAuthentication { + /** + * KafkaConnect password to authenticate to the API. + */ + password?: string; + /** + * KafkaConnect user to authenticate to the API. + */ + username?: string; +} + +/** + * Authentication mode to connect to hive. + */ +export enum AuthEnum { + Basic = "BASIC", + Custom = "CUSTOM", + Gssapi = "GSSAPI", + Jwt = "JWT", + Kerberos = "KERBEROS", + LDAP = "LDAP", + None = "NONE", + Nosasl = "NOSASL", + Plain = "PLAIN", +} + +/** + * Authentication mode to connect to Impala. + */ +export enum AuthMechanismEnum { + Gssapi = "GSSAPI", + Jwt = "JWT", + LDAP = "LDAP", + Nosasl = "NOSASL", + Plain = "PLAIN", +} + +/** + * OpenMetadata Server Authentication Provider. + * + * OpenMetadata Server Authentication Provider. Make sure configure same auth providers as + * the one configured on OpenMetadata server. + */ +export enum AuthProvider { + Auth0 = "auth0", + AwsCognito = "aws-cognito", + Azure = "azure", + Basic = "basic", + CustomOidc = "custom-oidc", + Google = "google", + LDAP = "ldap", + Okta = "okta", + Openmetadata = "openmetadata", + Saml = "saml", +} + +/** + * Types of methods used to authenticate to the tableau instance + * + * Basic Auth Credentials + * + * Access Token Auth Credentials + * + * Choose Auth Config Type. + * + * Common Database Connection Config + * + * IAM Auth Database Connection Config + * + * Azure Database Connection Config + * + * Types of methods used to authenticate to the alation instance + * + * API Access Token Auth Credentials + * + * Basic Auth Configuration for ElasticSearch + * + * SSL Certificates By Path + */ +export interface AuthenticationTypeForTableau { + /** + * Password to access the service. + * + * Password to connect to source. + * + * Elastic Search Password for Login + */ + password?: string; + /** + * Username to access the service. + * + * Elastic Search Username for Login + */ + username?: string; + /** + * Personal Access Token Name. + */ + personalAccessTokenName?: string; + /** + * Personal Access Token Secret. + */ + personalAccessTokenSecret?: string; + awsConfig?: AWSCredentials; + azureConfig?: AzureCredentials; + /** + * JWT to connect to source. + */ + jwt?: string; + /** + * Access Token for the API + */ + accessToken?: string; + /** + * CA Certificate Path + */ + caCertPath?: string; + /** + * Client Certificate Path + */ + clientCertPath?: string; + /** + * Private Key Path + */ + privateKeyPath?: string; +} + +/** + * AWS credentials configs. + */ +export interface AWSCredentials { + /** + * The Amazon Resource Name (ARN) of the role to assume. Required Field in case of Assume + * Role + */ + assumeRoleArn?: string; + /** + * An identifier for the assumed role session. Use the role session name to uniquely + * identify a session when the same role is assumed by different principals or for different + * reasons. Required Field in case of Assume Role + */ + assumeRoleSessionName?: string; + /** + * The Amazon Resource Name (ARN) of the role to assume. Optional Field in case of Assume + * Role + */ + assumeRoleSourceIdentity?: string; + /** + * AWS Access key ID. + */ + awsAccessKeyId?: string; + /** + * AWS Region + */ + awsRegion: string; + /** + * AWS Secret Access Key. + */ + awsSecretAccessKey?: string; + /** + * AWS Session Token. + */ + awsSessionToken?: string; + /** + * EndPoint URL for the AWS + */ + endPointURL?: string; + /** + * The name of a profile to use with the boto session. + */ + profileName?: string; +} + +/** + * Azure Cloud Credentials + * + * Available sources to fetch metadata. + * + * Azure Credentials + */ +export interface AzureCredentials { + /** + * Account Name of your storage account + */ + accountName?: string; + /** + * Your Service Principal App ID (Client ID) + */ + clientId?: string; + /** + * Your Service Principal Password (Client Secret) + */ + clientSecret?: string; + /** + * Scopes to get access token, for e.g. api://6dfX33ab-XXXX-49df-XXXX-3459eX817d3e/.default + */ + scopes?: string; + /** + * Tenant ID of your Azure Subscription + */ + tenantId?: string; + /** + * Key Vault Name + */ + vaultName?: string; +} + +/** + * Database Authentication types not requiring config. + */ +export enum NoConfigAuthenticationTypes { + OAuth2 = "OAuth2", +} + +export interface AuthenticationModeObject { + /** + * Authentication from Connection String for AzureSQL. + * + * Authentication from Connection String for Azure Synapse. + */ + authentication?: Authentication; + /** + * Connection Timeout from Connection String for AzureSQL. + * + * Connection Timeout from Connection String for Azure Synapse. + */ + connectionTimeout?: number; + /** + * Encrypt from Connection String for AzureSQL. + * + * Encrypt from Connection String for Azure Synapse. + */ + encrypt?: boolean; + /** + * Trust Server Certificate from Connection String for AzureSQL. + * + * Trust Server Certificate from Connection String for Azure Synapse. + */ + trustServerCertificate?: boolean; + [property: string]: any; +} + +/** + * Authentication from Connection String for AzureSQL. + * + * Authentication from Connection String for Azure Synapse. + */ +export enum Authentication { + ActiveDirectoryIntegrated = "ActiveDirectoryIntegrated", + ActiveDirectoryPassword = "ActiveDirectoryPassword", +} + +/** + * Iceberg Catalog configuration. + */ +export interface IcebergCatalog { + /** + * Catalog connection configuration, depending on your catalog type. + */ + connection: Connection; + /** + * Custom Database Name for your Iceberg Service. If not set it will be 'default'. + */ + databaseName?: string; + /** + * Catalog Name. + */ + name: string; + /** + * Warehouse Location. Used to specify a custom warehouse location if needed. + */ + warehouseLocation?: string; +} + +/** + * Catalog connection configuration, depending on your catalog type. + * + * Iceberg Hive Catalog configuration. + * + * Iceberg REST Catalog configuration. + * + * Iceberg Glue Catalog configuration. + * + * Iceberg DynamoDB Catalog configuration. + */ +export interface Connection { + fileSystem?: IcebergFileSystem; + /** + * Uri to the Hive Metastore. Example: 'thrift://localhost:9083' + * + * Uri to the REST catalog. Example: 'http://rest-catalog/ws/' + */ + uri?: string; + /** + * OAuth2 credential to use when initializing the catalog. + */ + credential?: OAuth2Credential; + /** + * Sign requests to the REST Server using AWS SigV4 protocol. + */ + sigv4?: Sigv4; + /** + * SSL Configuration details. + */ + ssl?: SSLCertificatesByPath; + /** + * Berarer token to use for the 'Authorization' header. + */ + token?: string; + awsConfig?: AWSCredentials; + /** + * DynamoDB table name. + */ + tableName?: string; +} + +/** + * OAuth2 credential to use when initializing the catalog. + */ +export interface OAuth2Credential { + /** + * OAuth2 Client ID. + */ + clientId?: string; + /** + * OAuth2 Client Secret + */ + clientSecret?: string; +} + +/** + * Iceberg File System configuration, based on where the Iceberg Warehouse is located. + */ +export interface IcebergFileSystem { + type?: Credentials | null; +} + +/** + * AWS credentials configs. + * + * Azure Cloud Credentials + * + * Available sources to fetch metadata. + * + * Azure Credentials + */ +export interface Credentials { + /** + * The Amazon Resource Name (ARN) of the role to assume. Required Field in case of Assume + * Role + */ + assumeRoleArn?: string; + /** + * An identifier for the assumed role session. Use the role session name to uniquely + * identify a session when the same role is assumed by different principals or for different + * reasons. Required Field in case of Assume Role + */ + assumeRoleSessionName?: string; + /** + * The Amazon Resource Name (ARN) of the role to assume. Optional Field in case of Assume + * Role + */ + assumeRoleSourceIdentity?: string; + /** + * AWS Access key ID. + */ + awsAccessKeyId?: string; + /** + * AWS Region + */ + awsRegion?: string; + /** + * AWS Secret Access Key. + */ + awsSecretAccessKey?: string; + /** + * AWS Session Token. + */ + awsSessionToken?: string; + /** + * EndPoint URL for the AWS + */ + endPointURL?: string; + /** + * The name of a profile to use with the boto session. + */ + profileName?: string; + /** + * Account Name of your storage account + */ + accountName?: string; + /** + * Your Service Principal App ID (Client ID) + */ + clientId?: string; + /** + * Your Service Principal Password (Client Secret) + */ + clientSecret?: string; + /** + * Scopes to get access token, for e.g. api://6dfX33ab-XXXX-49df-XXXX-3459eX817d3e/.default + */ + scopes?: string; + /** + * Tenant ID of your Azure Subscription + */ + tenantId?: string; + /** + * Key Vault Name + */ + vaultName?: string; +} + +/** + * Sign requests to the REST Server using AWS SigV4 protocol. + */ +export interface Sigv4 { + /** + * The service signing name to use when SigV4 signs a request. + */ + signingName?: string; + /** + * AWS Region to use when SigV4 signs a request. + */ + signingRegion?: string; + [property: string]: any; +} + +/** + * SSL Configuration details. + * + * SSL Certificates By Path + */ +export interface SSLCertificatesByPath { + /** + * CA Certificate Path + */ + caCertPath?: string; + /** + * Client Certificate Path + */ + clientCertPath?: string; + /** + * Private Key Path + */ + privateKeyPath?: string; +} + +/** + * Qlik Authentication Certificate By Values + * + * Qlik Authentication Certificate File Path + */ +export interface QlikCertificatesBy { + sslConfig?: SchemaRegistrySSLClass; + /** + * Client Certificate + */ + clientCertificate?: string; + /** + * Client Key Certificate. + */ + clientKeyCertificate?: string; + /** + * Root Certificate. + */ + rootCertificate?: string; + [property: string]: any; +} + +/** + * Client SSL configuration + * + * SSL Configuration details. + * + * Schema Registry SSL Config. Configuration for enabling SSL for the Schema Registry + * connection. + * + * SSL Configuration for OpenMetadata Server + * + * OpenMetadata Client configured to validate SSL certificates. + */ +export interface SchemaRegistrySSLClass { + /** + * The CA certificate used for SSL validation. + */ + caCertificate?: string; + /** + * The SSL certificate used for client authentication. + */ + sslCertificate?: string; + /** + * The private key associated with the SSL certificate. + */ + sslKey?: string; +} + +/** + * Available sources to fetch the metadata. + * + * Deltalake Metastore configuration. + * + * DeltaLake Storage Connection Config + * + * Available sources to fetch files. + * + * Local config source where no extra information needs to be sent. + * + * Azure Datalake Storage will ingest files in container + * + * DataLake GCS storage will ingest metadata of files + * + * DataLake S3 bucket will ingest metadata of files in bucket + * + * Azure Cloud Credentials + * + * Available sources to fetch metadata. + * + * Azure Credentials + */ +export interface DeltaLakeConfigurationSource { + /** + * pySpark App Name. + */ + appName?: string; + /** + * Metastore connection configuration, depending on your metastore type. + * + * Available sources to fetch files. + */ + connection?: ConnectionClass; + /** + * Bucket Name of the data source. + */ + bucketName?: string; + /** + * Prefix of the data source. + */ + prefix?: string; + securityConfig?: SecurityConfigClass; + /** + * Account Name of your storage account + */ + accountName?: string; + /** + * Your Service Principal App ID (Client ID) + */ + clientId?: string; + /** + * Your Service Principal Password (Client Secret) + */ + clientSecret?: string; + /** + * Scopes to get access token, for e.g. api://6dfX33ab-XXXX-49df-XXXX-3459eX817d3e/.default + */ + scopes?: string; + /** + * Tenant ID of your Azure Subscription + */ + tenantId?: string; + /** + * Key Vault Name + */ + vaultName?: string; +} + +/** + * Metastore connection configuration, depending on your metastore type. + * + * Available sources to fetch files. + * + * DataLake S3 bucket will ingest metadata of files in bucket + */ +export interface ConnectionClass { + /** + * Thrift connection to the metastore service. E.g., localhost:9083 + */ + metastoreHostPort?: string; + /** + * Driver class name for JDBC metastore. The value will be mapped as + * spark.hadoop.javax.jdo.option.ConnectionDriverName sparks property. E.g., + * org.mariadb.jdbc.Driver + */ + driverName?: string; + /** + * Class path to JDBC driver required for JDBC connection. The value will be mapped as + * spark.driver.extraClassPath sparks property. + */ + jdbcDriverClassPath?: string; + /** + * JDBC connection to the metastore database. E.g., jdbc:mysql://localhost:3306/demo_hive + */ + metastoreDb?: string; + /** + * Password to use against metastore database. The value will be mapped as + * spark.hadoop.javax.jdo.option.ConnectionPassword sparks property. + */ + password?: string; + /** + * Username to use against metastore database. The value will be mapped as + * spark.hadoop.javax.jdo.option.ConnectionUserName sparks property. + */ + username?: string; + /** + * Local path for the local file with metastore data. E.g., /tmp/metastore.db + */ + metastoreFilePath?: string; + securityConfig?: AWSCredentials; +} + +/** + * Azure Cloud Credentials + * + * Available sources to fetch metadata. + * + * Azure Credentials + * + * GCP credentials configs. + * + * GCP Credentials + * + * AWS credentials configs. + */ +export interface SecurityConfigClass { + /** + * Account Name of your storage account + */ + accountName?: string; + /** + * Your Service Principal App ID (Client ID) + */ + clientId?: string; + /** + * Your Service Principal Password (Client Secret) + */ + clientSecret?: string; + /** + * Scopes to get access token, for e.g. api://6dfX33ab-XXXX-49df-XXXX-3459eX817d3e/.default + */ + scopes?: string; + /** + * Tenant ID of your Azure Subscription + */ + tenantId?: string; + /** + * Key Vault Name + */ + vaultName?: string; + /** + * We support two ways of authenticating to GCP i.e via GCP Credentials Values or GCP + * Credentials Path + */ + gcpConfig?: GCPCredentialsConfiguration; + /** + * we enable the authenticated service account to impersonate another service account + */ + gcpImpersonateServiceAccount?: GCPImpersonateServiceAccountValues; + /** + * The Amazon Resource Name (ARN) of the role to assume. Required Field in case of Assume + * Role + */ + assumeRoleArn?: string; + /** + * An identifier for the assumed role session. Use the role session name to uniquely + * identify a session when the same role is assumed by different principals or for different + * reasons. Required Field in case of Assume Role + */ + assumeRoleSessionName?: string; + /** + * The Amazon Resource Name (ARN) of the role to assume. Optional Field in case of Assume + * Role + */ + assumeRoleSourceIdentity?: string; + /** + * AWS Access key ID. + */ + awsAccessKeyId?: string; + /** + * AWS Region + */ + awsRegion?: string; + /** + * AWS Secret Access Key. + */ + awsSecretAccessKey?: string; + /** + * AWS Session Token. + */ + awsSessionToken?: string; + /** + * EndPoint URL for the AWS + */ + endPointURL?: string; + /** + * The name of a profile to use with the boto session. + */ + profileName?: string; +} + +/** + * We support two ways of authenticating to GCP i.e via GCP Credentials Values or GCP + * Credentials Path + * + * Pass the raw credential values provided by GCP + * + * Pass the path of file containing the GCP credentials info + */ +export interface GCPCredentialsConfiguration { + /** + * Google Cloud auth provider certificate. + */ + authProviderX509CertUrl?: string; + /** + * Google Cloud auth uri. + */ + authUri?: string; + /** + * Google Cloud email. + */ + clientEmail?: string; + /** + * Google Cloud Client ID. + */ + clientId?: string; + /** + * Google Cloud client certificate uri. + */ + clientX509CertUrl?: string; + /** + * Google Cloud private key. + */ + privateKey?: string; + /** + * Google Cloud private key id. + */ + privateKeyId?: string; + /** + * Project ID + * + * GCP Project ID to parse metadata from + */ + projectId?: string[] | string; + /** + * Google Cloud token uri. + */ + tokenUri?: string; + /** + * Google Cloud Platform account type. + */ + type?: string; + /** + * Path of the file containing the GCP credentials info + */ + path?: string; + /** + * Google Security Token Service audience which contains the resource name for the workload + * identity pool and the provider identifier in that pool. + */ + audience?: string; + /** + * This object defines the mechanism used to retrieve the external credential from the local + * environment so that it can be exchanged for a GCP access token via the STS endpoint + */ + credentialSource?: { [key: string]: string }; + /** + * Google Cloud Platform account type. + */ + externalType?: string; + /** + * Google Security Token Service subject token type based on the OAuth 2.0 token exchange + * spec. + */ + subjectTokenType?: string; + /** + * Google Security Token Service token exchange endpoint. + */ + tokenURL?: string; + [property: string]: any; +} + +/** + * we enable the authenticated service account to impersonate another service account + * + * Pass the values to impersonate a service account of Google Cloud + */ +export interface GCPImpersonateServiceAccountValues { + /** + * The impersonated service account email + */ + impersonateServiceAccount?: string; + /** + * Number of seconds the delegated credential should be valid + */ + lifetime?: number; + [property: string]: any; +} + +/** + * Choose between API or database connection fetch metadata from superset. + * + * Superset API Connection Config + * + * Postgres Database Connection Config + * + * Mysql Database Connection Config + * + * Choose between Database connection or HDB User Store connection. + * + * Sap Hana Database SQL Connection Config + * + * Sap Hana Database HDB User Store Connection Config + * + * Choose between mysql and postgres connection for alation database + * + * Underlying database connection. See + * https://airflow.apache.org/docs/apache-airflow/stable/howto/set-up-database.html for + * supported backends. + * + * Lineage Backend Connection Config + * + * SQLite Database Connection Config + * + * Matillion Auth Configuration + * + * Matillion ETL Auth Config + */ +export interface ConnectionObject { + /** + * Password for Superset. + * + * Password to connect to Hana. + * + * Password to connect to SQLite. Blank for in-memory database. + * + * Password to connect to the Matillion. + */ + password?: string; + /** + * Authentication provider for the Superset service. For basic user/password authentication, + * the default value `db` can be used. This parameter is used internally to connect to + * Superset's REST API. + */ + provider?: Provider; + /** + * SSL Configuration details. + */ + sslConfig?: ConnectionSSLConfig; + /** + * Username for Superset. + * + * Username to connect to Postgres. This user should have privileges to read all the + * metadata in Postgres. + * + * Username to connect to MySQL. This user should have privileges to read all the metadata + * in Mysql. + * + * Username to connect to Hana. This user should have privileges to read all the metadata. + * + * Username to connect to SQLite. Blank for in-memory database. + * + * Username to connect to the Matillion. This user should have privileges to read all the + * metadata in Matillion. + */ + username?: string; + verifySSL?: VerifySSL; + /** + * Choose Auth Config Type. + */ + authType?: AuthConfigurationType; + /** + * Custom OpenMetadata Classification name for Postgres policy tags. + */ + classificationName?: string; + connectionArguments?: { [key: string]: any }; + connectionOptions?: { [key: string]: string }; + /** + * Database of the data source. This is optional parameter, if you would like to restrict + * the metadata reading to a single database. When left blank, OpenMetadata Ingestion + * attempts to scan all the databases. + * + * Database of the data source. + */ + database?: string; + /** + * Host and port of the source service. + * + * Host and port of the MySQL service. + * + * Host and port of the Hana service. + * + * Host and port of the SQLite service. Blank for in-memory database. + * + * Matillion Host + */ + hostPort?: string; + /** + * Ingest data from all databases in Postgres. You can use databaseFilterPattern on top of + * this. + */ + ingestAllDatabases?: boolean; + sampleDataStorageConfig?: SampleDataStorageConfig; + /** + * SQLAlchemy driver scheme options. + */ + scheme?: ConnectionScheme; + sslMode?: SSLMode; + supportsDatabase?: boolean; + supportsDataDiff?: boolean; + supportsDBTExtraction?: boolean; + supportsLineageExtraction?: boolean; + supportsMetadataExtraction?: boolean; + supportsProfiler?: boolean; + supportsQueryComment?: boolean; + supportsUsageExtraction?: boolean; + /** + * Service Type + */ + type?: ConnectionType; + /** + * Optional name to give to the database in OpenMetadata. If left blank, we will use default + * as the database name. + */ + databaseName?: string; + /** + * Database Schema of the data source. This is optional parameter, if you would like to + * restrict the metadata reading to a single schema. When left blank, OpenMetadata Ingestion + * attempts to scan all the schemas. + * + * Database Schema of the data source. This is an optional parameter, if you would like to + * restrict the metadata reading to a single schema. When left blank, OpenMetadata Ingestion + * attempts to scan all the schemas. + */ + databaseSchema?: string; + /** + * HDB Store User Key generated from the command `hdbuserstore SET + * ` + */ + userKey?: string; + /** + * How to run the SQLite database. :memory: by default. + */ + databaseMode?: string; + supportsViewLineageExtraction?: boolean; + [property: string]: any; +} + +/** + * Choose Auth Config Type. + * + * Common Database Connection Config + * + * IAM Auth Database Connection Config + * + * Azure Database Connection Config + */ +export interface AuthConfigurationType { + /** + * Password to connect to source. + */ + password?: string; + awsConfig?: AWSCredentials; + azureConfig?: AzureCredentials; +} + +/** + * Authentication provider for the Superset service. For basic user/password authentication, + * the default value `db` can be used. This parameter is used internally to connect to + * Superset's REST API. + */ +export enum Provider { + DB = "db", + LDAP = "ldap", +} + +/** + * Storage config to store sample data + */ +export interface SampleDataStorageConfig { + config?: DataStorageConfig; +} + +/** + * Storage config to store sample data + */ +export interface DataStorageConfig { + /** + * Bucket Name + */ + bucketName?: string; + /** + * Provide the pattern of the path where the generated sample data file needs to be stored. + */ + filePathPattern?: string; + /** + * When this field enabled a single parquet file will be created to store sample data, + * otherwise we will create a new file per day + */ + overwriteData?: boolean; + /** + * Prefix of the data source. + */ + prefix?: string; + storageConfig?: AwsCredentials; + [property: string]: any; +} + +/** + * AWS credentials configs. + */ +export interface AwsCredentials { + /** + * The Amazon Resource Name (ARN) of the role to assume. Required Field in case of Assume + * Role + */ + assumeRoleArn?: string; + /** + * An identifier for the assumed role session. Use the role session name to uniquely + * identify a session when the same role is assumed by different principals or for different + * reasons. Required Field in case of Assume Role + */ + assumeRoleSessionName?: string; + /** + * The Amazon Resource Name (ARN) of the role to assume. Optional Field in case of Assume + * Role + */ + assumeRoleSourceIdentity?: string; + /** + * AWS Access key ID. + */ + awsAccessKeyId?: string; + /** + * AWS Region + */ + awsRegion?: string; + /** + * AWS Secret Access Key. + */ + awsSecretAccessKey?: string; + /** + * AWS Session Token. + */ + awsSessionToken?: string; + /** + * EndPoint URL for the AWS + */ + endPointURL?: string; + /** + * The name of a profile to use with the boto session. + */ + profileName?: string; +} + +/** + * SQLAlchemy driver scheme options. + */ +export enum ConnectionScheme { + MysqlPymysql = "mysql+pymysql", + PgspiderPsycopg2 = "pgspider+psycopg2", + PostgresqlPsycopg2 = "postgresql+psycopg2", + SqlitePysqlite = "sqlite+pysqlite", +} + +/** + * Client SSL configuration + * + * OpenMetadata Client configured to validate SSL certificates. + * + * SSL Configuration details. + * + * Schema Registry SSL Config. Configuration for enabling SSL for the Schema Registry + * connection. + * + * SSL Configuration for OpenMetadata Server + */ +export interface ConnectionSSLConfig { + /** + * The CA certificate used for SSL validation. + */ + caCertificate?: string; + /** + * The SSL certificate used for client authentication. + */ + sslCertificate?: string; + /** + * The private key associated with the SSL certificate. + */ + sslKey?: string; +} + +/** + * SSL Mode to connect to database. + */ +export enum SSLMode { + Allow = "allow", + Disable = "disable", + Prefer = "prefer", + Require = "require", + VerifyCA = "verify-ca", + VerifyFull = "verify-full", +} + +/** + * Service Type + * + * Service type. + */ +export enum ConnectionType { + Backend = "Backend", + MatillionETL = "MatillionETL", + Mysql = "Mysql", + Postgres = "Postgres", + SQLite = "SQLite", +} + +/** + * Client SSL verification. Make sure to configure the SSLConfig if enabled. + * + * Flag to verify SSL Certificate for OpenMetadata Server. + */ +export enum VerifySSL { + Ignore = "ignore", + NoSSL = "no-ssl", + Validate = "validate", +} + +/** + * initial Kafka consumer offset + */ +export enum InitialConsumerOffsets { + Earliest = "earliest", + Latest = "latest", +} + +/** + * GCP credentials configs. + * + * GCP Credentials + * + * Azure Cloud Credentials + * + * Available sources to fetch metadata. + * + * Azure Credentials + */ +export interface GCPCredentials { + /** + * We support two ways of authenticating to GCP i.e via GCP Credentials Values or GCP + * Credentials Path + */ + gcpConfig?: GCPCredentialsConfiguration; + /** + * we enable the authenticated service account to impersonate another service account + */ + gcpImpersonateServiceAccount?: GCPImpersonateServiceAccountValues; + /** + * Account Name of your storage account + */ + accountName?: string; + /** + * Your Service Principal App ID (Client ID) + */ + clientId?: string; + /** + * Your Service Principal Password (Client Secret) + */ + clientSecret?: string; + /** + * Scopes to get access token, for e.g. api://6dfX33ab-XXXX-49df-XXXX-3459eX817d3e/.default + */ + scopes?: string; + /** + * Tenant ID of your Azure Subscription + */ + tenantId?: string; + /** + * Key Vault Name + */ + vaultName?: string; +} + +/** + * Configuration for Sink Component in the OpenMetadata Ingestion Framework. + */ +export interface ElasticsSearch { + config?: { [key: string]: any }; + /** + * Type of sink component ex: metadata + */ + type: string; +} + +/** + * Credentials to extract the .lkml files from a repository. This is required to get all the + * lineage and definitions. + * + * Do not set any credentials. Note that credentials are required to extract .lkml views and + * their lineage. + * + * Credentials for a GitHub repository + * + * Credentials for a BitBucket repository + * + * Credentials for a Gitlab repository + */ +export interface GitHubCredentials { + repositoryName?: string; + repositoryOwner?: string; + token?: string; + /** + * Credentials Type + */ + type?: GitHubCredentialsType; + /** + * Main production branch of the repository. E.g., `main` + */ + branch?: string; +} + +/** + * Credentials Type + * + * GitHub Credentials type + * + * BitBucket Credentials type + * + * Gitlab Credentials type + */ +export enum GitHubCredentialsType { + BitBucket = "BitBucket", + GitHub = "GitHub", + Gitlab = "Gitlab", +} + +/** + * The authentication method that the user uses to sign in. + */ +export enum IdentityType { + Anonymous = "ANONYMOUS", + Iam = "IAM", + Quicksight = "QUICKSIGHT", +} + +/** + * Specifies the logon authentication method. Possible values are TD2 (the default), JWT, + * LDAP, KRB5 for Kerberos, or TDNEGO + */ +export enum Logmech { + Custom = "CUSTOM", + Jwt = "JWT", + Krb5 = "KRB5", + LDAP = "LDAP", + Td2 = "TD2", + Tdnego = "TDNEGO", +} + +/** + * Hive Metastore Connection Details + * + * Postgres Database Connection Config + * + * Mysql Database Connection Config + */ +export interface HiveMetastoreConnectionDetails { + /** + * Choose Auth Config Type. + */ + authType?: AuthConfigurationType; + /** + * Custom OpenMetadata Classification name for Postgres policy tags. + */ + classificationName?: string; + connectionArguments?: { [key: string]: any }; + connectionOptions?: { [key: string]: string }; + /** + * Database of the data source. This is optional parameter, if you would like to restrict + * the metadata reading to a single database. When left blank, OpenMetadata Ingestion + * attempts to scan all the databases. + */ + database?: string; + /** + * Host and port of the source service. + * + * Host and port of the MySQL service. + */ + hostPort?: string; + /** + * Ingest data from all databases in Postgres. You can use databaseFilterPattern on top of + * this. + */ + ingestAllDatabases?: boolean; + sampleDataStorageConfig?: SampleDataStorageConfig; + /** + * SQLAlchemy driver scheme options. + */ + scheme?: HiveMetastoreConnectionDetailsScheme; + /** + * SSL Configuration details. + */ + sslConfig?: SchemaRegistrySSLClass; + sslMode?: SSLMode; + supportsDatabase?: boolean; + supportsDataDiff?: boolean; + supportsDBTExtraction?: boolean; + supportsLineageExtraction?: boolean; + supportsMetadataExtraction?: boolean; + supportsProfiler?: boolean; + supportsQueryComment?: boolean; + supportsUsageExtraction?: boolean; + /** + * Service Type + */ + type?: HiveMetastoreConnectionDetailsType; + /** + * Username to connect to Postgres. This user should have privileges to read all the + * metadata in Postgres. + * + * Username to connect to MySQL. This user should have privileges to read all the metadata + * in Mysql. + */ + username?: string; + /** + * Optional name to give to the database in OpenMetadata. If left blank, we will use default + * as the database name. + */ + databaseName?: string; + /** + * Database Schema of the data source. This is optional parameter, if you would like to + * restrict the metadata reading to a single schema. When left blank, OpenMetadata Ingestion + * attempts to scan all the schemas. + */ + databaseSchema?: string; +} + +/** + * SQLAlchemy driver scheme options. + */ +export enum HiveMetastoreConnectionDetailsScheme { + MysqlPymysql = "mysql+pymysql", + PgspiderPsycopg2 = "pgspider+psycopg2", + PostgresqlPsycopg2 = "postgresql+psycopg2", +} + +/** + * Service Type + * + * Service type. + */ +export enum HiveMetastoreConnectionDetailsType { + Mysql = "Mysql", + Postgres = "Postgres", +} + +/** + * We support username/password or client certificate authentication + * + * username/password auth + * + * client certificate auth + */ +export interface NifiCredentialsConfiguration { + /** + * Nifi password to authenticate to the API. + */ + password?: string; + /** + * Nifi user to authenticate to the API. + */ + username?: string; + /** + * Boolean marking if we need to verify the SSL certs for Nifi. False by default. + */ + verifySSL?: boolean; + /** + * Path to the root CA certificate + */ + certificateAuthorityPath?: string; + /** + * Path to the client certificate + */ + clientCertificatePath?: string; + /** + * Path to the client key + */ + clientkeyPath?: string; +} + +/** + * Connect with oracle by either passing service name or database schema name. + */ +export interface OracleConnectionType { + /** + * databaseSchema of the data source. This is optional parameter, if you would like to + * restrict the metadata reading to a single databaseSchema. When left blank, OpenMetadata + * Ingestion attempts to scan all the databaseSchema. + */ + databaseSchema?: string; + /** + * The Oracle Service name is the TNS alias that you give when you remotely connect to your + * database. + */ + oracleServiceName?: string; + /** + * Pass the full constructed TNS string, e.g., + * (DESCRIPTION=(ADDRESS_LIST=(ADDRESS=(PROTOCOL=TCP)(HOST=myhost)(PORT=1530)))(CONNECT_DATA=(SID=MYSERVICENAME))). + */ + oracleTNSConnection?: string; + [property: string]: any; +} + +/** + * Source to get the .pbit files to extract lineage information + * + * Local config source where no extra information needs to be sent. + * + * Azure storage config for pbit files + * + * GCS storage config for pbit files + * + * S3 storage config for pbit files + */ +export interface PowerBIPbitFilesSource { + /** + * Directory path for the pbit files + */ + path?: string; + /** + * pbit File Configuration type + */ + pbitFileConfigType?: PbitFileConfigType; + /** + * Path of the folder where the .pbit files will be unzipped and datamodel schema will be + * extracted + */ + pbitFilesExtractDir?: string; + prefixConfig?: BucketDetails; + securityConfig?: SecurityConfigClass; +} + +/** + * pbit File Configuration type + */ +export enum PbitFileConfigType { + Azure = "azure", + Gcs = "gcs", + Local = "local", + S3 = "s3", +} + +/** + * Details of the bucket where the .pbit files are stored + */ +export interface BucketDetails { + /** + * Name of the bucket where the .pbit files are stored + */ + bucketName?: string; + /** + * Path of the folder where the .pbit files are stored + */ + objectPrefix?: string; +} + +/** + * This schema publisher run modes. + */ +export enum RunMode { + Batch = "batch", + Stream = "stream", +} + +/** + * SASL Configuration details. + * + * SASL client configuration. + */ +export interface SASLClientConfig { + /** + * SASL security mechanism + */ + saslMechanism?: SaslMechanismType; + /** + * The SASL authentication password. + */ + saslPassword?: string; + /** + * The SASL authentication username. + */ + saslUsername?: string; +} + +/** + * sasl.mechanism Consumer Config property + * + * SASL Mechanism consumer config property + * + * SASL security mechanism + */ +export enum SaslMechanismType { + Gssapi = "GSSAPI", + Oauthbearer = "OAUTHBEARER", + Plain = "PLAIN", + ScramSHA256 = "SCRAM-SHA-256", + ScramSHA512 = "SCRAM-SHA-512", +} + +/** + * Recreate Indexes with updated Language + * + * This schema defines the language options available for search index mappings. + */ +export enum SearchIndexMappingLanguage { + En = "EN", + Jp = "JP", + Zh = "ZH", +} + +/** + * Secrets Manager Loader for the Pipeline Service Client. + * + * OpenMetadata Secrets Manager Client Loader. Lets the client know how the Secrets Manager + * Credentials should be loaded from the environment. + */ +export enum SecretsManagerClientLoader { + Airflow = "airflow", + Env = "env", + Noop = "noop", +} + +/** + * Secrets Manager Provider for OpenMetadata Server. + * + * OpenMetadata Secrets Manager Provider. Make sure to configure the same secrets manager + * providers as the ones configured on the OpenMetadata server. + */ +export enum SecretsManagerProvider { + Aws = "aws", + AwsSsm = "aws-ssm", + AzureKv = "azure-kv", + DB = "db", + Gcp = "gcp", + InMemory = "in-memory", + ManagedAws = "managed-aws", + ManagedAwsSsm = "managed-aws-ssm", + ManagedAzureKv = "managed-azure-kv", +} + +/** + * OpenMetadata Client security configuration. + * + * openMetadataJWTClientConfig security configs. + */ +export interface OpenMetadataJWTClientConfig { + /** + * OpenMetadata generated JWT token. + */ + jwtToken: string; +} + +/** + * security.protocol consumer config property + * + * Kafka security protocol config + */ +export enum KafkaSecurityProtocol { + Plaintext = "PLAINTEXT", + SSL = "SSL", + SaslPlaintext = "SASL_PLAINTEXT", + SaslSSL = "SASL_SSL", +} + +/** + * Client SSL configuration + * + * SSL Configuration details. + * + * Schema Registry SSL Config. Configuration for enabling SSL for the Schema Registry + * connection. + * + * SSL Configuration for OpenMetadata Server + * + * OpenMetadata Client configured to validate SSL certificates. + * + * SSL Config + */ +export interface SSLConfigObject { + /** + * The CA certificate used for SSL validation. + */ + caCertificate?: string; + /** + * The SSL certificate used for client authentication. + */ + sslCertificate?: string; + /** + * The private key associated with the SSL certificate. + */ + sslKey?: string; + /** + * SSL Certificates + */ + certificates?: SSLCertificates; + [property: string]: any; +} + +/** + * SSL Certificates + * + * SSL Configuration details. + * + * SSL Certificates By Path + * + * SSL Certificates By Values + */ +export interface SSLCertificates { + /** + * CA Certificate Path + */ + caCertPath?: string; + /** + * Client Certificate Path + */ + clientCertPath?: string; + /** + * Private Key Path + */ + privateKeyPath?: string; + /** + * CA Certificate Value + */ + caCertValue?: string; + /** + * Client Certificate Value + */ + clientCertValue?: string; + /** + * Private Key Value + */ + privateKeyValue?: string; + /** + * Staging Directory Path + */ + stagingDir?: string; +} + +/** + * Client SSL/TLS settings. + */ +export enum SSLTLSSettings { + DisableTLS = "disable-tls", + IgnoreCertificate = "ignore-certificate", + ValidateCertificate = "validate-certificate", +} + +/** + * Specifies the transaction mode for the connection + */ +export enum TransactionMode { + ANSI = "ANSI", + Default = "DEFAULT", + Tera = "TERA", +} + +/** + * REST API Type + * + * REST API type + * + * Service Type + * + * Looker service type + * + * Metabase service type + * + * PowerBI service type + * + * PowerBIReportServer service type + * + * Redash service type + * + * Superset service type + * + * Tableau service type + * + * Mode service type + * + * Custom dashboard service type + * + * service type + * + * QuickSight service type + * + * Qlik sense service type + * + * Lightdash service type + * + * MicroStrategy service type + * + * Qlik Cloud service type + * + * Sigma service type + * + * Service type. + * + * Custom database service type + * + * Kafka service type + * + * Redpanda service type + * + * Custom messaging service type + * + * Amundsen service type + * + * Metadata to Elastic Search type + * + * OpenMetadata service type + * + * Custom pipeline service type + * + * Custom Ml model service type + * + * S3 service type + * + * ADLS service type + * + * Gcs service type + * + * Custom storage service type + * + * ElasticSearch Type + * + * ElasticSearch service type + * + * OpenSearch service type + * + * Custom search service type + */ +export enum RESTType { + Adls = "ADLS", + Airbyte = "Airbyte", + Airflow = "Airflow", + Alation = "Alation", + AlationSink = "AlationSink", + Amundsen = "Amundsen", + Athena = "Athena", + Atlas = "Atlas", + AzureSQL = "AzureSQL", + BigQuery = "BigQuery", + BigTable = "BigTable", + Clickhouse = "Clickhouse", + Couchbase = "Couchbase", + CustomDashboard = "CustomDashboard", + CustomDatabase = "CustomDatabase", + CustomMessaging = "CustomMessaging", + CustomMlModel = "CustomMlModel", + CustomPipeline = "CustomPipeline", + CustomSearch = "CustomSearch", + CustomStorage = "CustomStorage", + DBTCloud = "DBTCloud", + Dagster = "Dagster", + DataFactory = "DataFactory", + Databricks = "Databricks", + DatabricksPipeline = "DatabricksPipeline", + Datalake = "Datalake", + Db2 = "Db2", + DeltaLake = "DeltaLake", + DomoDashboard = "DomoDashboard", + DomoDatabase = "DomoDatabase", + DomoPipeline = "DomoPipeline", + Doris = "Doris", + Druid = "Druid", + DynamoDB = "DynamoDB", + ElasticSearch = "ElasticSearch", + Exasol = "Exasol", + Fivetran = "Fivetran", + Flink = "Flink", + Gcs = "GCS", + Glue = "Glue", + GluePipeline = "GluePipeline", + Greenplum = "Greenplum", + Hive = "Hive", + Iceberg = "Iceberg", + Impala = "Impala", + Kafka = "Kafka", + KafkaConnect = "KafkaConnect", + Kinesis = "Kinesis", + Lightdash = "Lightdash", + Looker = "Looker", + MariaDB = "MariaDB", + Matillion = "Matillion", + Metabase = "Metabase", + MetadataES = "MetadataES", + MicroStrategy = "MicroStrategy", + Mlflow = "Mlflow", + Mode = "Mode", + MongoDB = "MongoDB", + Mssql = "Mssql", + Mysql = "Mysql", + Nifi = "Nifi", + OpenLineage = "OpenLineage", + OpenMetadata = "OpenMetadata", + OpenSearch = "OpenSearch", + Oracle = "Oracle", + PinotDB = "PinotDB", + Postgres = "Postgres", + PowerBI = "PowerBI", + PowerBIReportServer = "PowerBIReportServer", + Presto = "Presto", + QlikCloud = "QlikCloud", + QlikSense = "QlikSense", + QuickSight = "QuickSight", + REST = "Rest", + Redash = "Redash", + Redpanda = "Redpanda", + Redshift = "Redshift", + S3 = "S3", + SAS = "SAS", + SQLite = "SQLite", + SageMaker = "SageMaker", + Salesforce = "Salesforce", + SapERP = "SapErp", + SapHana = "SapHana", + Sigma = "Sigma", + SingleStore = "SingleStore", + Sklearn = "Sklearn", + Snowflake = "Snowflake", + Spark = "Spark", + Spline = "Spline", + Stitch = "Stitch", + Superset = "Superset", + Synapse = "Synapse", + Tableau = "Tableau", + Teradata = "Teradata", + Trino = "Trino", + UnityCatalog = "UnityCatalog", + VertexAI = "VertexAI", + Vertica = "Vertica", +} + /** * Pipeline type * diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/tests/testCase.ts b/openmetadata-ui/src/main/resources/ui/src/generated/tests/testCase.ts index 7c26892b2a45..2150e10481ee 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/tests/testCase.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/tests/testCase.ts @@ -1,5 +1,5 @@ /* - * Copyright 2024 Collate. + * Copyright 2025 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 @@ -10,9 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - - - /** +/** * Test case is a test definition to capture data quality tests against tables, columns, and * other data assets. */ @@ -98,9 +96,12 @@ export interface TestCase { */ testDefinition: EntityReference; /** - * Test Suite that this test case belongs to. + * Basic Test Suite that this test case belongs to. + */ + testSuite: EntityReference; + /** + * Basic and Logical Test Suites this test case belongs to */ - testSuite: EntityReference; testSuites?: TestSuite[]; /** * Last update time corresponding to the new version of the entity in Unix epoch time @@ -184,13 +185,13 @@ export interface FieldChange { * * Test definition that this test case is based on. * - * Test Suite that this test case belongs to. + * Basic Test Suite that this test case belongs to. + * + * Entity reference the test suite needs to execute the test against. Only applicable if the + * test suite is basic. * * Domain the test Suite belongs to. When not set, the test Suite inherits the domain from * the table it belongs to. - * - * Entity reference the test suite is executed against. Only applicable if the test suite is - * executable. */ export interface EntityReference { /** @@ -461,6 +462,16 @@ export interface TestResultValue { * data entities. */ export interface TestSuite { + /** + * Indicates if the test suite is basic, i.e., the parent suite of a test and linked to an + * entity. Set on the backend. + */ + basic?: boolean; + /** + * Entity reference the test suite needs to execute the test against. Only applicable if the + * test suite is basic. + */ + basicEntityReference?: EntityReference; /** * Change that lead to this version of the entity. */ @@ -486,15 +497,6 @@ export interface TestSuite { * the table it belongs to. */ domain?: EntityReference; - /** - * Indicates if the test suite is executable. Set on the backend. - */ - executable?: boolean; - /** - * Entity reference the test suite is executed against. Only applicable if the test suite is - * executable. - */ - executableEntityReference?: EntityReference; /** * FullyQualifiedName same as `name`. */ @@ -520,8 +522,7 @@ export interface TestSuite { */ owners?: EntityReference[]; /** - * References to pipelines deployed for this database service to extract metadata, usage, - * lineage etc.. + * References to pipelines deployed for this Test Suite to execute the tests. */ pipelines?: EntityReference[]; /** diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/tests/testSuite.ts b/openmetadata-ui/src/main/resources/ui/src/generated/tests/testSuite.ts index c80982499b21..ab6d7ed391d3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/tests/testSuite.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/tests/testSuite.ts @@ -1,5 +1,5 @@ /* - * Copyright 2024 Collate. + * Copyright 2025 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 @@ -10,13 +10,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - - - /** +/** * TestSuite is a set of test cases grouped together to capture data quality tests against * data entities. */ export interface TestSuite { + /** + * Indicates if the test suite is basic, i.e., the parent suite of a test and linked to an + * entity. Set on the backend. + */ + basic?: boolean; + /** + * Entity reference the test suite needs to execute the test against. Only applicable if the + * test suite is basic. + */ + basicEntityReference?: EntityReference; /** * Change that lead to this version of the entity. */ @@ -42,15 +50,6 @@ export interface TestSuite { * the table it belongs to. */ domain?: EntityReference; - /** - * Indicates if the test suite is executable. Set on the backend. - */ - executable?: boolean; - /** - * Entity reference the test suite is executed against. Only applicable if the test suite is - * executable. - */ - executableEntityReference?: EntityReference; /** * FullyQualifiedName same as `name`. */ @@ -76,8 +75,7 @@ export interface TestSuite { */ owners?: EntityReference[]; /** - * References to pipelines deployed for this database service to extract metadata, usage, - * lineage etc.. + * References to pipelines deployed for this Test Suite to execute the tests. */ pipelines?: EntityReference[]; /** @@ -118,65 +116,16 @@ export interface TestSuite { } /** - * Change that lead to this version of the entity. - * - * Description of the change. - */ -export interface ChangeDescription { - /** - * Names of fields added during the version changes. - */ - fieldsAdded?: FieldChange[]; - /** - * Fields deleted during the version changes with old value before deleted. - */ - fieldsDeleted?: FieldChange[]; - /** - * Fields modified during the version changes with old and new values. - */ - fieldsUpdated?: FieldChange[]; - /** - * When a change did not result in change, this could be same as the current version. - */ - previousVersion?: number; -} - -export interface FieldChange { - /** - * Name of the entity field that changed. - */ - name?: string; - /** - * New value of the field. Note that this is a JSON string and use the corresponding field - * type to deserialize it. - */ - newValue?: any; - /** - * Previous value of the field. Note that this is a JSON string and use the corresponding - * field type to deserialize it. - */ - oldValue?: any; -} - -/** - * TestSuite mock connection, since it needs to implement a Service. - */ -export interface TestSuiteConnection { - config?: null; - [property: string]: any; -} - -/** - * Domain the test Suite belongs to. When not set, the test Suite inherits the domain from - * the table it belongs to. + * Entity reference the test suite needs to execute the test against. Only applicable if the + * test suite is basic. * * This schema defines the EntityReference type used for referencing an entity. * EntityReference is used for capturing relationships from one entity to another. For * example, a table has an attribute called database of type EntityReference that captures * the relationship of a table `belongs to a` database. * - * Entity reference the test suite is executed against. Only applicable if the test suite is - * executable. + * Domain the test Suite belongs to. When not set, the test Suite inherits the domain from + * the table it belongs to. * * Owners of this TestCase definition. * @@ -228,6 +177,55 @@ export interface EntityReference { type: string; } +/** + * Change that lead to this version of the entity. + * + * Description of the change. + */ +export interface ChangeDescription { + /** + * Names of fields added during the version changes. + */ + fieldsAdded?: FieldChange[]; + /** + * Fields deleted during the version changes with old value before deleted. + */ + fieldsDeleted?: FieldChange[]; + /** + * Fields modified during the version changes with old and new values. + */ + fieldsUpdated?: FieldChange[]; + /** + * When a change did not result in change, this could be same as the current version. + */ + previousVersion?: number; +} + +export interface FieldChange { + /** + * Name of the entity field that changed. + */ + name?: string; + /** + * New value of the field. Note that this is a JSON string and use the corresponding field + * type to deserialize it. + */ + newValue?: any; + /** + * Previous value of the field. Note that this is a JSON string and use the corresponding + * field type to deserialize it. + */ + oldValue?: any; +} + +/** + * TestSuite mock connection, since it needs to implement a Service. + */ +export interface TestSuiteConnection { + config?: null; + [property: string]: any; +} + /** * Type of database service such as MySQL, BigQuery, Snowflake, Redshift, Postgres... */ diff --git a/openmetadata-ui/src/main/resources/ui/src/mocks/TestCase.mock.ts b/openmetadata-ui/src/main/resources/ui/src/mocks/TestCase.mock.ts index eed16b7475d3..ade3de89f141 100644 --- a/openmetadata-ui/src/main/resources/ui/src/mocks/TestCase.mock.ts +++ b/openmetadata-ui/src/main/resources/ui/src/mocks/TestCase.mock.ts @@ -39,7 +39,7 @@ export const MOCK_TEST_CASE_DATA = { name: 'sample_data.ecommerce_db.shopify.dim_address.testSuite', fullyQualifiedName: 'sample_data.ecommerce_db.shopify.dim_address.testSuite', - description: 'This is an executable test suite linked to an entity', + description: 'This is an basic test suite linked to an entity', deleted: false, href: 'http://localhost:8585/api/v1/dataQuality/testSuites/bce9b69f-125a-42a8-b06a-13bdbc049f8d', }, diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/IncidentManager/IncidentManagerDetailPage/IncidentManagerDetailPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/IncidentManager/IncidentManagerDetailPage/IncidentManagerDetailPage.test.tsx index 64c67e9a374e..2f6aed44f2f0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/IncidentManager/IncidentManagerDetailPage/IncidentManagerDetailPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/IncidentManager/IncidentManagerDetailPage/IncidentManagerDetailPage.test.tsx @@ -45,7 +45,7 @@ const mockTestCaseData = { name: 'sample_data.ecommerce_db.shopify.dim_address.testSuite', fullyQualifiedName: 'sample_data.ecommerce_db.shopify.dim_address.testSuite', - description: 'This is an executable test suite linked to an entity', + description: 'This is an basic test suite linked to an entity', deleted: false, href: 'http://localhost:8585/api/v1/dataQuality/testSuites/fe44ef1a-1b83-4872-bef6-fbd1885986b8', }, diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TestSuiteDetailsPage/TestSuiteDetailsPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TestSuiteDetailsPage/TestSuiteDetailsPage.component.tsx index 2c1cbec1e395..eae0a14d747e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TestSuiteDetailsPage/TestSuiteDetailsPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TestSuiteDetailsPage/TestSuiteDetailsPage.component.tsx @@ -11,7 +11,7 @@ * limitations under the License. */ -import { Button, Col, Modal, Row, Space } from 'antd'; +import { Button, Col, Modal, Row, Space, Tabs } from 'antd'; import { AxiosError } from 'axios'; import { compare } from 'fast-json-patch'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; @@ -30,6 +30,7 @@ import TitleBreadcrumb from '../../components/common/TitleBreadcrumb/TitleBreadc import { TitleBreadcrumbProps } from '../../components/common/TitleBreadcrumb/TitleBreadcrumb.interface'; import DataQualityTab from '../../components/Database/Profiler/DataQualityTab/DataQualityTab'; import { AddTestCaseList } from '../../components/DataQuality/AddTestCaseList/AddTestCaseList.component'; +import TestSuitePipelineTab from '../../components/DataQuality/TestSuite/TestSuitePipelineTab/TestSuitePipelineTab.component'; import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1'; import { INITIAL_PAGING_VALUE } from '../../constants/constants'; import { DEFAULT_SORT_ORDER } from '../../constants/profiler.constant'; @@ -39,7 +40,11 @@ import { ResourceEntity, } from '../../context/PermissionProvider/PermissionProvider.interface'; import { ACTION_TYPE, ERROR_PLACEHOLDER_TYPE } from '../../enums/common.enum'; -import { EntityType, TabSpecificField } from '../../enums/entity.enum'; +import { + EntityTabs, + EntityType, + TabSpecificField, +} from '../../enums/entity.enum'; import { TestCase } from '../../generated/tests/testCase'; import { TestSuite } from '../../generated/tests/testSuite'; import { Include } from '../../generated/type/include'; @@ -329,6 +334,48 @@ const TestSuiteDetailsPage = () => { [currentPage, paging, pageSize, handlePageSizeChange, handleTestCasePaging] ); + const tabs = useMemo( + () => [ + { + label: t('label.test-case-plural'), + key: EntityTabs.TEST_CASES, + children: ( + + ), + }, + { + label: t('label.pipeline-plural'), + key: EntityTabs.PIPELINE, + children: ( + + ), + }, + ], + [ + testSuite, + incidentUrlState, + isLoading, + isTestCaseLoading, + pagingData, + showPagination, + testCaseResult, + handleTestSuiteUpdate, + handleSortTestCase, + fetchTestCases, + ] + ); + if (isLoading) { return ; } @@ -398,18 +445,7 @@ const TestSuiteDetailsPage = () => { - + { @@ -78,12 +74,15 @@ const TestSuiteIngestionPage = () => { url: getDataQualityPagePath(), }, { - name: getEntityName(response.executableEntityReference), - url: getEntityDetailsPath( - EntityType.TABLE, - response.executableEntityReference?.fullyQualifiedName ?? '', - EntityTabs.PROFILER + name: getEntityName( + response.basic ? response.basicEntityReference : response ), + url: getTestSuiteDetailsPath({ + isExecutableTestSuite: response.basic, + fullyQualifiedName: response.basic + ? response.basicEntityReference?.fullyQualifiedName ?? '' + : response.fullyQualifiedName ?? '', + }), }, { name: `${ingestionFQN ? t('label.edit') : t('label.add')} ${t( diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/testAPI.ts b/openmetadata-ui/src/main/resources/ui/src/rest/testAPI.ts index 9294e3bfb4e6..e731a7611701 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/testAPI.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/testAPI.ts @@ -37,7 +37,7 @@ import { getEncodedFqn } from '../utils/StringsUtils'; import APIClient from './index'; export enum TestSuiteType { - executable = 'executable', + basic = 'basic', logical = 'logical', } export enum TestCaseType { @@ -321,7 +321,7 @@ export const createExecutableTestSuite = async (data: CreateTestSuite) => { const response = await APIClient.post< CreateTestSuite, AxiosResponse - >(`${testSuiteUrl}/executable`, data); + >(`${testSuiteUrl}/basic`, data); return response.data; }; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/EntityUtils.test.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/EntityUtils.test.tsx index b0e77f33a64c..a697153d6979 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/EntityUtils.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/EntityUtils.test.tsx @@ -92,17 +92,17 @@ describe('EntityUtils unit tests', () => { describe('getBreadcrumbForTestSuite', () => { const testSuiteData: TestSuite = { name: 'testSuite', - executableEntityReference: { + basicEntityReference: { fullyQualifiedName: 'test/testSuite', id: '123', type: 'testType', }, }; - it('should get breadcrumb if data is executable', () => { + it('should get breadcrumb if data is basic', () => { const result = getBreadcrumbForTestSuite({ ...testSuiteData, - executable: true, + basic: true, }); expect(result).toEqual([ @@ -111,7 +111,7 @@ describe('EntityUtils unit tests', () => { ]); }); - it('should get breadcrumb if data is not executable', () => { + it('should get breadcrumb if data is not basic', () => { const result = getBreadcrumbForTestSuite(testSuiteData); expect(result).toEqual([ 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 abc539c226a4..595e5ca6a1d8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/EntityUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/EntityUtils.tsx @@ -1924,12 +1924,12 @@ export const getBreadcrumbForTestCase = (entity: TestCase): TitleLink[] => [ ]; export const getBreadcrumbForTestSuite = (entity: TestSuite) => { - return entity.executable + return entity.basic ? [ { - name: getEntityName(entity.executableEntityReference), + name: getEntityName(entity.basicEntityReference), url: getEntityLinkFromType( - entity.executableEntityReference?.fullyQualifiedName ?? '', + entity.basicEntityReference?.fullyQualifiedName ?? '', EntityType.TABLE ), }, diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/LogsClassBase.ts b/openmetadata-ui/src/main/resources/ui/src/utils/LogsClassBase.ts index 6f975072204a..a738b29959bc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/LogsClassBase.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/LogsClassBase.ts @@ -12,11 +12,8 @@ */ import { isUndefined, startCase } from 'lodash'; -import { TableProfilerTab } from '../components/Database/Profiler/ProfilerDashboard/profilerDashboard.interface'; -import { getEntityDetailsPath } from '../constants/constants'; import { GlobalSettingOptions } from '../constants/GlobalSettings.constants'; import { OPEN_METADATA } from '../constants/service-guide.constant'; -import { EntityTabs, EntityType } from '../enums/entity.enum'; import { Pipeline } from '../generated/api/services/ingestionPipelines/createIngestionPipeline'; import { IngestionPipeline } from '../generated/entity/services/ingestionPipelines/ingestionPipeline'; import { DataQualityPageTabs } from '../pages/DataQuality/DataQualityPage.interface'; @@ -30,6 +27,7 @@ import { getLogEntityPath, getSettingPath, } from './RouterUtils'; +import { getTestSuiteDetailsPath } from './TestSuiteUtils'; class LogsClassBase { /** @@ -81,6 +79,11 @@ class LogsClassBase { } if (serviceType === 'testSuite') { + const isExecutableTestSuite = !isUndefined( + (ingestionDetails.sourceConfig.config as Pipeline) + ?.entityFullyQualifiedName + ); + return [ { name: startCase(serviceType), @@ -88,13 +91,14 @@ class LogsClassBase { }, { name: ingestionDetails.name, - url: - getEntityDetailsPath( - EntityType.TABLE, - (ingestionDetails.sourceConfig.config as Pipeline) - ?.entityFullyQualifiedName ?? '', - EntityTabs.PROFILER - ) + `?activeTab=${TableProfilerTab.DATA_QUALITY}`, + url: getTestSuiteDetailsPath({ + isExecutableTestSuite, + fullyQualifiedName: + (isExecutableTestSuite + ? (ingestionDetails.sourceConfig.config as Pipeline) + ?.entityFullyQualifiedName + : ingestionDetails.service?.fullyQualifiedName) ?? '', + }), }, { name: i18n.t('label.log-plural'), diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/SearchClassBase.ts b/openmetadata-ui/src/main/resources/ui/src/utils/SearchClassBase.ts index d14b26de5fd3..370a41f1edfc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/SearchClassBase.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/SearchClassBase.ts @@ -557,7 +557,7 @@ class SearchClassBase { ): string | { pathname: string } { if (entity.entityType === EntityType.TEST_SUITE) { return getTestSuiteDetailsPath({ - isExecutableTestSuite: (entity as TestSuite).executable, + isExecutableTestSuite: (entity as TestSuite).basic, fullyQualifiedName: entity.fullyQualifiedName ?? '', }); } From cd8f0d1e824ab59ec595e9fb1cac17ffbad69553 Mon Sep 17 00:00:00 2001 From: Pere Miquel Brull Date: Tue, 7 Jan 2025 23:35:13 +0100 Subject: [PATCH 061/294] FIX - Executable Test Suite cherry-pick --- .../resources/dqtests/TestSuiteMapper.java | 31 ------------------- .../resources/dqtests/TestSuiteResource.java | 12 +++---- 2 files changed, 5 insertions(+), 38 deletions(-) delete mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestSuiteMapper.java diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestSuiteMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestSuiteMapper.java deleted file mode 100644 index f5bb30c2f6e2..000000000000 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestSuiteMapper.java +++ /dev/null @@ -1,31 +0,0 @@ -package org.openmetadata.service.resources.dqtests; - -import org.openmetadata.schema.api.tests.CreateTestSuite; -import org.openmetadata.schema.entity.data.Table; -import org.openmetadata.schema.tests.TestSuite; -import org.openmetadata.schema.type.EntityReference; -import org.openmetadata.service.Entity; -import org.openmetadata.service.mapper.EntityMapper; - -public class TestSuiteMapper implements EntityMapper { - @Override - public TestSuite createToEntity(CreateTestSuite create, String user) { - TestSuite testSuite = - copy(new TestSuite(), create, user) - .withDescription(create.getDescription()) - .withDisplayName(create.getDisplayName()) - .withName(create.getName()); - if (create.getBasicEntityReference() != null) { - Table table = - Entity.getEntityByName(Entity.TABLE, create.getBasicEntityReference(), null, null); - EntityReference entityReference = - new EntityReference() - .withId(table.getId()) - .withFullyQualifiedName(table.getFullyQualifiedName()) - .withName(table.getName()) - .withType(Entity.TABLE); - testSuite.setBasicEntityReference(entityReference); - } - return testSuite; - } -} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestSuiteResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestSuiteResource.java index 11ea3bcda751..5797b1a31771 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestSuiteResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestSuiteResource.java @@ -582,8 +582,7 @@ public Response createExecutable( @Context SecurityContext securityContext, @Context HttpServletResponse response, @Valid CreateTestSuite create) { - TestSuite testSuite = - mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); + TestSuite testSuite = getTestSuite(create, securityContext.getUserPrincipal().getName()); testSuite.setBasic(true); // Set the deprecation header based on draft specification from IETF // https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-deprecation-header-02 @@ -723,8 +722,7 @@ public Response createOrUpdateBasic( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateTestSuite create) { - TestSuite testSuite = - mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); + TestSuite testSuite = getTestSuite(create, securityContext.getUserPrincipal().getName()); testSuite.setBasic(true); return createOrUpdate(uriInfo, securityContext, testSuite); } @@ -981,16 +979,16 @@ private TestSuite getTestSuite(CreateTestSuite create, String user) { .withDescription(create.getDescription()) .withDisplayName(create.getDisplayName()) .withName(create.getName()); - if (create.getExecutableEntityReference() != null) { + if (create.getBasicEntityReference() != null) { Table table = - Entity.getEntityByName(Entity.TABLE, create.getExecutableEntityReference(), null, null); + Entity.getEntityByName(Entity.TABLE, create.getBasicEntityReference(), null, null); EntityReference entityReference = new EntityReference() .withId(table.getId()) .withFullyQualifiedName(table.getFullyQualifiedName()) .withName(table.getName()) .withType(Entity.TABLE); - testSuite.setExecutableEntityReference(entityReference); + testSuite.setBasicEntityReference(entityReference); } return testSuite; } From 6a3f3d5232bbbbcf53adb0bd4f91ac76ef41c2e6 Mon Sep 17 00:00:00 2001 From: Aniket Katkar Date: Wed, 8 Jan 2025 06:57:25 +0530 Subject: [PATCH 062/294] Minor: Add missing asset specific filter options for automator filters (#19268) * Add missing filter options for glossary term, data model and stored procedure data assets. * make the handleDeleteSelection prop optional for PipelineActionsDropdown component * Fix the unit test (cherry picked from commit 6a285781caa75b0421b3d27571f34c5da4d1eeae) --- .../PipelineActions.interface.ts | 2 +- .../PipelineActionsDropdown.interface.ts | 2 +- .../PipelineActionsDropdown.tsx | 2 +- .../src/utils/AdvancedSearchClassBase.test.ts | 5 ++++- .../ui/src/utils/AdvancedSearchClassBase.ts | 20 ++++++++++++++++--- .../resources/ui/src/utils/EntityUtils.tsx | 3 +++ 6 files changed, 27 insertions(+), 7 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/PipelineActions/PipelineActions.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/PipelineActions/PipelineActions.interface.ts index 075361568eec..75a56df992da 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/PipelineActions/PipelineActions.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/PipelineActions/PipelineActions.interface.ts @@ -24,7 +24,7 @@ export interface PipelineActionsProps { serviceName?: string; deployIngestion?: (id: string, displayName: string) => Promise; triggerIngestion?: (id: string, displayName: string) => Promise; - handleDeleteSelection: (row: SelectedRowDetails) => void; + handleDeleteSelection?: (row: SelectedRowDetails) => void; handleEditClick?: (fqn: string) => void; handleEnableDisableIngestion?: (id: string) => Promise; handleIsConfirmationModalOpen: (value: boolean) => void; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/PipelineActions/PipelineActionsDropdown.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/PipelineActions/PipelineActionsDropdown.interface.ts index b38d19a6ba8a..d860ae282979 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/PipelineActions/PipelineActionsDropdown.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/PipelineActions/PipelineActionsDropdown.interface.ts @@ -24,7 +24,7 @@ export interface PipelineActionsDropdownProps { deployIngestion?: (id: string, displayName: string) => Promise; handleEditClick: ((fqn: string) => void) | undefined; ingestionPipelinePermissions?: IngestionServicePermission; - handleDeleteSelection: (row: SelectedRowDetails) => void; + handleDeleteSelection?: (row: SelectedRowDetails) => void; handleIsConfirmationModalOpen: (value: boolean) => void; onIngestionWorkflowsUpdate?: () => void; moreActionButtonProps?: ButtonProps; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/PipelineActions/PipelineActionsDropdown.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/PipelineActions/PipelineActionsDropdown.tsx index a6b5db9165ec..105b33d11b7c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/PipelineActions/PipelineActionsDropdown.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/PipelineActions/PipelineActionsDropdown.tsx @@ -136,7 +136,7 @@ function PipelineActionsDropdown({ const handleConfirmDelete = useCallback( (id: string, name: string, displayName?: string) => { - handleDeleteSelection({ + handleDeleteSelection?.({ id, name, displayName, diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.test.ts index 537fdc377d61..63ff757b8630 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.test.ts @@ -148,7 +148,10 @@ describe('getEntitySpecificQueryBuilderFields', () => { SearchIndex.GLOSSARY_TERM, ]); - expect(Object.keys(result)).toEqual([EntityFields.GLOSSARY_TERM_STATUS]); + expect(Object.keys(result)).toEqual([ + EntityFields.GLOSSARY_TERM_STATUS, + EntityFields.GLOSSARY, + ]); }); it('should return databaseSchema specific fields', () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.ts b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.ts index 12ed537d1c29..6afe1cdcef36 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.ts @@ -337,7 +337,7 @@ class AdvancedSearchClassBase { /** * Fields specific to Glossary */ - glossaryQueryBuilderFields: Fields = { + glossaryTermQueryBuilderFields: Fields = { [EntityFields.GLOSSARY_TERM_STATUS]: { label: t('label.status'), type: 'select', @@ -350,6 +350,18 @@ class AdvancedSearchClassBase { useAsyncSearch: true, }, }, + [EntityFields.GLOSSARY]: { + label: t('label.glossary'), + type: 'select', + mainWidgetProps: this.mainWidgetProps, + fieldSettings: { + asyncFetch: this.autocomplete({ + searchIndex: SearchIndex.GLOSSARY_TERM, + entityField: EntityFields.GLOSSARY, + }), + useAsyncSearch: true, + }, + }, }; /** @@ -742,7 +754,7 @@ class AdvancedSearchClassBase { [SearchIndex.SEARCH_INDEX]: this.searchIndexQueryBuilderFields, [SearchIndex.DASHBOARD_DATA_MODEL]: this.dataModelQueryBuilderFields, [SearchIndex.API_ENDPOINT_INDEX]: this.apiEndpointQueryBuilderFields, - [SearchIndex.GLOSSARY_TERM]: this.glossaryQueryBuilderFields, + [SearchIndex.GLOSSARY_TERM]: this.glossaryTermQueryBuilderFields, [SearchIndex.DATABASE_SCHEMA]: this.databaseSchemaQueryBuilderFields, [SearchIndex.STORED_PROCEDURE]: this.storedProcedureQueryBuilderFields, [SearchIndex.ALL]: { @@ -766,7 +778,7 @@ class AdvancedSearchClassBase { ...this.searchIndexQueryBuilderFields, ...this.dataModelQueryBuilderFields, ...this.apiEndpointQueryBuilderFields, - ...this.glossaryQueryBuilderFields, + ...this.glossaryTermQueryBuilderFields, }, }; @@ -866,6 +878,8 @@ class AdvancedSearchClassBase { SearchIndex.API_SERVICE_INDEX, SearchIndex.API_ENDPOINT_INDEX, SearchIndex.API_COLLECTION_INDEX, + SearchIndex.DASHBOARD_DATA_MODEL, + SearchIndex.STORED_PROCEDURE, ]; const shouldAddServiceField = 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 595e5ca6a1d8..9b4dd9aefa88 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/EntityUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/EntityUtils.tsx @@ -2499,6 +2499,9 @@ export const getEntityNameLabel = (entityName?: string) => { query: t('label.query'), THREAD: t('label.thread'), app: t('label.application'), + apiCollection: t('label.api-collection'), + apiEndpoint: t('label.api-endpoint'), + metric: t('label.metric'), }; return ( From 7523a10222ff425fb19bad6cab44398314b047bd Mon Sep 17 00:00:00 2001 From: sonika-shah <58761340+sonika-shah@users.noreply.github.com> Date: Tue, 7 Jan 2025 21:51:05 +0530 Subject: [PATCH 063/294] Bug Fix - Glossary export returns null values in relatedTerms column (#19173) * Bug Fix - Glossary export returns null values in relatedTerms column * Add coverage test --- .../service/jdbi3/GlossaryTermRepository.java | 35 +++++++++++-------- .../glossary/GlossaryResourceTest.java | 14 ++++---- 2 files changed, 27 insertions(+), 22 deletions(-) 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 2631fefd7159..3ed9baf66d90 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 @@ -803,26 +803,31 @@ private void fetchAndSetRelatedTerms(List entities, Fields fields) allRecords.addAll(fromRecords); allRecords.addAll(toRecords); - Map> relatedTermsMap = new HashMap<>(); + Map> relatedTermIdsMap = new HashMap<>(); for (CollectionDAO.EntityRelationshipObject rec : allRecords) { - UUID termId; - UUID relatedTermId; - - if (termIdsSet.contains(rec.getFromId())) { - termId = UUID.fromString(rec.getFromId()); - relatedTermId = UUID.fromString(rec.getToId()); - } else { - termId = UUID.fromString(rec.getToId()); - relatedTermId = UUID.fromString(rec.getFromId()); - } - - EntityReference relatedTermRef = - new EntityReference().withId(relatedTermId).withType(Entity.GLOSSARY_TERM); + UUID termId = UUID.fromString(rec.getFromId()); + UUID relatedTermId = UUID.fromString(rec.getToId()); - relatedTermsMap.computeIfAbsent(termId, k -> new ArrayList<>()).add(relatedTermRef); + // store the bidirectional relationship in related-term map + relatedTermIdsMap.computeIfAbsent(termId, k -> new HashSet<>()).add(relatedTermId); + relatedTermIdsMap.computeIfAbsent(relatedTermId, k -> new HashSet<>()).add(termId); } + Map> relatedTermsMap = + relatedTermIdsMap.entrySet().stream() + .collect( + Collectors.toMap( + Map.Entry::getKey, + entry -> + entry.getValue().stream() + .map( + id -> + Entity.getEntityReferenceById( + Entity.GLOSSARY_TERM, id, Include.ALL)) + .sorted(EntityUtil.compareEntityReference) + .toList())); + if (!allRecords.isEmpty()) { for (GlossaryTerm term : entities) { List relatedTerms = diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/glossary/GlossaryResourceTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/glossary/GlossaryResourceTest.java index 2f5e79a12402..b5e4ae27ac08 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/resources/glossary/GlossaryResourceTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/glossary/GlossaryResourceTest.java @@ -903,28 +903,28 @@ void testGlossaryImportExport() throws IOException { } // CSV Header "parent", "name", "displayName", "description", "synonyms", "relatedTerms", // "references", - // "tags", "reviewers", "owners", "status", "extension" + // "tags", "reviewers", "owner", "glossaryStatus", "extension" // Create two records List createRecords = listOf( String.format( - ",g1,dsp1,\"dsc1,1\",h1;h2;h3,,term1;http://term1,PII.None,user:%s,user:%s,%s,\"glossaryTermDateCp:18-09-2024;glossaryTermDateTimeCp:18-09-2024 01:09:34;glossaryTermDurationCp:PT5H30M10S;glossaryTermEmailCp:admin@open-metadata.org;glossaryTermEntRefCp:team:\"\"%s\"\";glossaryTermEntRefListCp:user:\"\"%s\"\"|user:\"\"%s\"\"\"", + ",g1,dsp1,\"dsc1,1\",h1;h2;h3,g1.g1t1;g2.g2t1,term1;http://term1,PII.None,user:%s,user:%s,%s,\"glossaryTermDateCp:18-09-2024;glossaryTermDateTimeCp:18-09-2024 01:09:34;glossaryTermDurationCp:PT5H30M10S;glossaryTermEmailCp:admin@open-metadata.org;glossaryTermEntRefCp:team:\"\"%s\"\";glossaryTermEntRefListCp:user:\"\"%s\"\"|user:\"\"%s\"\"\"", reviewerRef.get(0), user1, "Approved", team11, user1, user2), String.format( - ",g2,dsp2,dsc3,h1;h3;h3,,term2;https://term2,PII.NonSensitive,,user:%s,%s,\"glossaryTermEnumCpMulti:val3|val2|val1|val4|val5;glossaryTermEnumCpSingle:single1;glossaryTermIntegerCp:7777;glossaryTermMarkdownCp:# Sample Markdown Text;glossaryTermNumberCp:123456;\"\"glossaryTermQueryCp:select col,row from table where id ='30';\"\";glossaryTermStringCp:sample string content;glossaryTermTimeCp:10:08:45;glossaryTermTimeIntervalCp:1726142300000:17261420000;glossaryTermTimestampCp:1726142400000\"", + ",g2,dsp2,dsc3,h1;h3;h3,g1.g1t1;g2.g2t1,term2;https://term2,PII.NonSensitive,,user:%s,%s,\"glossaryTermEnumCpMulti:val3|val2|val1|val4|val5;glossaryTermEnumCpSingle:single1;glossaryTermIntegerCp:7777;glossaryTermMarkdownCp:# Sample Markdown Text;glossaryTermNumberCp:123456;\"\"glossaryTermQueryCp:select col,row from table where id ='30';\"\";glossaryTermStringCp:sample string content;glossaryTermTimeCp:10:08:45;glossaryTermTimeIntervalCp:1726142300000:17261420000;glossaryTermTimestampCp:1726142400000\"", user1, "Approved"), String.format( - "importExportTest.g1,g11,dsp2,dsc11,h1;h3;h3,,,,user:%s,team:%s,%s,", + "importExportTest.g1,g11,dsp2,dsc11,h1;h3;h3,g1.g1t1;g2.g2t1,,,user:%s,team:%s,%s,", reviewerRef.get(0), team11, "Draft")); // Update terms with change in description List updateRecords = listOf( String.format( - ",g1,dsp1,new-dsc1,h1;h2;h3,,term1;http://term1,PII.None,user:%s,user:%s,%s,\"glossaryTermDateCp:18-09-2024;glossaryTermDateTimeCp:18-09-2024 01:09:34;glossaryTermDurationCp:PT5H30M10S;glossaryTermEmailCp:admin@open-metadata.org;glossaryTermEntRefCp:team:\"\"%s\"\";glossaryTermEntRefListCp:user:\"\"%s\"\"|user:\"\"%s\"\"\"", + ",g1,dsp1,new-dsc1,h1;h2;h3,g1.g1t1;importExportTest.g2;g2.g2t1,term1;http://term1,PII.None,user:%s,user:%s,%s,\"glossaryTermDateCp:18-09-2024;glossaryTermDateTimeCp:18-09-2024 01:09:34;glossaryTermDurationCp:PT5H30M10S;glossaryTermEmailCp:admin@open-metadata.org;glossaryTermEntRefCp:team:\"\"%s\"\";glossaryTermEntRefListCp:user:\"\"%s\"\"|user:\"\"%s\"\"\"", reviewerRef.get(0), user1, "Approved", team11, user1, user2), String.format( - ",g2,dsp2,new-dsc3,h1;h3;h3,,term2;https://term2,PII.NonSensitive,user:%s,user:%s,%s,\"glossaryTermEnumCpMulti:val3|val2|val1|val4|val5;glossaryTermEnumCpSingle:single1;glossaryTermIntegerCp:7777;glossaryTermMarkdownCp:# Sample Markdown Text;glossaryTermNumberCp:123456;\"\"glossaryTermQueryCp:select col,row from table where id ='30';\"\";glossaryTermStringCp:sample string content;glossaryTermTimeCp:10:08:45;glossaryTermTimeIntervalCp:1726142300000:17261420000;glossaryTermTimestampCp:1726142400000\"", + ",g2,dsp2,new-dsc3,h1;h3;h3,importExportTest.g1;g1.g1t1;g2.g2t1,term2;https://term2,PII.NonSensitive,user:%s,user:%s,%s,\"glossaryTermEnumCpMulti:val3|val2|val1|val4|val5;glossaryTermEnumCpSingle:single1;glossaryTermIntegerCp:7777;glossaryTermMarkdownCp:# Sample Markdown Text;glossaryTermNumberCp:123456;\"\"glossaryTermQueryCp:select col,row from table where id ='30';\"\";glossaryTermStringCp:sample string content;glossaryTermTimeCp:10:08:45;glossaryTermTimeIntervalCp:1726142300000:17261420000;glossaryTermTimestampCp:1726142400000\"", user1, user2, "Approved"), String.format( "importExportTest.g1,g11,dsp2,new-dsc11,h1;h3;h3,,,,user:%s,team:%s,%s,\"\"\"glossaryTermTableCol1Cp:row_1_col1_Value,,\"\";\"\"glossaryTermTableCol3Cp:row_1_col1_Value,row_1_col2_Value,row_1_col3_Value|row_2_col1_Value,row_2_col2_Value,row_2_col3_Value\"\"\"", @@ -933,7 +933,7 @@ void testGlossaryImportExport() throws IOException { // Add new row to existing rows List newRecords = listOf( - ",g3,dsp0,dsc0,h1;h2;h3,,term0;http://term0,PII.Sensitive,,,Approved,\"\"\"glossaryTermTableCol1Cp:row_1_col1_Value,,\"\";\"\"glossaryTermTableCol3Cp:row_1_col1_Value,row_1_col2_Value,row_1_col3_Value|row_2_col1_Value,row_2_col2_Value,row_2_col3_Value\"\"\""); + ",g3,dsp0,dsc0,h1;h2;h3,g1.g1t1;g2.g2t1,term0;http://term0,PII.Sensitive,,,Approved,\"\"\"glossaryTermTableCol1Cp:row_1_col1_Value,,\"\";\"\"glossaryTermTableCol3Cp:row_1_col1_Value,row_1_col2_Value,row_1_col3_Value|row_2_col1_Value,row_2_col2_Value,row_2_col3_Value\"\"\""); Awaitility.await() .atMost(Duration.ofMillis(120 * 1000L)) .pollInterval(Duration.ofMillis(2000L)) From 75db49f711c209ba9a7ffbdb2094bd94cbe87c14 Mon Sep 17 00:00:00 2001 From: Mayur Singal <39544459+ulixius9@users.noreply.github.com> Date: Mon, 6 Jan 2025 11:39:34 +0530 Subject: [PATCH 064/294] Fix #15952: Update SQLParse to Version 0.5 (#19224) --- ingestion/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ingestion/setup.py b/ingestion/setup.py index 42b5c4e3bc90..7e8b015d6574 100644 --- a/ingestion/setup.py +++ b/ingestion/setup.py @@ -137,7 +137,7 @@ "requests>=2.23", "requests-aws4auth~=1.1", # Only depends on requests as external package. Leaving as base. "sqlalchemy>=1.4.0,<2", - "collate-sqllineage~=1.5.0", + "collate-sqllineage~=1.6.0", "tabulate==0.9.0", "typing-inspect", "packaging", # For version parsing From d080824100a70f8f1bd106c89e220179ae197aaf Mon Sep 17 00:00:00 2001 From: Mayur Singal <39544459+ulixius9@users.noreply.github.com> Date: Fri, 3 Jan 2025 15:44:01 +0530 Subject: [PATCH 065/294] Fix #19141: Data Insight Tier Filter does not work (#19142) --- .../jdbi3/DataInsightSystemChartRepository.java | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DataInsightSystemChartRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DataInsightSystemChartRepository.java index 5d02304282bc..d082445fcdc4 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DataInsightSystemChartRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DataInsightSystemChartRepository.java @@ -5,6 +5,7 @@ import java.io.IOException; import java.util.HashMap; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Set; import org.openmetadata.schema.dataInsight.custom.DataInsightCustomChart; @@ -87,7 +88,13 @@ public DataInsightCustomChartResultList getPreviewData( DataInsightCustomChart chart, long startTimestamp, long endTimestamp, String filter) throws IOException { if (chart.getChartDetails() != null && filter != null) { - ((LinkedHashMap) chart.getChartDetails()).put("filter", filter); + HashMap chartDetails = (LinkedHashMap) chart.getChartDetails(); + if (chartDetails.get("metrics") != null) { + for (LinkedHashMap metrics : + (List>) chartDetails.get("metrics")) { + metrics.put("filter ", filter); + } + } } return getPreviewData(chart, startTimestamp, endTimestamp); } @@ -110,7 +117,13 @@ public Map listChartData( if (chart != null) { if (chart.getChartDetails() != null && filter != null) { - ((LinkedHashMap) chart.getChartDetails()).put("filter", filter); + HashMap chartDetails = (LinkedHashMap) chart.getChartDetails(); + if (chartDetails.get("metrics") != null) { + for (LinkedHashMap metrics : + (List>) chartDetails.get("metrics")) { + metrics.put("filter ", filter); + } + } } DataInsightCustomChartResultList data = searchClient.buildDIChart(chart, startTimestamp, endTimestamp); From dfe34e191c18701cef7fcef034685468d4e1d8e0 Mon Sep 17 00:00:00 2001 From: Mayur Singal <39544459+ulixius9@users.noreply.github.com> Date: Wed, 18 Dec 2024 16:44:54 +0530 Subject: [PATCH 066/294] MINOR: User search should only look in name & displayName (#19121) * MINOR: User search should only look in name & displayname * py_format * pyformat --------- Co-authored-by: Suman Maharana --- .../ingestion/ometa/mixins/user_mixin.py | 24 +++++++++++++++---- .../integration/ometa/test_ometa_user_api.py | 9 ++++++- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/ingestion/src/metadata/ingestion/ometa/mixins/user_mixin.py b/ingestion/src/metadata/ingestion/ometa/mixins/user_mixin.py index 2ee604973f1a..c389fbc2842a 100644 --- a/ingestion/src/metadata/ingestion/ometa/mixins/user_mixin.py +++ b/ingestion/src/metadata/ingestion/ometa/mixins/user_mixin.py @@ -13,6 +13,7 @@ To be used by OpenMetadata class """ +import json from functools import lru_cache from typing import Optional, Type @@ -46,16 +47,29 @@ def email_search_query_es(entity: Type[T]) -> str: ) @staticmethod - def name_search_query_es(entity: Type[T]) -> str: + def name_search_query_es(entity: Type[T], name: str, from_: int, size: int) -> str: """ Allow for more flexible lookup following what the UI is doing when searching users. We don't want to stick to `q=name:{name}` since in case a user is named `random.user` but looked as `Random User`, we want to find this match. + + Search should only look in name and displayName fields and should not return bots. """ + query_filter = { + "query": { + "query_string": { + "query": f"{name} AND isBot:false", + "fields": ["name", "displayName"], + "default_operator": "AND", + "fuzziness": "AUTO", + } + } + } + return ( - "/search/query?q={name} AND isBot:false&from={from_}&size={size}&index=" - + ES_INDEX_MAP[entity.__name__] + f"""/search/query?query_filter={json.dumps(query_filter)}""" + f"&from={from_}&size={size}&index=" + ES_INDEX_MAP[entity.__name__] ) def _search_by_email( @@ -103,8 +117,8 @@ def _search_by_name( fields: Optional field list to pass to ES request """ if name: - query_string = self.name_search_query_es(entity=entity).format( - name=name, from_=from_count, size=size + query_string = self.name_search_query_es( + entity=entity, name=name, from_=from_count, size=size ) return self.get_entity_from_es( entity=entity, query_string=query_string, fields=fields diff --git a/ingestion/tests/integration/ometa/test_ometa_user_api.py b/ingestion/tests/integration/ometa/test_ometa_user_api.py index fa716b9efbbe..e746a7d65e10 100644 --- a/ingestion/tests/integration/ometa/test_ometa_user_api.py +++ b/ingestion/tests/integration/ometa/test_ometa_user_api.py @@ -62,7 +62,9 @@ def setUpClass(cls) -> None: cls.user_1: User = cls.metadata.create_or_update( data=CreateUserRequest( - name="random.user.es", email="random.user.es@getcollate.io" + name="random.user.es", + email="random.user.es@getcollate.io", + description="test", ), ) @@ -198,3 +200,8 @@ def test_es_search_from_name(self): self.assertIsNone( self.metadata.get_reference_by_name(name="Organization", is_owner=True) ) + + # description should not affect in search + self.assertIsNone( + self.metadata.get_reference_by_name(name="test", is_owner=True) + ) From 922c3d2713daca2eaabb5704ce5e6b4ef763d188 Mon Sep 17 00:00:00 2001 From: harshsoni2024 <64592571+harshsoni2024@users.noreply.github.com> Date: Wed, 18 Dec 2024 16:39:06 +0530 Subject: [PATCH 067/294] powerbi refactor, avoid prepare bulk data (#19123) --- .../source/dashboard/powerbi/client.py | 26 ++- .../source/dashboard/powerbi/metadata.py | 209 +++++++----------- 2 files changed, 96 insertions(+), 139 deletions(-) diff --git a/ingestion/src/metadata/ingestion/source/dashboard/powerbi/client.py b/ingestion/src/metadata/ingestion/source/dashboard/powerbi/client.py index d1acc7901ac9..68a7aad00470 100644 --- a/ingestion/src/metadata/ingestion/source/dashboard/powerbi/client.py +++ b/ingestion/src/metadata/ingestion/source/dashboard/powerbi/client.py @@ -127,7 +127,13 @@ def fetch_all_org_dashboards( List[PowerBIDashboard] """ try: - response_data = self.client.get(f"/myorg/groups/{group_id}/dashboards") + admin = "admin/" if self.config.useAdminApis else "" + response_data = self.client.get( + f"/myorg/{admin}groups/{group_id}/dashboards" + ) + if not response_data: + logger.debug(f"No dashboards found for workspace_id: {group_id}") + return None response = DashboardsResponse(**response_data) return response.value except Exception as exc: # pylint: disable=broad-except @@ -142,7 +148,11 @@ def fetch_all_org_reports(self, group_id: str) -> Optional[List[PowerBIReport]]: List[PowerBIReport] """ try: - response_data = self.client.get(f"/myorg/groups/{group_id}/reports") + admin = "admin/" if self.config.useAdminApis else "" + response_data = self.client.get(f"/myorg/{admin}groups/{group_id}/reports") + if not response_data: + logger.debug(f"No reports found for workspace_id: {group_id}") + return None response = ReportsResponse(**response_data) return response.value except Exception as exc: # pylint: disable=broad-except @@ -157,7 +167,11 @@ def fetch_all_org_datasets(self, group_id: str) -> Optional[List[Dataset]]: List[Dataset] """ try: - response_data = self.client.get(f"/myorg/groups/{group_id}/datasets") + admin = "admin/" if self.config.useAdminApis else "" + response_data = self.client.get(f"/myorg/{admin}groups/{group_id}/datasets") + if not response_data: + logger.debug(f"No datasets found for workspace_id: {group_id}") + return None response = DatasetResponse(**response_data) return response.value except Exception as exc: # pylint: disable=broad-except @@ -174,9 +188,13 @@ def fetch_all_org_tiles( List[Tile] """ try: + admin = "admin/" if self.config.useAdminApis else "" response_data = self.client.get( - f"/myorg/groups/{group_id}/dashboards/{dashboard_id}/tiles" + f"/myorg/{admin}dashboards/{dashboard_id}/tiles" ) + if not response_data: + logger.debug(f"No dashboard tiles found for workspace_id: {group_id}") + return None response = TilesResponse(**response_data) return response.value except Exception as exc: # pylint: disable=broad-except diff --git a/ingestion/src/metadata/ingestion/source/dashboard/powerbi/metadata.py b/ingestion/src/metadata/ingestion/source/dashboard/powerbi/metadata.py index 685e699c281d..ea66b1b279fd 100644 --- a/ingestion/src/metadata/ingestion/source/dashboard/powerbi/metadata.py +++ b/ingestion/src/metadata/ingestion/source/dashboard/powerbi/metadata.py @@ -92,12 +92,6 @@ def __init__( self.datamodel_file_mappings = [] def prepare(self): - if self.service_connection.useAdminApis: - groups = self.get_admin_workspace_data() - else: - groups = self.get_org_workspace_data() - if groups: - self.workspace_data = self.get_filtered_workspaces(groups) return super().prepare() def close(self): @@ -123,91 +117,6 @@ def get_filtered_workspaces(self, groups: List[Group]) -> List[Group]: filtered_groups.append(group) return filtered_groups - def get_org_workspace_data(self) -> Optional[List[Group]]: - """ - fetch all the group workspace ids - """ - groups = self.client.api_client.fetch_all_workspaces() - for group in groups: - # add the dashboards to the groups - group.dashboards.extend( - self.client.api_client.fetch_all_org_dashboards(group_id=group.id) or [] - ) - for dashboard in group.dashboards: - # add the tiles to the dashboards - dashboard.tiles.extend( - self.client.api_client.fetch_all_org_tiles( - group_id=group.id, dashboard_id=dashboard.id - ) - or [] - ) - - # add the reports to the groups - group.reports.extend( - self.client.api_client.fetch_all_org_reports(group_id=group.id) or [] - ) - - # add the datasets to the groups - group.datasets.extend( - self.client.api_client.fetch_all_org_datasets(group_id=group.id) or [] - ) - for dataset in group.datasets: - # add the tables to the datasets - dataset.tables.extend( - self.client.api_client.fetch_dataset_tables( - group_id=group.id, dataset_id=dataset.id - ) - or [] - ) - return groups - - def get_admin_workspace_data(self) -> Optional[List[Group]]: - """ - fetch all the workspace ids - """ - groups = [] - workspaces = self.client.api_client.fetch_all_workspaces() - if workspaces: - workspace_id_list = [workspace.id for workspace in workspaces] - - # Start the scan of the available workspaces for dashboard metadata - workspace_paginated_list = [ - workspace_id_list[i : i + self.pagination_entity_per_page] - for i in range( - 0, len(workspace_id_list), self.pagination_entity_per_page - ) - ] - count = 1 - for workspace_ids_chunk in workspace_paginated_list: - logger.info( - f"Scanning {count}/{len(workspace_paginated_list)} set of workspaces" - ) - workspace_scan = self.client.api_client.initiate_workspace_scan( - workspace_ids_chunk - ) - - # Keep polling the scan status endpoint to check if scan is succeeded - workspace_scan_status = self.client.api_client.wait_for_scan_complete( - scan_id=workspace_scan.id - ) - if workspace_scan_status: - response = self.client.api_client.fetch_workspace_scan_result( - scan_id=workspace_scan.id - ) - groups.extend( - [ - active_workspace - for active_workspace in response.workspaces - if active_workspace.state == "Active" - ] - ) - else: - logger.error("Error in fetching dashboards and charts") - count += 1 - else: - logger.error("Unable to fetch any PowerBI workspaces") - return groups or None - @classmethod def create( cls, config_dict, metadata: OpenMetadata, pipeline_name: Optional[str] = None @@ -220,12 +129,51 @@ def create( ) return cls(config, metadata) + def prepare_workspace_data(self, workspace: Group): + """prepare one workspace data at a time""" + # add the dashboards to the groups + workspace.dashboards.extend( + self.client.api_client.fetch_all_org_dashboards(group_id=workspace.id) or [] + ) + for dashboard in workspace.dashboards: + # add the tiles to the dashboards + dashboard.tiles.extend( + self.client.api_client.fetch_all_org_tiles( + group_id=workspace.id, dashboard_id=dashboard.id + ) + or [] + ) + + # add the reports to the groups + workspace.reports.extend( + self.client.api_client.fetch_all_org_reports(group_id=workspace.id) or [] + ) + + # add the datasets to the groups + workspace.datasets.extend( + self.client.api_client.fetch_all_org_datasets(group_id=workspace.id) or [] + ) + for dataset in workspace.datasets: + # add the tables to the datasets + dataset.tables.extend( + self.client.api_client.fetch_dataset_tables( + group_id=workspace.id, dataset_id=dataset.id + ) + or [] + ) + def get_dashboard(self) -> Any: """ Method to iterate through dashboard lists filter dashboards & yield dashboard details """ - for workspace in self.workspace_data: + # fetch all workspaces/groups & apply filter pattern + all_workspaces = self.client.api_client.fetch_all_workspaces() + all_workspaces = self.get_filtered_workspaces(all_workspaces) + for workspace in all_workspaces: + # prepare additional data for specific workspace (datasets, reports, dashboards) + self.prepare_workspace_data(workspace) self.context.get().workspace = workspace + self.workspace_data.append(workspace) for dashboard in self.get_dashboards_list(): try: dashboard_details = self.get_dashboard_details(dashboard) @@ -309,54 +257,45 @@ def _get_chart_url( f"{workspace_id}/{chart_url_postfix}" ) - def list_datamodels(self) -> Iterable[Dataset]: + def yield_datamodel( + self, dashboard_details: Union[PowerBIDashboard, PowerBIReport] + ) -> Iterable[Either[CreateDashboardRequest]]: """ - Get All the Powerbi Datasets + Method to yield datamodel for each workspace """ - if self.source_config.includeDataModels: + workspace_datasets = self.context.get().workspace.datasets + for dataset in workspace_datasets: + if filter_by_datamodel( + self.source_config.dataModelFilterPattern, dataset.name + ): + self.status.filter(dataset.name, "Data model filtered out.") + continue try: - for workspace in self.workspace_data: - for dataset in workspace.datasets or []: - if filter_by_datamodel( - self.source_config.dataModelFilterPattern, dataset.name - ): - self.status.filter(dataset.name, "Data model filtered out.") - continue - yield dataset - except Exception as err: - logger.debug(traceback.format_exc()) - logger.error(f"Unexpected error fetching PowerBI datasets - {err}") - - def yield_bulk_datamodel( - self, dataset: Dataset - ) -> Iterable[Either[CreateDashboardDataModelRequest]]: - """ - Method to fetch DataModels in bulk - """ - try: - data_model_request = CreateDashboardDataModelRequest( - name=EntityName(dataset.id), - displayName=dataset.name, - description=Markdown(dataset.description) - if dataset.description - else None, - service=FullyQualifiedEntityName(self.context.get().dashboard_service), - dataModelType=DataModelType.PowerBIDataModel.value, - serviceType=DashboardServiceType.PowerBI.value, - columns=self._get_column_info(dataset), - project=self._fetch_dataset_workspace(dataset_id=dataset.id), - ) - yield Either(right=data_model_request) - self.register_record_datamodel(datamodel_request=data_model_request) + data_model_request = CreateDashboardDataModelRequest( + name=EntityName(dataset.id), + displayName=dataset.name, + description=Markdown(dataset.description) + if dataset.description + else None, + service=FullyQualifiedEntityName( + self.context.get().dashboard_service + ), + dataModelType=DataModelType.PowerBIDataModel.value, + serviceType=DashboardServiceType.PowerBI.value, + columns=self._get_column_info(dataset), + project=self.get_project_name(dashboard_details), + ) + yield Either(right=data_model_request) + self.register_record_datamodel(datamodel_request=data_model_request) - except Exception as exc: - yield Either( - left=StackTraceError( - name=dataset.name, - error=f"Error yielding Data Model [{dataset.name}]: {exc}", - stackTrace=traceback.format_exc(), + except Exception as exc: + yield Either( + left=StackTraceError( + name=dataset.name, + error=f"Error yielding Data Model [{dataset.name}]: {exc}", + stackTrace=traceback.format_exc(), + ) ) - ) def _get_child_columns(self, table: PowerBiTable) -> List[Column]: """ From 40d48ba072d71dffc0af2ed2e30fafc29542a6e3 Mon Sep 17 00:00:00 2001 From: harshsoni2024 <64592571+harshsoni2024@users.noreply.github.com> Date: Tue, 7 Jan 2025 14:39:59 +0530 Subject: [PATCH 068/294] MINOR: PowerBI improve error logging (#19249) --- .../ingestion/source/dashboard/powerbi/client.py | 9 +++++++++ .../ingestion/source/dashboard/powerbi/metadata.py | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/ingestion/src/metadata/ingestion/source/dashboard/powerbi/client.py b/ingestion/src/metadata/ingestion/source/dashboard/powerbi/client.py index 68a7aad00470..bd4d24d28b64 100644 --- a/ingestion/src/metadata/ingestion/source/dashboard/powerbi/client.py +++ b/ingestion/src/metadata/ingestion/source/dashboard/powerbi/client.py @@ -234,6 +234,9 @@ def fetch_all_workspaces(self) -> Optional[List[Group]]: entities_per_page = self.config.pagination_entity_per_page params_data = {"$top": "1"} response_data = self.client.get(api_url, data=params_data) + if not response_data: + logger.debug("No groups/workspaces found") + return None response = GroupsResponse(**response_data) count = response.odata_count indexes = math.ceil(count / entities_per_page) @@ -245,6 +248,9 @@ def fetch_all_workspaces(self) -> Optional[List[Group]]: "$skip": str(index * entities_per_page), } response_data = self.client.get(api_url, data=params_data) + if not response_data: + logger.debug("No more groups/workspaces found") + continue response = GroupsResponse(**response_data) workspaces.extend(response.value) return workspaces @@ -280,6 +286,7 @@ def initiate_workspace_scan( def fetch_workspace_scan_status( self, scan_id: str ) -> Optional[WorkSpaceScanResponse]: + # deprecated in favour to avoide bulk data prepare """Get Workspace scan status by id method Args: scan_id: @@ -298,6 +305,7 @@ def fetch_workspace_scan_status( return None def fetch_workspace_scan_result(self, scan_id: str) -> Optional[Workspaces]: + # deprecated in favour to avoide bulk data prepare """Get Workspace scan result by id method Args: scan_id: @@ -319,6 +327,7 @@ def wait_for_scan_complete(self, scan_id, timeout=180) -> bool: """ Method to poll the scan status endpoint until the timeout """ + # deprecated in favour to avoide bulk data prepare min_sleep_time = 3 if min_sleep_time > timeout: logger.info(f"Timeout is set to minimum sleep time: {timeout}") diff --git a/ingestion/src/metadata/ingestion/source/dashboard/powerbi/metadata.py b/ingestion/src/metadata/ingestion/source/dashboard/powerbi/metadata.py index ea66b1b279fd..db8158da8240 100644 --- a/ingestion/src/metadata/ingestion/source/dashboard/powerbi/metadata.py +++ b/ingestion/src/metadata/ingestion/source/dashboard/powerbi/metadata.py @@ -167,7 +167,7 @@ def get_dashboard(self) -> Any: Method to iterate through dashboard lists filter dashboards & yield dashboard details """ # fetch all workspaces/groups & apply filter pattern - all_workspaces = self.client.api_client.fetch_all_workspaces() + all_workspaces = self.client.api_client.fetch_all_workspaces() or [] all_workspaces = self.get_filtered_workspaces(all_workspaces) for workspace in all_workspaces: # prepare additional data for specific workspace (datasets, reports, dashboards) From dabcf4fbc3e1c4ed13bcd4a4eb6302c0f61354c4 Mon Sep 17 00:00:00 2001 From: sonika-shah <58761340+sonika-shah@users.noreply.github.com> Date: Tue, 7 Jan 2025 16:20:59 +0530 Subject: [PATCH 069/294] move activeFqn to new method (#19263) --- .../org/openmetadata/service/search/SearchClient.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchClient.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchClient.java index a02aaad5168e..ade12dae4ded 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchClient.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchClient.java @@ -214,6 +214,15 @@ default ResultList listPageHierarchy(String parent, String pageType, int offset, Response.Status.NOT_IMPLEMENTED, NOT_IMPLEMENTED_ERROR_TYPE, NOT_IMPLEMENTED_METHOD); } + /* + Used for listing knowledge page hierarchy for a given active Page and page type, used in Elastic/Open SearchClientExtension + */ + default ResultList listPageHierarchyForActivePage( + String activeFqn, String pageType, int offset, int limit) { + throw new CustomExceptionMessage( + Response.Status.NOT_IMPLEMENTED, NOT_IMPLEMENTED_ERROR_TYPE, NOT_IMPLEMENTED_METHOD); + } + @SuppressWarnings("unused") default ResultList searchPageHierarchy(String query, String pageType, int offset, int limit) { throw new CustomExceptionMessage( From 49b2030ea8a8321e122ef359ef0bfb69fe839342 Mon Sep 17 00:00:00 2001 From: Karan Hotchandani <33024356+karanh37@users.noreply.github.com> Date: Sat, 14 Dec 2024 22:30:19 +0530 Subject: [PATCH 070/294] remove extra assignees from workflow (#19030) (cherry picked from commit 497b477be3d2064f29825891c1a6436dce83362e) --- .../nodes/userTask/userApprovalTask.json | 7 --- .../nodes/userTask/userApprovalTask.ts | 57 +------------------ 2 files changed, 1 insertion(+), 63 deletions(-) diff --git a/openmetadata-spec/src/main/resources/json/schema/governance/workflows/elements/nodes/userTask/userApprovalTask.json b/openmetadata-spec/src/main/resources/json/schema/governance/workflows/elements/nodes/userTask/userApprovalTask.json index fe4ae4636a7c..b175fba1d6e8 100644 --- a/openmetadata-spec/src/main/resources/json/schema/governance/workflows/elements/nodes/userTask/userApprovalTask.json +++ b/openmetadata-spec/src/main/resources/json/schema/governance/workflows/elements/nodes/userTask/userApprovalTask.json @@ -40,13 +40,6 @@ "description": "Add the Reviewers to the assignees List.", "type": "boolean", "default": false - }, - "extraAssignees": { - "description": "Manually add Specific Assignees.", - "type": "array", - "items": { - "$ref": "../../../../../type/entityReference.json" - } } } } diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/governance/workflows/elements/nodes/userTask/userApprovalTask.ts b/openmetadata-ui/src/main/resources/ui/src/generated/governance/workflows/elements/nodes/userTask/userApprovalTask.ts index 5cab66f590ec..6d523d5b7427 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/governance/workflows/elements/nodes/userTask/userApprovalTask.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/governance/workflows/elements/nodes/userTask/userApprovalTask.ts @@ -10,9 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - - - /** +/** * Defines a Task for a given User to approve. */ export interface UserApprovalTask { @@ -51,58 +49,5 @@ export interface Assignees { * Add the Reviewers to the assignees List. */ addReviewers?: boolean; - /** - * Manually add Specific Assignees. - */ - extraAssignees?: EntityReference[]; [property: string]: any; } - -/** - * This schema defines the EntityReference type used for referencing an entity. - * EntityReference is used for capturing relationships from one entity to another. For - * example, a table has an attribute called database of type EntityReference that captures - * the relationship of a table `belongs to a` database. - */ -export interface EntityReference { - /** - * If true the entity referred to has been soft-deleted. - */ - deleted?: boolean; - /** - * Optional description of entity. - */ - description?: string; - /** - * Display Name that identifies this entity. - */ - displayName?: string; - /** - * Fully qualified name of the entity instance. For entities such as tables, databases - * fullyQualifiedName is returned in this field. For entities that don't have name hierarchy - * such as `user` and `team` this will be same as the `name` field. - */ - fullyQualifiedName?: string; - /** - * Link to the entity resource. - */ - href?: string; - /** - * Unique identifier that identifies an entity instance. - */ - id: string; - /** - * If true the relationship indicated by this entity reference is inherited from the parent - * entity. - */ - inherited?: boolean; - /** - * Name of the entity instance. - */ - name?: string; - /** - * Entity type/class name - Examples: `database`, `table`, `metrics`, `databaseService`, - * `dashboardService`... - */ - type: string; -} From d387bf263b8e481432d6c1702355a4b5ef276488 Mon Sep 17 00:00:00 2001 From: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> Date: Fri, 13 Dec 2024 19:11:03 +0530 Subject: [PATCH 071/294] fix(ui): render column type max 3 lines and ellipsis (#19046) (cherry picked from commit ea5a246a44f53cee3d9f21a6c55caf691896a943) --- .../components/Database/SchemaTable/SchemaTable.component.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/SchemaTable/SchemaTable.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/SchemaTable/SchemaTable.component.tsx index bbdafcb16071..c34b5f822a3b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/SchemaTable/SchemaTable.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/SchemaTable/SchemaTable.component.tsx @@ -420,7 +420,6 @@ const SchemaTable = ({ dataIndex: 'dataTypeDisplay', key: 'dataTypeDisplay', accessor: 'dataTypeDisplay', - ellipsis: true, width: 150, render: renderDataTypeDisplay, }, From 9a63d776b5f75b51c047748a837825b180bb3b77 Mon Sep 17 00:00:00 2001 From: Mayur Singal <39544459+ulixius9@users.noreply.github.com> Date: Wed, 8 Jan 2025 11:18:05 +0530 Subject: [PATCH 072/294] Fix #19160: Add Depth Support Part-1 (#19275) (cherry picked from commit e32ad88c813c0077c26f90ca24b34ebc0f62f9f2) --- .../storage/containerMetadataConfig.json | 6 ++++++ .../storage/containerMetadataConfig.ts | 10 ++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/openmetadata-spec/src/main/resources/json/schema/metadataIngestion/storage/containerMetadataConfig.json b/openmetadata-spec/src/main/resources/json/schema/metadataIngestion/storage/containerMetadataConfig.json index 24942df3b962..0cd65ceba3da 100644 --- a/openmetadata-spec/src/main/resources/json/schema/metadataIngestion/storage/containerMetadataConfig.json +++ b/openmetadata-spec/src/main/resources/json/schema/metadataIngestion/storage/containerMetadataConfig.json @@ -31,6 +31,12 @@ }, "default": null }, + "depth":{ + "title": "Depth", + "description": "Depth of the data path in the container", + "type": "integer", + "default": 0 + }, "separator": { "title": "Separator", "description": "For delimited files such as CSV, what is the separator being used?", diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/metadataIngestion/storage/containerMetadataConfig.ts b/openmetadata-ui/src/main/resources/ui/src/generated/metadataIngestion/storage/containerMetadataConfig.ts index 0d872a3154e2..90faddfd438a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/metadataIngestion/storage/containerMetadataConfig.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/metadataIngestion/storage/containerMetadataConfig.ts @@ -1,5 +1,5 @@ /* - * Copyright 2024 Collate. + * Copyright 2025 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 @@ -10,9 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - - - /** +/** * Definition of the properties contained by an object store container template config file */ export interface ContainerMetadataConfig { @@ -31,6 +29,10 @@ export interface MetadataEntry { * The path where the data resides in the container, excluding the bucket name */ dataPath: string; + /** + * Depth of the data path in the container + */ + depth?: number; /** * Flag indicating whether the container's data is partitioned */ From cb733a2ad87d79984aae2d4febe421e2a1f253fa Mon Sep 17 00:00:00 2001 From: Ashish Gupta Date: Wed, 8 Jan 2025 11:08:34 +0530 Subject: [PATCH 073/294] fix the search for custom properties in advance search for some types (#19113) * fix the search in advance search custom properties * modify v1/metadata/types/customProperties api to get customPropertyConfig along with name and type * added config list in the entity ref list * fix type of duration, timestamp and other types * minor ui changes * remove customProperty type which are not supported for now * added playwright test around the duration type property * fix flaky test --------- Co-authored-by: sonikashah (cherry picked from commit 2179b43232803a9ebb7465fb1d545b887d68252c) --- .../service/util/SchemaFieldExtractor.java | 59 +++++--- .../AdvanceSearchCustomProperty.spec.ts | 140 ++++++++++++++++++ .../utils/advancedSearchCustomProperty.ts | 28 ++++ .../AdvanceSearchProvider.component.tsx | 30 ++-- .../AdvanceSearchProvider.interface.ts | 4 + .../CustomPropertyTable/PropertyValue.tsx | 2 +- .../src/constants/AdvancedSearch.constants.ts | 10 ++ .../connections/pipeline/airbyteConnection.ts | 6 +- .../metadataIngestion/dbtPipeline.ts | 2 +- .../components/react-awesome-query.less | 9 ++ .../ui/src/utils/AdvancedSearchClassBase.ts | 107 +++++++++++-- .../ui/src/utils/AdvancedSearchUtils.tsx | 24 ++- .../QueryBuilderElasticsearchFormatUtils.js | 9 +- 13 files changed, 376 insertions(+), 54 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/AdvanceSearchCustomProperty.spec.ts create mode 100644 openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearchCustomProperty.ts diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/util/SchemaFieldExtractor.java b/openmetadata-service/src/main/java/org/openmetadata/service/util/SchemaFieldExtractor.java index e6d2d446cd56..dfbb05a8e33f 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/util/SchemaFieldExtractor.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/util/SchemaFieldExtractor.java @@ -32,7 +32,7 @@ @Slf4j public class SchemaFieldExtractor { - private static final Map> entityFieldsCache = + private static final Map> entityFieldsCache = new ConcurrentHashMap<>(); public SchemaFieldExtractor() { @@ -55,7 +55,7 @@ private static void initializeEntityFieldsCache() { Schema mainSchema = loadMainSchema(schemaPath, entityType, schemaUri, schemaClient); // Extract fields from the schema - Map fieldTypesMap = new LinkedHashMap<>(); + Map fieldTypesMap = new LinkedHashMap<>(); Deque processingStack = new ArrayDeque<>(); Set processedFields = new HashSet<>(); extractFieldsFromSchema(mainSchema, "", fieldTypesMap, processingStack, processedFields); @@ -75,7 +75,7 @@ public List extractFields(Type typeEntity, String entityType) { SchemaClient schemaClient = new CustomSchemaClient(schemaUri); Deque processingStack = new ArrayDeque<>(); Set processedFields = new HashSet<>(); - Map fieldTypesMap = entityFieldsCache.get(entityType); + Map fieldTypesMap = entityFieldsCache.get(entityType); addCustomProperties( typeEntity, schemaUri, schemaClient, fieldTypesMap, processingStack, processedFields); return convertMapToFieldList(fieldTypesMap); @@ -90,7 +90,7 @@ public Map> extractAllCustomProperties( SchemaClient schemaClient = new CustomSchemaClient(schemaUri); EntityUtil.Fields fieldsParam = new EntityUtil.Fields(Set.of("customProperties")); Type typeEntity = repository.getByName(uriInfo, entityType, fieldsParam, Include.ALL, false); - Map fieldTypesMap = new LinkedHashMap<>(); + Map fieldTypesMap = new LinkedHashMap<>(); Set processedFields = new HashSet<>(); Deque processingStack = new ArrayDeque<>(); addCustomProperties( @@ -170,7 +170,7 @@ private static Schema loadMainSchema( private static void extractFieldsFromSchema( Schema schema, String parentPath, - Map fieldTypesMap, + Map fieldTypesMap, Deque processingStack, Set processedFields) { if (processingStack.contains(schema)) { @@ -206,7 +206,8 @@ private static void extractFieldsFromSchema( arraySchema, fullFieldName, fieldTypesMap, processingStack, processedFields); } else { String fieldType = mapSchemaTypeToSimpleType(fieldSchema); - fieldTypesMap.putIfAbsent(fullFieldName, fieldType); + fieldTypesMap.putIfAbsent( + fullFieldName, new FieldDefinition(fullFieldName, fieldType, null)); processedFields.add(fullFieldName); LOG.debug("Added field '{}', Type: '{}'", fullFieldName, fieldType); // Recursively process nested objects or arrays @@ -220,7 +221,7 @@ private static void extractFieldsFromSchema( handleArraySchema(arraySchema, parentPath, fieldTypesMap, processingStack, processedFields); } else { String fieldType = mapSchemaTypeToSimpleType(schema); - fieldTypesMap.putIfAbsent(parentPath, fieldType); + fieldTypesMap.putIfAbsent(parentPath, new FieldDefinition(parentPath, fieldType, null)); LOG.debug("Added field '{}', Type: '{}'", parentPath, fieldType); } } finally { @@ -231,7 +232,7 @@ private static void extractFieldsFromSchema( private static void handleReferenceSchema( ReferenceSchema referenceSchema, String fullFieldName, - Map fieldTypesMap, + Map fieldTypesMap, Deque processingStack, Set processedFields) { @@ -239,7 +240,8 @@ private static void handleReferenceSchema( String referenceType = determineReferenceType(refUri); if (referenceType != null) { - fieldTypesMap.putIfAbsent(fullFieldName, referenceType); + fieldTypesMap.putIfAbsent( + fullFieldName, new FieldDefinition(fullFieldName, referenceType, null)); processedFields.add(fullFieldName); LOG.debug("Added field '{}', Type: '{}'", fullFieldName, referenceType); if (referenceType.startsWith("array<") && referenceType.endsWith(">")) { @@ -255,7 +257,7 @@ private static void handleReferenceSchema( referredSchema, fullFieldName, fieldTypesMap, processingStack, processedFields); } } else { - fieldTypesMap.putIfAbsent(fullFieldName, "object"); + fieldTypesMap.putIfAbsent(fullFieldName, new FieldDefinition(fullFieldName, "object", null)); processedFields.add(fullFieldName); LOG.debug("Added field '{}', Type: 'object'", fullFieldName); extractFieldsFromSchema( @@ -270,7 +272,7 @@ private static void handleReferenceSchema( private static void handleArraySchema( ArraySchema arraySchema, String fullFieldName, - Map fieldTypesMap, + Map fieldTypesMap, Deque processingStack, Set processedFields) { @@ -282,7 +284,8 @@ private static void handleArraySchema( if (itemsReferenceType != null) { String arrayFieldType = "array<" + itemsReferenceType + ">"; - fieldTypesMap.putIfAbsent(fullFieldName, arrayFieldType); + fieldTypesMap.putIfAbsent( + fullFieldName, new FieldDefinition(fullFieldName, arrayFieldType, null)); processedFields.add(fullFieldName); LOG.debug("Added field '{}', Type: '{}'", fullFieldName, arrayFieldType); Schema referredItemsSchema = itemsReferenceSchema.getReferredSchema(); @@ -292,7 +295,8 @@ private static void handleArraySchema( } } String arrayType = mapSchemaTypeToSimpleType(itemsSchema); - fieldTypesMap.putIfAbsent(fullFieldName, "array<" + arrayType + ">"); + fieldTypesMap.putIfAbsent( + fullFieldName, new FieldDefinition(fullFieldName, "array<" + arrayType + ">", null)); processedFields.add(fullFieldName); LOG.debug("Added field '{}', Type: 'array<{}>'", fullFieldName, arrayType); @@ -306,7 +310,7 @@ private void addCustomProperties( Type typeEntity, String schemaUri, SchemaClient schemaClient, - Map fieldTypesMap, + Map fieldTypesMap, Deque processingStack, Set processedFields) { if (typeEntity == null || typeEntity.getCustomProperties() == null) { @@ -320,9 +324,13 @@ private void addCustomProperties( LOG.debug("Processing custom property '{}'", fullFieldName); + Object customPropertyConfigObj = customProperty.getCustomPropertyConfig(); + if (isEntityReferenceList(propertyType)) { String referenceType = "array"; - fieldTypesMap.putIfAbsent(fullFieldName, referenceType); + FieldDefinition referenceFieldDefinition = + new FieldDefinition(fullFieldName, referenceType, customPropertyConfigObj); + fieldTypesMap.putIfAbsent(fullFieldName, referenceFieldDefinition); processedFields.add(fullFieldName); LOG.debug("Added custom property '{}', Type: '{}'", fullFieldName, referenceType); @@ -337,7 +345,9 @@ private void addCustomProperties( } } else if (isEntityReference(propertyType)) { String referenceType = "entityReference"; - fieldTypesMap.putIfAbsent(fullFieldName, referenceType); + FieldDefinition referenceFieldDefinition = + new FieldDefinition(fullFieldName, referenceType, customPropertyConfigObj); + fieldTypesMap.putIfAbsent(fullFieldName, referenceFieldDefinition); processedFields.add(fullFieldName); LOG.debug("Added custom property '{}', Type: '{}'", fullFieldName, referenceType); @@ -351,17 +361,22 @@ private void addCustomProperties( fullFieldName); } } else { - fieldTypesMap.putIfAbsent(fullFieldName, propertyType); + FieldDefinition entityFieldDefinition = + new FieldDefinition(fullFieldName, propertyType, customPropertyConfigObj); + fieldTypesMap.putIfAbsent(fullFieldName, entityFieldDefinition); processedFields.add(fullFieldName); LOG.debug("Added custom property '{}', Type: '{}'", fullFieldName, propertyType); } } } - private List convertMapToFieldList(Map fieldTypesMap) { + private List convertMapToFieldList(Map fieldTypesMap) { List fieldsList = new ArrayList<>(); - for (Map.Entry entry : fieldTypesMap.entrySet()) { - fieldsList.add(new FieldDefinition(entry.getKey(), entry.getValue())); + for (Map.Entry entry : fieldTypesMap.entrySet()) { + FieldDefinition fieldDef = entry.getValue(); + fieldsList.add( + new FieldDefinition( + fieldDef.getName(), fieldDef.getType(), fieldDef.getCustomPropertyConfig())); } return fieldsList; } @@ -622,10 +637,12 @@ private String mapUrlToResourcePath(String url) { public static class FieldDefinition { private String name; private String type; + private Object customPropertyConfig; - public FieldDefinition(String name, String type) { + public FieldDefinition(String name, String type, Object customPropertyConfig) { this.name = name; this.type = type; + this.customPropertyConfig = customPropertyConfig; } } } diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/AdvanceSearchCustomProperty.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/AdvanceSearchCustomProperty.spec.ts new file mode 100644 index 000000000000..1642742a5a22 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/AdvanceSearchCustomProperty.spec.ts @@ -0,0 +1,140 @@ +/* + * Copyright 2025 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 test, { expect } from '@playwright/test'; +import { CUSTOM_PROPERTIES_ENTITIES } from '../../constant/customProperty'; +import { GlobalSettingOptions } from '../../constant/settings'; +import { SidebarItem } from '../../constant/sidebar'; +import { TableClass } from '../../support/entity/TableClass'; +import { + selectOption, + showAdvancedSearchDialog, +} from '../../utils/advancedSearch'; +import { advanceSearchSaveFilter } from '../../utils/advancedSearchCustomProperty'; +import { createNewPage, redirectToHomePage, uuid } from '../../utils/common'; +import { addCustomPropertiesForEntity } from '../../utils/customProperty'; +import { settingClick, sidebarClick } from '../../utils/sidebar'; + +// use the admin user to login +test.use({ storageState: 'playwright/.auth/admin.json' }); + +test.describe('Advanced Search Custom Property', () => { + const table = new TableClass(); + const durationPropertyName = `pwCustomPropertyDurationTest${uuid()}`; + const durationPropertyValue = 'PT1H30M'; + + test.beforeAll('Setup pre-requests', async ({ browser }) => { + const { apiContext, afterAction } = await createNewPage(browser); + await table.create(apiContext); + await afterAction(); + }); + + test.afterAll('Cleanup', async ({ browser }) => { + const { apiContext, afterAction } = await createNewPage(browser); + await table.delete(apiContext); + await afterAction(); + }); + + test('Create, Assign and Test Advance Search for Duration', async ({ + page, + }) => { + test.slow(true); + + await redirectToHomePage(page); + + await test.step('Create and Assign Custom Property Value', async () => { + await settingClick(page, GlobalSettingOptions.TABLES, true); + + await addCustomPropertiesForEntity({ + page, + propertyName: durationPropertyName, + customPropertyData: CUSTOM_PROPERTIES_ENTITIES['entity_table'], + customType: 'Duration', + }); + + await table.visitEntityPage(page); + + await page.getByTestId('custom_properties').click(); // Tab Click + + await page + .getByTestId(`custom-property-${durationPropertyName}-card`) + .locator('svg') + .click(); // Add Custom Property Value + + await page.getByTestId('duration-input').fill(durationPropertyValue); + + const saveResponse = page.waitForResponse('/api/v1/tables/*'); + await page.getByTestId('inline-save-btn').click(); + await saveResponse; + }); + + await test.step('Verify Duration Type in Advance Search ', async () => { + await sidebarClick(page, SidebarItem.EXPLORE); + + await showAdvancedSearchDialog(page); + + const ruleLocator = page.locator('.rule').nth(0); + + // Perform click on rule field + await selectOption( + page, + ruleLocator.locator('.rule--field .ant-select'), + 'Custom Properties' + ); + + // Perform click on custom property type to filter + await selectOption( + page, + ruleLocator.locator('.rule--field .ant-select'), + durationPropertyName + ); + + const inputElement = ruleLocator.locator( + '.rule--widget--TEXT input[type="text"]' + ); + + await inputElement.fill(durationPropertyValue); + + await advanceSearchSaveFilter(page, durationPropertyValue); + + await expect( + page.getByTestId( + `table-data-card_${table.entityResponseData.fullyQualifiedName}` + ) + ).toBeVisible(); + + // Check around the Partial Search Value + const partialSearchValue = durationPropertyValue.slice(0, 3); + + await page.getByTestId('advance-search-filter-btn').click(); + + await expect(page.locator('[role="dialog"].ant-modal')).toBeVisible(); + + // Perform click on operator + await selectOption( + page, + ruleLocator.locator('.rule--operator .ant-select'), + 'Contains' + ); + + await inputElement.fill(partialSearchValue); + + await advanceSearchSaveFilter(page, partialSearchValue); + + await expect( + page.getByTestId( + `table-data-card_${table.entityResponseData.fullyQualifiedName}` + ) + ).toBeVisible(); + }); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearchCustomProperty.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearchCustomProperty.ts new file mode 100644 index 000000000000..2540d71065b7 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearchCustomProperty.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2025 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'; + +export const advanceSearchSaveFilter = async ( + page: Page, + propertyValue: string +) => { + const searchResponse = page.waitForResponse( + '/api/v1/search/query?*index=dataAsset&from=0&size=10*' + ); + await page.getByTestId('apply-btn').click(); + + const res = await searchResponse; + const json = await res.json(); + + expect(JSON.stringify(json)).toContain(propertyValue); +}; 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 9f612e84c287..cd8cff8ea122 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 @@ -27,16 +27,15 @@ import { JsonTree, Utils as QbUtils, ValueField, + ValueSource, } from 'react-awesome-query-builder'; import { useHistory, useParams } from 'react-router-dom'; -import { - emptyJsonTree, - TEXT_FIELD_OPERATORS, -} from '../../../constants/AdvancedSearch.constants'; +import { emptyJsonTree } from '../../../constants/AdvancedSearch.constants'; import { SearchIndex } from '../../../enums/search.enum'; import useCustomLocation from '../../../hooks/useCustomLocation/useCustomLocation'; import { TabsInfoData } from '../../../pages/ExplorePage/ExplorePage.interface'; import { getAllCustomProperties } from '../../../rest/metadataTypeAPI'; +import advancedSearchClassBase from '../../../utils/AdvancedSearchClassBase'; import { getTierOptions, getTreeConfig, @@ -225,15 +224,24 @@ export const AdvanceSearchProvider = ({ Object.entries(res).forEach(([_, fields]) => { if (Array.isArray(fields) && fields.length > 0) { - fields.forEach((field: { name: string; type: string }) => { - if (field.name && field.type) { - subfields[field.name] = { - type: 'text', - valueSources: ['value'], - operators: TEXT_FIELD_OPERATORS, + fields.forEach( + (field: { + name: string; + type: string; + customPropertyConfig: { + config: string | string[]; }; + }) => { + if (field.name && field.type) { + const { subfieldsKey, dataObject } = + advancedSearchClassBase.getCustomPropertiesSubFields(field); + subfields[subfieldsKey] = { + ...dataObject, + valueSources: dataObject.valueSources as ValueSource[], + }; + } } - }); + ); } }); } catch (error) { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/AdvanceSearchProvider/AdvanceSearchProvider.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Explore/AdvanceSearchProvider/AdvanceSearchProvider.interface.ts index 65506e5ab97d..9f7bbe6b11e6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Explore/AdvanceSearchProvider/AdvanceSearchProvider.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/AdvanceSearchProvider/AdvanceSearchProvider.interface.ts @@ -51,3 +51,7 @@ export interface AdvanceSearchContext { } export type FilterObject = Record; +export interface CustomPropertyEnumConfig { + multiSelect: boolean; + values: string[]; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/PropertyValue.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/PropertyValue.tsx index 20d276522084..38ec5a201a1c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/PropertyValue.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/PropertyValue.tsx @@ -905,7 +905,7 @@ export const PropertyValue: FC = ({ return (
diff --git a/openmetadata-ui/src/main/resources/ui/src/constants/AdvancedSearch.constants.ts b/openmetadata-ui/src/main/resources/ui/src/constants/AdvancedSearch.constants.ts index 74a10290ad13..219264b059c2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/constants/AdvancedSearch.constants.ts +++ b/openmetadata-ui/src/main/resources/ui/src/constants/AdvancedSearch.constants.ts @@ -299,6 +299,16 @@ export const TEXT_FIELD_OPERATORS = [ 'is_null', 'is_not_null', ]; + +export const RANGE_FIELD_OPERATORS = ['between', 'not_between']; + +export const LIST_VALUE_OPERATORS = [ + 'select_equals', + 'select_not_equals', + 'is_null', + 'is_not_null', +]; + /** * Generates a query builder tree with a group containing an empty rule */ diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/connections/pipeline/airbyteConnection.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/connections/pipeline/airbyteConnection.ts index e257fdcd17d8..ed2040e65ebd 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/connections/pipeline/airbyteConnection.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/connections/pipeline/airbyteConnection.ts @@ -1,5 +1,5 @@ /* - * Copyright 2024 Collate. + * Copyright 2025 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 @@ -10,9 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - - - /** +/** * Airbyte Metadata Database Connection Config */ export interface AirbyteConnection { diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/metadataIngestion/dbtPipeline.ts b/openmetadata-ui/src/main/resources/ui/src/generated/metadataIngestion/dbtPipeline.ts index 7374438ec427..07d5c6194c3f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/metadataIngestion/dbtPipeline.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/metadataIngestion/dbtPipeline.ts @@ -1,5 +1,5 @@ /* - * Copyright 2024 Collate. + * Copyright 2025 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 diff --git a/openmetadata-ui/src/main/resources/ui/src/styles/components/react-awesome-query.less b/openmetadata-ui/src/main/resources/ui/src/styles/components/react-awesome-query.less index d9bb04362eb7..e52449a79565 100644 --- a/openmetadata-ui/src/main/resources/ui/src/styles/components/react-awesome-query.less +++ b/openmetadata-ui/src/main/resources/ui/src/styles/components/react-awesome-query.less @@ -255,6 +255,15 @@ } } +.rule--widget--NUMBER { + .widget--valuesrc { + display: none; + } + .ant-input-number { + width: 100%; + } +} + .rule--widget { min-width: 180px; .ant-select, diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.ts b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.ts index 6afe1cdcef36..8a35255f46b6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.ts @@ -22,11 +22,20 @@ import { SelectFieldSettings, } from 'react-awesome-query-builder'; import AntdConfig from 'react-awesome-query-builder/lib/config/antd'; -import { SEARCH_INDICES_WITH_COLUMNS_FIELD } from '../constants/AdvancedSearch.constants'; +import { CustomPropertyEnumConfig } from '../components/Explore/AdvanceSearchProvider/AdvanceSearchProvider.interface'; +import { + LIST_VALUE_OPERATORS, + RANGE_FIELD_OPERATORS, + SEARCH_INDICES_WITH_COLUMNS_FIELD, + TEXT_FIELD_OPERATORS, +} from '../constants/AdvancedSearch.constants'; import { EntityFields, SuggestionField } from '../enums/AdvancedSearch.enum'; import { SearchIndex } from '../enums/search.enum'; import { getAggregateFieldOptions } from '../rest/miscAPI'; -import { renderAdvanceSearchButtons } from './AdvancedSearchUtils'; +import { + getCustomPropertyAdvanceSearchEnumOptions, + renderAdvanceSearchButtons, +} from './AdvancedSearchUtils'; import { getCombinedQueryFilterObject } from './ExplorePage/ExplorePageUtils'; import { renderQueryBuilderFilterButtons } from './QueryBuilderUtils'; @@ -208,12 +217,7 @@ class AdvancedSearchClassBase { [EntityFields.COLUMN_DESCRIPTION_STATUS]: { label: t('label.column-description'), type: 'select', - operators: [ - 'select_equals', - 'select_not_equals', - 'is_null', - 'is_not_null', - ], + operators: LIST_VALUE_OPERATORS, mainWidgetProps: this.mainWidgetProps, valueSources: ['value'], fieldSettings: { @@ -680,12 +684,7 @@ class AdvancedSearchClassBase { descriptionStatus: { label: t('label.description'), type: 'select', - operators: [ - 'select_equals', - 'select_not_equals', - 'is_null', - 'is_not_null', - ], + operators: LIST_VALUE_OPERATORS, mainWidgetProps: this.mainWidgetProps, valueSources: ['value'], fieldSettings: { @@ -899,6 +898,86 @@ class AdvancedSearchClassBase { }, }; }; + + public getCustomPropertiesSubFields(field: { + name: string; + type: string; + customPropertyConfig: { + config: string | string[] | CustomPropertyEnumConfig; + }; + }) { + { + switch (field.type) { + case 'array': + case 'entityReference': + return { + subfieldsKey: field.name + `.displayName`, + dataObject: { + type: 'select', + label: field.name, + fieldSettings: { + asyncFetch: this.autocomplete({ + searchIndex: ( + (field.customPropertyConfig.config ?? []) as string[] + ).join(',') as SearchIndex, + entityField: EntityFields.DISPLAY_NAME_KEYWORD, + }), + useAsyncSearch: true, + }, + }, + }; + + case 'enum': + return { + subfieldsKey: field.name, + dataObject: { + type: 'select', + operators: LIST_VALUE_OPERATORS, + fieldSettings: { + listValues: getCustomPropertyAdvanceSearchEnumOptions( + ( + field.customPropertyConfig + .config as CustomPropertyEnumConfig + ).values + ), + }, + }, + }; + + case 'date-cp': { + return { + subfieldsKey: field.name, + dataObject: { + type: 'date', + operators: RANGE_FIELD_OPERATORS, + }, + }; + } + + case 'timestamp': + case 'integer': + case 'number': { + return { + subfieldsKey: field.name, + dataObject: { + type: 'number', + operators: RANGE_FIELD_OPERATORS, + }, + }; + } + + default: + return { + subfieldsKey: field.name, + dataObject: { + type: 'text', + valueSources: ['value'], + operators: TEXT_FIELD_OPERATORS, + }, + }; + } + } + } } const advancedSearchClassBase = new AdvancedSearchClassBase(); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchUtils.tsx index 5111714d0874..ad6f119a80e6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchUtils.tsx @@ -14,7 +14,7 @@ import Icon, { CloseCircleOutlined, PlusOutlined } from '@ant-design/icons'; import { Button, Checkbox, MenuProps, Space, Typography } from 'antd'; import i18next from 'i18next'; -import { isArray, isEmpty } from 'lodash'; +import { isArray, isEmpty, toLower } from 'lodash'; import React from 'react'; import { AsyncFetchListValues, @@ -452,3 +452,25 @@ export const getTreeConfig = ({ ? advancedSearchClassBase.getQbConfigs(tierOptions, index, isExplorePage) : jsonLogicSearchClassBase.getQbConfigs(tierOptions, index, isExplorePage); }; + +export const formatQueryValueBasedOnType = ( + value: string[], + field: string, + type: string +) => { + if (field.includes('extension') && type === 'text') { + return value.map((item) => toLower(item)); + } + + return value; +}; + +export const getCustomPropertyAdvanceSearchEnumOptions = ( + enumValues: string[] +) => { + return enumValues.reduce((acc: Record, value) => { + acc[value] = value; + + return acc; + }, {}); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderElasticsearchFormatUtils.js b/openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderElasticsearchFormatUtils.js index 74b81de257fc..a2de5a564fbd 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderElasticsearchFormatUtils.js +++ b/openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderElasticsearchFormatUtils.js @@ -15,6 +15,7 @@ import { defaultConjunction } from 'react-awesome-query-builder/lib/utils/defaultUtils'; import { getWidgetForFieldOp } from 'react-awesome-query-builder/lib/utils/ruleUtils'; +import { formatQueryValueBasedOnType } from './AdvancedSearchUtils'; /* * This script is a modified version of https://github.com/ukrbublik/react-awesome-query-builder/blob/5.1.2/modules/export/elasticSearch.js @@ -372,7 +373,13 @@ export function elasticSearchFormat(tree, config) { }, }; } else { - return buildEsRule(field, value, operator, config, valueSrc); + return buildEsRule( + field, + formatQueryValueBasedOnType(value, field, _valueType), + operator, + config, + valueSrc + ); } } From 6529e8cf6cd94c1b580148212fa19169daf31773 Mon Sep 17 00:00:00 2001 From: sonika-shah <58761340+sonika-shah@users.noreply.github.com> Date: Wed, 8 Jan 2025 08:55:59 +0530 Subject: [PATCH 074/294] Improvement #19065 : Support removing existing enumKeys (for enum type custom property) (#19054) * Improvement : Support removing existing enumKeys (for enum type custom property) * initial implementation of background job job runner for enum cleanup * fix postgres query * ui: allow existing enum value updates in EditCustomPropertyModal * create BackgroundJob to use jsonSchema * feat: add socket event handling for background job part1 * fix(i18n): remove trailing spaces in Galician language JSON file * refactor to use jsonSchema * feat(ui): show browser notification for custom property update * feat(utils): add getCustomPropertyEntityPathname function and tests * change enumCleanup trigger condition * feat(i18n): add enum property update message to multiple language JSON files * enhancement : improve execution flow and better exception handling with test --------- Co-authored-by: Sachin Chaurasiya Co-authored-by: Sriharsha Chintalapani (cherry picked from commit 5ccc0c5a2af7c4a074b6e90150bfb06f4abdcbe6) --- .../native/1.6.2/mysql/schemaChanges.sql | 18 ++ .../native/1.6.2/postgres/schemaChanges.sql | 18 ++ .../java/org/openmetadata/service/Entity.java | 3 + .../service/OpenMetadataApplication.java | 12 + .../service/jdbi3/CollectionDAO.java | 49 +++++ .../service/jdbi3/EntityRepository.java | 3 + .../service/jdbi3/TypeRepository.java | 56 ++++- .../ConnectionAwareAnnotationSqlLocator.java | 17 ++ .../locator/ConnectionAwareSqlBatch.java | 16 ++ .../ConnectionAwareSqlBatchContainer.java | 15 ++ .../service/jobs/BackgroundJobException.java | 19 ++ .../service/jobs/EnumCleanupHandler.java | 81 +++++++ .../service/jobs/GenericBackgroundWorker.java | 124 +++++++++++ .../org/openmetadata/service/jobs/JobDAO.java | 113 ++++++++++ .../openmetadata/service/jobs/JobHandler.java | 9 + .../service/jobs/JobHandlerRegistry.java | 26 +++ .../service/socket/WebSocketManager.java | 1 + .../jobs/BackgroundJobWorkerTest.java | 207 ++++++++++++++++++ .../service/OpenMetadataApplicationTest.java | 2 + .../resources/metadata/TypeResourceTest.java | 19 +- .../json/schema/jobs/backgroundJob.json | 50 +++++ .../json/schema/jobs/enumCleanupArgs.json | 26 +++ .../ui/src/components/NavBar/NavBar.tsx | 49 ++++- .../EditCustomPropertyModal.tsx | 20 +- .../resources/ui/src/constants/constants.ts | 1 + .../ui/src/generated/jobs/backgroundJob.ts | 87 ++++++++ .../ui/src/generated/jobs/enumCleanupArgs.ts | 29 +++ .../ui/src/locale/languages/de-de.json | 4 +- .../ui/src/locale/languages/en-us.json | 4 +- .../ui/src/locale/languages/es-es.json | 4 +- .../ui/src/locale/languages/fr-fr.json | 4 +- .../ui/src/locale/languages/gl-es.json | 4 +- .../ui/src/locale/languages/he-he.json | 4 +- .../ui/src/locale/languages/ja-jp.json | 4 +- .../ui/src/locale/languages/mr-in.json | 6 +- .../ui/src/locale/languages/nl-nl.json | 4 +- .../ui/src/locale/languages/pr-pr.json | 4 +- .../ui/src/locale/languages/pt-br.json | 4 +- .../ui/src/locale/languages/pt-pt.json | 2 + .../ui/src/locale/languages/ru-ru.json | 4 +- .../ui/src/locale/languages/th-th.json | 2 + .../ui/src/locale/languages/zh-cn.json | 4 +- .../ui/src/utils/CustomProperty.utils.test.ts | 34 +++ .../ui/src/utils/CustomProperty.utils.ts | 20 ++ 44 files changed, 1147 insertions(+), 35 deletions(-) create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/locator/ConnectionAwareSqlBatch.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/locator/ConnectionAwareSqlBatchContainer.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/jobs/BackgroundJobException.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/jobs/EnumCleanupHandler.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/jobs/GenericBackgroundWorker.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/jobs/JobDAO.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/jobs/JobHandler.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/jobs/JobHandlerRegistry.java create mode 100644 openmetadata-service/src/test/java/org/openmetadata/jobs/BackgroundJobWorkerTest.java create mode 100644 openmetadata-spec/src/main/resources/json/schema/jobs/backgroundJob.json create mode 100644 openmetadata-spec/src/main/resources/json/schema/jobs/enumCleanupArgs.json create mode 100644 openmetadata-ui/src/main/resources/ui/src/generated/jobs/backgroundJob.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/generated/jobs/enumCleanupArgs.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/CustomProperty.utils.test.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/CustomProperty.utils.ts diff --git a/bootstrap/sql/migrations/native/1.6.2/mysql/schemaChanges.sql b/bootstrap/sql/migrations/native/1.6.2/mysql/schemaChanges.sql index eb23ec4a2ee0..49ea32e96d49 100644 --- a/bootstrap/sql/migrations/native/1.6.2/mysql/schemaChanges.sql +++ b/bootstrap/sql/migrations/native/1.6.2/mysql/schemaChanges.sql @@ -1,6 +1,23 @@ -- add timestamp index for test case result reindex performance ALTER TABLE data_quality_data_time_series ADD INDEX `idx_timestamp_desc` (timestamp DESC); +CREATE TABLE background_jobs ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + jobType VARCHAR(256) NOT NULL, + methodName VARCHAR(256) NOT NULL, + jobArgs JSON NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + createdBy VARCHAR(256) NOT NULL, + createdAt BIGINT UNSIGNED NOT NULL DEFAULT (UNIX_TIMESTAMP(NOW(3)) * 1000), + updatedAt BIGINT UNSIGNED NOT NULL DEFAULT (UNIX_TIMESTAMP(NOW(3)) * 1000) +); + +CREATE INDEX idx_status_createdAt ON background_jobs (status, createdAt); +CREATE INDEX idx_createdBy ON background_jobs (createdBy); +CREATE INDEX idx_status ON background_jobs (status); +CREATE INDEX idx_jobType ON background_jobs (jobType); +CREATE INDEX idx_updatedAt ON background_jobs (updatedAt); + -- rename executable -> basic for test suites UPDATE test_suite SET json = JSON_INSERT( @@ -24,3 +41,4 @@ UPDATE test_case SET json = json_remove(json, '$.testSuites'); -- clean up the testSuites in the version history too UPDATE entity_extension SET json = json_remove(json, '$.testSuites') WHERE jsonSchema = 'testCase'; + diff --git a/bootstrap/sql/migrations/native/1.6.2/postgres/schemaChanges.sql b/bootstrap/sql/migrations/native/1.6.2/postgres/schemaChanges.sql index a1ee9a6c6e70..2b517db2ee7e 100644 --- a/bootstrap/sql/migrations/native/1.6.2/postgres/schemaChanges.sql +++ b/bootstrap/sql/migrations/native/1.6.2/postgres/schemaChanges.sql @@ -1,6 +1,23 @@ -- add timestamp index for test case result reindex performance CREATE INDEX idx_timestamp_desc ON data_quality_data_time_series (timestamp DESC); +CREATE TABLE background_jobs ( + id BIGSERIAL PRIMARY KEY, + jobType VARCHAR(256) NOT NULL, + methodName VARCHAR(256) NOT NULL, + jobArgs JSONB NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + createdBy VARCHAR(256) NOT NULL, + createdAt BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT, + updatedAt BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT +); + +CREATE INDEX idx_status_createdAt ON background_jobs (status, createdAt); +CREATE INDEX idx_createdBy ON background_jobs (createdBy); +CREATE INDEX idx_status ON background_jobs (status); +CREATE INDEX idx_jobType ON background_jobs (jobType); +CREATE INDEX idx_updatedAt ON background_jobs (updatedAt); + -- rename executable -> basic for test suites UPDATE test_suite SET json = jsonb_set( @@ -26,3 +43,4 @@ UPDATE test_case SET json = json::jsonb #- '{testSuites}'; -- clean up the testSuites in the version history too UPDATE entity_extension SET json = json::jsonb #- '{testSuites}' WHERE jsonSchema = 'testCase'; + diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java b/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java index 9ee396193102..97b2b5e1307d 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java @@ -67,6 +67,7 @@ import org.openmetadata.service.jdbi3.SystemRepository; import org.openmetadata.service.jdbi3.TokenRepository; import org.openmetadata.service.jdbi3.UsageRepository; +import org.openmetadata.service.jobs.JobDAO; import org.openmetadata.service.resources.feeds.MessageParser.EntityLink; import org.openmetadata.service.search.SearchRepository; import org.openmetadata.service.search.indexes.SearchIndex; @@ -77,6 +78,7 @@ public final class Entity { private static volatile boolean initializedRepositories = false; @Getter @Setter private static CollectionDAO collectionDAO; + @Getter @Setter private static JobDAO jobDAO; @Getter @Setter private static Jdbi jdbi; public static final String SEPARATOR = "."; // Fully qualified name separator @@ -314,6 +316,7 @@ public static void initializeRepositories(OpenMetadataApplicationConfig config, public static void cleanup() { initializedRepositories = false; collectionDAO = null; + jobDAO = null; searchRepository = null; ENTITY_REPOSITORY_MAP.clear(); } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/OpenMetadataApplication.java b/openmetadata-service/src/main/java/org/openmetadata/service/OpenMetadataApplication.java index 8efa36c4bc47..ee00e54cf121 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/OpenMetadataApplication.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/OpenMetadataApplication.java @@ -85,6 +85,10 @@ import org.openmetadata.service.jdbi3.MigrationDAO; import org.openmetadata.service.jdbi3.locator.ConnectionAwareAnnotationSqlLocator; import org.openmetadata.service.jdbi3.locator.ConnectionType; +import org.openmetadata.service.jobs.EnumCleanupHandler; +import org.openmetadata.service.jobs.GenericBackgroundWorker; +import org.openmetadata.service.jobs.JobDAO; +import org.openmetadata.service.jobs.JobHandlerRegistry; import org.openmetadata.service.limits.DefaultLimits; import org.openmetadata.service.limits.Limits; import org.openmetadata.service.migration.Migration; @@ -163,6 +167,7 @@ public void run(OpenMetadataApplicationConfig catalogConfig, Environment environ jdbi = createAndSetupJDBI(environment, catalogConfig.getDataSourceFactory()); Entity.setCollectionDAO(getDao(jdbi)); + Entity.setJobDAO(jdbi.onDemand(JobDAO.class)); Entity.setJdbi(jdbi); initializeSearchRepository(catalogConfig.getElasticSearchConfiguration()); @@ -234,6 +239,13 @@ public void run(OpenMetadataApplicationConfig catalogConfig, Environment environ // Register Event Handler registerEventFilter(catalogConfig, environment); environment.lifecycle().manage(new ManagedShutdown()); + + JobHandlerRegistry registry = new JobHandlerRegistry(); + registry.register("EnumCleanupHandler", new EnumCleanupHandler(getDao(jdbi))); + environment + .lifecycle() + .manage(new GenericBackgroundWorker(jdbi.onDemand(JobDAO.class), registry)); + // Register Event publishers registerEventPublisher(catalogConfig); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java index 5926e5413af3..3bf706b624be 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java @@ -46,6 +46,7 @@ import org.jdbi.v3.sqlobject.CreateSqlObject; import org.jdbi.v3.sqlobject.config.RegisterRowMapper; import org.jdbi.v3.sqlobject.customizer.Bind; +import org.jdbi.v3.sqlobject.customizer.BindBean; import org.jdbi.v3.sqlobject.customizer.BindBeanList; import org.jdbi.v3.sqlobject.customizer.BindList; import org.jdbi.v3.sqlobject.customizer.BindMap; @@ -53,6 +54,7 @@ import org.jdbi.v3.sqlobject.statement.SqlQuery; import org.jdbi.v3.sqlobject.statement.SqlUpdate; import org.jdbi.v3.sqlobject.statement.UseRowMapper; +import org.jdbi.v3.sqlobject.transaction.Transaction; import org.openmetadata.api.configuration.UiThemePreference; import org.openmetadata.schema.TokenInterface; import org.openmetadata.schema.analytics.ReportData; @@ -141,6 +143,7 @@ import org.openmetadata.service.jdbi3.CollectionDAO.TagUsageDAO.TagLabelMapper; import org.openmetadata.service.jdbi3.CollectionDAO.UsageDAO.UsageDetailsMapper; import org.openmetadata.service.jdbi3.FeedRepository.FilterType; +import org.openmetadata.service.jdbi3.locator.ConnectionAwareSqlBatch; import org.openmetadata.service.jdbi3.locator.ConnectionAwareSqlQuery; import org.openmetadata.service.jdbi3.locator.ConnectionAwareSqlUpdate; import org.openmetadata.service.resources.events.subscription.TypedEvent; @@ -731,6 +734,30 @@ void insert( List getExtensionsBatch( @BindList("ids") List ids, @Bind("extensionPrefix") String extensionPrefix); + @SqlQuery( + "SELECT id, extension, json, jsonschema " + + "FROM entity_extension " + + "WHERE extension LIKE CONCAT(:extensionPrefix, '%') " + + "ORDER BY id, extension") + @RegisterRowMapper(ExtensionWithIdAndSchemaRowMapper.class) + List getExtensionsByPrefixBatch( + @Bind("extensionPrefix") String extensionPrefix); + + @Transaction + @ConnectionAwareSqlBatch( + value = + "INSERT INTO entity_extension (id, extension, json, jsonschema) " + + "VALUES (:id, :extension, :json, :jsonschema) " + + "ON DUPLICATE KEY UPDATE json = VALUES(json), jsonschema = VALUES(jsonschema)", + connectionType = MYSQL) + @ConnectionAwareSqlBatch( + value = + "INSERT INTO entity_extension (id, extension, json,jsonschema) VALUES (:id, :extension, :json::jsonb,:jsonschema) " + + "ON CONFLICT (id, extension) DO UPDATE SET json = EXCLUDED.json , jsonschema = EXCLUDED.jsonschema", + connectionType = POSTGRES) + void bulkUpsertExtensions( + @BindBean List extensionWithIdObjects); + @RegisterRowMapper(ExtensionMapper.class) @SqlQuery( "SELECT extension, json FROM entity_extension WHERE id = :id AND extension " @@ -792,6 +819,28 @@ public ExtensionRecordWithId map(ResultSet rs, StatementContext ctx) throws SQLE } } + @Getter + @Setter + @Builder + class ExtensionWithIdAndSchemaObject { + private String id; + private String extension; + private String json; + private String jsonschema; + } + + class ExtensionWithIdAndSchemaRowMapper implements RowMapper { + @Override + public ExtensionWithIdAndSchemaObject map(ResultSet rs, StatementContext ctx) + throws SQLException { + String id = rs.getString("id"); + String extensionName = rs.getString("extension"); + String extensionJson = rs.getString("json"); + String jsonSchema = rs.getString("jsonschema"); + return new ExtensionWithIdAndSchemaObject(id, extensionName, extensionJson, jsonSchema); + } + } + @Getter @Builder class EntityRelationshipRecord { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java index 7eac71fda0a0..4c517924e745 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java @@ -169,6 +169,7 @@ import org.openmetadata.service.jdbi3.CollectionDAO.ExtensionRecord; import org.openmetadata.service.jdbi3.FeedRepository.TaskWorkflow; import org.openmetadata.service.jdbi3.FeedRepository.ThreadContext; +import org.openmetadata.service.jobs.JobDAO; import org.openmetadata.service.resources.tags.TagLabelUtil; import org.openmetadata.service.search.SearchClient; import org.openmetadata.service.search.SearchListFilter; @@ -237,6 +238,7 @@ public record EntityHistoryWithOffset(EntityHistory entityHistory, int nextOffse @Getter protected final String entityType; @Getter protected final EntityDAO dao; @Getter protected final CollectionDAO daoCollection; + @Getter protected final JobDAO jobDao; @Getter protected final SearchRepository searchRepository; @Getter protected final Set allowedFields; public final boolean supportsSoftDelete; @@ -278,6 +280,7 @@ protected EntityRepository( allowedFields = getEntityFields(entityClass); this.dao = entityDAO; this.daoCollection = Entity.getCollectionDAO(); + this.jobDao = Entity.getJobDAO(); this.searchRepository = Entity.getSearchRepository(); this.entityType = entityType; this.patchFields = getFields(patchFields); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TypeRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TypeRepository.java index 1d9078a264f6..8bde739c0b4c 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TypeRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TypeRepository.java @@ -38,6 +38,8 @@ import org.openmetadata.schema.entity.Type; import org.openmetadata.schema.entity.type.Category; import org.openmetadata.schema.entity.type.CustomProperty; +import org.openmetadata.schema.jobs.BackgroundJob; +import org.openmetadata.schema.jobs.EnumCleanupArgs; import org.openmetadata.schema.type.CustomPropertyConfig; import org.openmetadata.schema.type.EntityReference; import org.openmetadata.schema.type.Include; @@ -47,6 +49,7 @@ import org.openmetadata.service.Entity; import org.openmetadata.service.TypeRegistry; import org.openmetadata.service.exception.CatalogExceptionMessage; +import org.openmetadata.service.jobs.EnumCleanupHandler; import org.openmetadata.service.resources.types.TypeResource; import org.openmetadata.service.util.EntityUtil; import org.openmetadata.service.util.EntityUtil.Fields; @@ -121,6 +124,11 @@ public EntityUpdater getUpdater(Type original, Type updated, Operation operation return new TypeUpdater(original, updated, operation); } + @Override + public void postUpdate(Type original, Type updated) { + super.postUpdate(original, updated); + } + public PutResponse addCustomProperty( UriInfo uriInfo, String updatedBy, UUID id, CustomProperty property) { Type type = find(id, Include.NON_DELETED); @@ -428,6 +436,7 @@ private void updateCustomPropertyConfig( Relationship.HAS.ordinal(), "customProperty", customPropertyJson); + postUpdateCustomPropertyConfig(entity, origProperty, updatedProperty); } } @@ -443,9 +452,50 @@ private void validatePropertyConfigUpdate( HashSet updatedValues = new HashSet<>(updatedConfig.getValues()); if (updatedValues.size() != updatedConfig.getValues().size()) { throw new IllegalArgumentException("Enum Custom Property values cannot have duplicates."); - } else if (!updatedValues.containsAll(origConfig.getValues())) { - throw new IllegalArgumentException( - "Existing Enum Custom Property values cannot be removed."); + } + } + } + + private void postUpdateCustomPropertyConfig( + Type entity, CustomProperty origProperty, CustomProperty updatedProperty) { + String updatedBy = entity.getUpdatedBy(); + if (origProperty.getPropertyType().getName().equals("enum")) { + EnumConfig origConfig = + JsonUtils.convertValue( + origProperty.getCustomPropertyConfig().getConfig(), EnumConfig.class); + EnumConfig updatedConfig = + JsonUtils.convertValue( + updatedProperty.getCustomPropertyConfig().getConfig(), EnumConfig.class); + HashSet origKeys = new HashSet<>(origConfig.getValues()); + HashSet updatedKeys = new HashSet<>(updatedConfig.getValues()); + + HashSet removedKeys = new HashSet<>(origKeys); + removedKeys.removeAll(updatedKeys); + HashSet addedKeys = new HashSet<>(updatedKeys); + addedKeys.removeAll(origKeys); + + if (!removedKeys.isEmpty() && addedKeys.isEmpty()) { + List removedEnumKeys = new ArrayList<>(removedKeys); + + try { + EnumCleanupArgs enumCleanupArgs = + new EnumCleanupArgs() + .withPropertyName(updatedProperty.getName()) + .withRemovedEnumKeys(removedEnumKeys) + .withEntityType(entity.getName()); + + String jobArgs = JsonUtils.pojoToJson(enumCleanupArgs); + long jobId = + jobDao.insertJob( + BackgroundJob.JobType.CUSTOM_PROPERTY_ENUM_CLEANUP, + new EnumCleanupHandler(daoCollection), + jobArgs, + updatedBy); + + } catch (Exception e) { + LOG.error("Failed to trigger background job for enum cleanup", e); + throw new RuntimeException("Failed to trigger background job", e); + } } } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/locator/ConnectionAwareAnnotationSqlLocator.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/locator/ConnectionAwareAnnotationSqlLocator.java index 9e0a70e4e782..a4d7a34ee011 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/locator/ConnectionAwareAnnotationSqlLocator.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/locator/ConnectionAwareAnnotationSqlLocator.java @@ -19,6 +19,7 @@ import java.lang.reflect.Method; import java.util.Arrays; +import java.util.List; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; @@ -74,6 +75,22 @@ private Optional getAnnotationValue(Method method) { .findFirst()) .flatMap(identity()) // Unwrap Option> to Optional .map(ConnectionAwareSqlQuery::value), + () -> { + Optional containerAnnotation = + Optional.ofNullable(method.getAnnotation(ConnectionAwareSqlBatchContainer.class)); + + Optional> annotationsList = + containerAnnotation.map(ConnectionAwareSqlBatchContainer::value).map(Arrays::asList); + + Optional matchingAnnotation = + annotationsList.flatMap( + annotations -> + annotations.stream() + .filter(annotation -> annotation.connectionType().equals(connectionType)) + .findFirst()); + + return matchingAnnotation.map(ConnectionAwareSqlBatch::value); + }, () -> SqlAnnotations.getAnnotationValue(method)); } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/locator/ConnectionAwareSqlBatch.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/locator/ConnectionAwareSqlBatch.java new file mode 100644 index 000000000000..6b74526cca72 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/locator/ConnectionAwareSqlBatch.java @@ -0,0 +1,16 @@ +package org.openmetadata.service.jdbi3.locator; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD}) +@Repeatable(ConnectionAwareSqlBatchContainer.class) +public @interface ConnectionAwareSqlBatch { + String value() default ""; + + ConnectionType connectionType(); +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/locator/ConnectionAwareSqlBatchContainer.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/locator/ConnectionAwareSqlBatchContainer.java new file mode 100644 index 000000000000..663a16769b7b --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/locator/ConnectionAwareSqlBatchContainer.java @@ -0,0 +1,15 @@ +package org.openmetadata.service.jdbi3.locator; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.jdbi.v3.sqlobject.SqlOperation; +import org.jdbi.v3.sqlobject.statement.internal.SqlBatchHandler; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD}) +@SqlOperation(SqlBatchHandler.class) +public @interface ConnectionAwareSqlBatchContainer { + ConnectionAwareSqlBatch[] value(); +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jobs/BackgroundJobException.java b/openmetadata-service/src/main/java/org/openmetadata/service/jobs/BackgroundJobException.java new file mode 100644 index 000000000000..429d35550a47 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jobs/BackgroundJobException.java @@ -0,0 +1,19 @@ +package org.openmetadata.service.jobs; + +public class BackgroundJobException extends RuntimeException { + private final long jobId; + + public BackgroundJobException(long jobId, String message) { + super(message); + this.jobId = jobId; + } + + public BackgroundJobException(long jobId, String message, Throwable cause) { + super(message, cause); + this.jobId = jobId; + } + + public long getJobId() { + return jobId; + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jobs/EnumCleanupHandler.java b/openmetadata-service/src/main/java/org/openmetadata/service/jobs/EnumCleanupHandler.java new file mode 100644 index 000000000000..28dceacde87d --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jobs/EnumCleanupHandler.java @@ -0,0 +1,81 @@ +package org.openmetadata.service.jobs; + +import static org.openmetadata.service.TypeRegistry.getCustomPropertyFQN; + +import com.fasterxml.jackson.core.type.TypeReference; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import org.openmetadata.schema.jobs.BackgroundJob; +import org.openmetadata.schema.jobs.EnumCleanupArgs; +import org.openmetadata.service.jdbi3.CollectionDAO; +import org.openmetadata.service.util.JsonUtils; + +@Slf4j +public class EnumCleanupHandler implements JobHandler { + private final CollectionDAO daoCollection; + + public EnumCleanupHandler(CollectionDAO daoCollection) { + this.daoCollection = daoCollection; + } + + @Override + public void runJob(BackgroundJob job) throws BackgroundJobException { + try { + Object jobArgs = job.getJobArgs(); + LOG.debug("Starting EnumCleanupHandler job with args: {}", jobArgs); + EnumCleanupArgs enumCleanupArgs; + try { + enumCleanupArgs = JsonUtils.convertValue(jobArgs, EnumCleanupArgs.class); + } catch (IllegalArgumentException e) { + throw new BackgroundJobException(job.getId(), "Invalid arguments " + jobArgs.toString()); + } + + String propertyName = enumCleanupArgs.getPropertyName(); + List removedEnumKeys = enumCleanupArgs.getRemovedEnumKeys(); + String entityType = enumCleanupArgs.getEntityType(); + String customPropertyFQN = getCustomPropertyFQN(entityType, propertyName); + + List extensions = + daoCollection.entityExtensionDAO().getExtensionsByPrefixBatch(customPropertyFQN); + + List updatedExtensions = + extensions.stream() + .map( + extension -> { + List rowValues = + Optional.ofNullable(extension.getJson()) + .map( + json -> + JsonUtils.readValue(json, new TypeReference>() {})) + .orElseGet(ArrayList::new); + rowValues.removeAll(removedEnumKeys); + + return CollectionDAO.ExtensionWithIdAndSchemaObject.builder() + .id(extension.getId()) + .extension(extension.getExtension()) + .json(JsonUtils.pojoToJson(rowValues)) + .jsonschema(extension.getJsonschema()) + .build(); + }) + .collect(Collectors.toList()); + + LOG.debug("Updated extensions: {}", updatedExtensions); + daoCollection.entityExtensionDAO().bulkUpsertExtensions(updatedExtensions); + LOG.debug( + "Completed EnumCleanupHandler job for entityType: {}, propertyName: {}", + entityType, + propertyName); + } catch (Exception e) { + throw new BackgroundJobException( + job.getId(), "Failed to run EnumCleanupHandler job. Error:" + e.getMessage(), e); + } + } + + @Override + public boolean sendStatusToWebSocket() { + return true; + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jobs/GenericBackgroundWorker.java b/openmetadata-service/src/main/java/org/openmetadata/service/jobs/GenericBackgroundWorker.java new file mode 100644 index 000000000000..684aa9a09b65 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jobs/GenericBackgroundWorker.java @@ -0,0 +1,124 @@ +package org.openmetadata.service.jobs; + +import io.dropwizard.lifecycle.Managed; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; +import org.openmetadata.schema.entity.teams.User; +import org.openmetadata.schema.jobs.BackgroundJob; +import org.openmetadata.service.Entity; +import org.openmetadata.service.socket.WebSocketManager; +import org.openmetadata.service.util.FullyQualifiedName; +import org.openmetadata.service.util.JsonUtils; + +@Slf4j +public class GenericBackgroundWorker implements Managed { + + private static final int INITIAL_BACKOFF_SECONDS = 1; + private static final int MAX_BACKOFF_SECONDS = 600; // 10 minutes + private static final int NO_JOB_SLEEP_SECONDS = 10; // Sleep if no jobs are available + + private final JobDAO jobDao; + private final JobHandlerRegistry handlerRegistry; + private volatile boolean running = true; + + public GenericBackgroundWorker(JobDAO jobDao, JobHandlerRegistry handlerRegistry) { + this.jobDao = jobDao; + this.handlerRegistry = handlerRegistry; + } + + @Override + public void start() throws Exception { + LOG.info("Starting background job worker"); + Thread workerThread = new Thread(this::runWorker, "background-job-worker"); + workerThread.setDaemon(true); + workerThread.start(); + } + + @Override + public void stop() throws Exception { + running = false; + } + + private void runWorker() { + int backoff = INITIAL_BACKOFF_SECONDS; + + while (running) { + try { + Optional jobOpt = jobDao.fetchPendingJob(); + if (jobOpt.isPresent()) { + processJob(jobOpt.get()); + backoff = INITIAL_BACKOFF_SECONDS; // Reset backoff after successful processing + } else { + sleep(NO_JOB_SLEEP_SECONDS); + } + } catch (BackgroundJobException e) { + long jobId = e.getJobId(); + jobDao.updateJobStatus(jobId, BackgroundJob.Status.FAILED); + LOG.error("Background Job {} failed. Error: {}", jobId, e.getMessage(), e); + } catch (Exception e) { + LOG.error("Unexpected error in background job worker: {}", e.getMessage(), e); + backoff = Math.min(backoff * 5, MAX_BACKOFF_SECONDS); // Exponential backoff with max limit + sleep(backoff); + } + } + + LOG.info("Background job worker terminated successfully."); + } + + private void processJob(BackgroundJob job) { + try { + jobDao.updateJobStatus(job.getId(), BackgroundJob.Status.RUNNING); + JobHandler handler = handlerRegistry.getHandler(job); + handler.runJob(job); + markJobAsCompleted(job); + + if (handler.sendStatusToWebSocket()) { + sendJobStatusUpdate(job); + } + } catch (BackgroundJobException e) { + markJobAsFailed(job, e); + sendJobStatusUpdate(job); + } + } + + private void markJobAsCompleted(BackgroundJob job) { + job.setStatus(BackgroundJob.Status.COMPLETED); + jobDao.updateJobStatus(job.getId(), BackgroundJob.Status.COMPLETED); + LOG.info( + "Background Job {} completed successfully. Type: {}, Method: {}", + job.getId(), + job.getJobType(), + job.getMethodName()); + } + + private void markJobAsFailed(BackgroundJob job, Exception e) { + job.setStatus(BackgroundJob.Status.FAILED); + jobDao.updateJobStatus(job.getId(), BackgroundJob.Status.FAILED); + LOG.error( + "Background Job {} failed. Type: {}, Method: {}. Error: {}", + job.getId(), + job.getJobType(), + job.getMethodName(), + e.getMessage(), + e); + } + + private void sleep(int seconds) { + try { + Thread.sleep(seconds * 1000L); + } catch (InterruptedException ie) { + LOG.error("Worker interrupted during sleep"); + Thread.currentThread().interrupt(); + } + } + + private void sendJobStatusUpdate(BackgroundJob job) { + String jsonMessage = JsonUtils.pojoToJson(job); + User user = + Entity.getCollectionDAO() + .userDAO() + .findEntityByName(FullyQualifiedName.quoteName(job.getCreatedBy())); + WebSocketManager.getInstance() + .sendToOne(user.getId(), WebSocketManager.BACKGROUND_JOB_CHANNEL, jsonMessage); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jobs/JobDAO.java b/openmetadata-service/src/main/java/org/openmetadata/service/jobs/JobDAO.java new file mode 100644 index 000000000000..73bb9997133b --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jobs/JobDAO.java @@ -0,0 +1,113 @@ +package org.openmetadata.service.jobs; + +import static org.openmetadata.service.jdbi3.locator.ConnectionType.MYSQL; +import static org.openmetadata.service.jdbi3.locator.ConnectionType.POSTGRES; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; +import org.jdbi.v3.core.mapper.RowMapper; +import org.jdbi.v3.core.statement.StatementContext; +import org.jdbi.v3.core.statement.StatementException; +import org.jdbi.v3.sqlobject.config.RegisterRowMapper; +import org.jdbi.v3.sqlobject.customizer.Bind; +import org.jdbi.v3.sqlobject.statement.GetGeneratedKeys; +import org.jdbi.v3.sqlobject.statement.SqlQuery; +import org.openmetadata.schema.jobs.BackgroundJob; +import org.openmetadata.service.jdbi3.locator.ConnectionAwareSqlUpdate; +import org.openmetadata.service.util.JsonUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public interface JobDAO { + + Logger LOG = LoggerFactory.getLogger(JobDAO.class); + + default long insertJob( + BackgroundJob.JobType jobType, JobHandler handler, String jobArgs, String createdBy) { + try { + JsonUtils.readTree(jobArgs); + } catch (Exception e) { + throw new IllegalArgumentException("jobArgs must be a valid JSON string"); + } + return insertJobInternal( + jobType.name(), handler.getClass().getSimpleName(), jobArgs, createdBy); + } + + @ConnectionAwareSqlUpdate( + value = + "INSERT INTO background_jobs (jobType, methodName, jobArgs, createdBy) " + + "VALUES (:jobType, :methodName, :jobArgs, :createdBy)", + connectionType = MYSQL) + @ConnectionAwareSqlUpdate( + value = + "INSERT INTO background_jobs (jobType, methodName, jobArgs,createdBy) VALUES (:jobType, :methodName, :jobArgs::jsonb,:createdBy) ", + connectionType = POSTGRES) + @GetGeneratedKeys + long insertJobInternal( + @Bind("jobType") String jobType, + @Bind("methodName") String methodName, + @Bind("jobArgs") String jobArgs, + @Bind("createdBy") String createdBy); + + default Optional fetchPendingJob() throws BackgroundJobException { + return Optional.ofNullable(fetchPendingJobInternal()); + } + + @SqlQuery( + "SELECT id,jobType,methodName,jobArgs,status,createdAt,updatedAt,createdBy FROM background_jobs WHERE status = 'PENDING' ORDER BY createdAt LIMIT 1") + @RegisterRowMapper(BackgroundJobMapper.class) + BackgroundJob fetchPendingJobInternal() throws StatementException; + + @ConnectionAwareSqlUpdate( + value = + "UPDATE background_jobs SET status = :status, updatedAt = (UNIX_TIMESTAMP(NOW(3)) * 1000) WHERE id = :id", + connectionType = MYSQL) + @ConnectionAwareSqlUpdate( + value = + "UPDATE background_jobs SET status = :status, updatedAt = (EXTRACT(EPOCH FROM NOW()) * 1000) WHERE id = :id", + connectionType = POSTGRES) + void updateJobStatusInternal(@Bind("id") long id, @Bind("status") String status); + + default void updateJobStatus(long id, BackgroundJob.Status status) { + updateJobStatusInternal(id, status.name()); + } + + @SqlQuery( + "SELECT id, jobType, methodName, jobArgs, status, createdAt, updatedAt, createdBy FROM background_jobs WHERE id = :id") + @RegisterRowMapper(BackgroundJobMapper.class) + BackgroundJob getJob(@Bind("id") long id) throws StatementException; + + default Optional fetchJobById(long id) { + return Optional.ofNullable(getJob(id)); + } + + @Slf4j + class BackgroundJobMapper implements RowMapper { + + @Override + public BackgroundJob map(ResultSet rs, StatementContext ctx) throws SQLException { + long jobId = rs.getLong("id"); + try { + BackgroundJob job = new BackgroundJob(); + job.setId(jobId); + job.setJobType(BackgroundJob.JobType.fromValue(rs.getString("jobType"))); + job.setMethodName(rs.getString("methodName")); + + String jobArgsJson = rs.getString("jobArgs"); + Object jobArgs = JsonUtils.readValue(jobArgsJson, Object.class); + job.setJobArgs(jobArgs); + + job.setStatus(BackgroundJob.Status.fromValue(rs.getString("status"))); + job.setCreatedAt(rs.getLong("createdAt")); + job.setUpdatedAt(rs.getLong("updatedAt")); + job.setCreatedBy(rs.getString("createdBy")); + + return job; + } catch (Exception e) { + throw new BackgroundJobException(jobId, "Failed to fetch/map pending job.", e); + } + } + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jobs/JobHandler.java b/openmetadata-service/src/main/java/org/openmetadata/service/jobs/JobHandler.java new file mode 100644 index 000000000000..2088ac786611 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jobs/JobHandler.java @@ -0,0 +1,9 @@ +package org.openmetadata.service.jobs; + +import org.openmetadata.schema.jobs.BackgroundJob; + +public interface JobHandler { + void runJob(BackgroundJob job) throws BackgroundJobException; + + boolean sendStatusToWebSocket(); +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jobs/JobHandlerRegistry.java b/openmetadata-service/src/main/java/org/openmetadata/service/jobs/JobHandlerRegistry.java new file mode 100644 index 000000000000..2b64249742cd --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jobs/JobHandlerRegistry.java @@ -0,0 +1,26 @@ +package org.openmetadata.service.jobs; + +import java.util.HashMap; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.openmetadata.schema.jobs.BackgroundJob; + +@Slf4j +public class JobHandlerRegistry { + private final Map handlers = new HashMap<>(); + + public void register(String methodName, JobHandler handler) { + LOG.info("Registering background job handler for: {}", handler.getClass().getSimpleName()); + handlers.put(methodName, handler); + } + + public JobHandler getHandler(BackgroundJob job) { + String methodName = job.getMethodName(); + Long jobId = job.getId(); + JobHandler handler = handlers.get(methodName); + if (handler == null) { + throw new BackgroundJobException(jobId, "No handler registered for " + methodName); + } + return handler; + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/socket/WebSocketManager.java b/openmetadata-service/src/main/java/org/openmetadata/service/socket/WebSocketManager.java index d4f6ff9ebf01..e794166e29eb 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/socket/WebSocketManager.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/socket/WebSocketManager.java @@ -26,6 +26,7 @@ public class WebSocketManager { public static final String TASK_BROADCAST_CHANNEL = "taskChannel"; public static final String SEARCH_INDEX_JOB_BROADCAST_CHANNEL = "searchIndexJobStatus"; public static final String DATA_INSIGHTS_JOB_BROADCAST_CHANNEL = "dataInsightsJobStatus"; + public static final String BACKGROUND_JOB_CHANNEL = "backgroundJobStatus"; public static final String MENTION_CHANNEL = "mentionChannel"; public static final String ANNOUNCEMENT_CHANNEL = "announcementChannel"; public static final String CSV_EXPORT_CHANNEL = "csvExportChannel"; diff --git a/openmetadata-service/src/test/java/org/openmetadata/jobs/BackgroundJobWorkerTest.java b/openmetadata-service/src/test/java/org/openmetadata/jobs/BackgroundJobWorkerTest.java new file mode 100644 index 000000000000..50afd197845c --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/jobs/BackgroundJobWorkerTest.java @@ -0,0 +1,207 @@ +package org.openmetadata.jobs; + +import static javax.ws.rs.core.Response.Status.OK; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.openmetadata.service.security.SecurityUtil.getPrincipalName; +import static org.openmetadata.service.util.TestUtils.ADMIN_AUTH_HEADERS; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.security.SecureRandom; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; +import org.openmetadata.schema.api.data.CreateTable; +import org.openmetadata.schema.entity.Type; +import org.openmetadata.schema.entity.data.Table; +import org.openmetadata.schema.entity.type.CustomProperty; +import org.openmetadata.schema.jobs.BackgroundJob; +import org.openmetadata.schema.jobs.EnumCleanupArgs; +import org.openmetadata.schema.type.CustomPropertyConfig; +import org.openmetadata.service.Entity; +import org.openmetadata.service.OpenMetadataApplicationTest; +import org.openmetadata.service.jdbi3.CollectionDAO; +import org.openmetadata.service.jobs.BackgroundJobException; +import org.openmetadata.service.jobs.EnumCleanupHandler; +import org.openmetadata.service.jobs.JobDAO; +import org.openmetadata.service.jobs.JobHandler; +import org.openmetadata.service.jobs.JobHandlerRegistry; +import org.openmetadata.service.resources.databases.TableResourceTest; +import org.openmetadata.service.resources.metadata.TypeResourceTest; +import org.openmetadata.service.util.JsonUtils; + +@Slf4j +public class BackgroundJobWorkerTest extends OpenMetadataApplicationTest { + + static JobHandlerRegistry registry; + static JobDAO jobDAO; + static CollectionDAO collectionDao; + static EnumCleanupHandler enumCleanupHandler; + public static TableResourceTest TABLE_RESOURCE_TEST; + public static TypeResourceTest TYPE_RESOURCE_TEST; + + public static CustomProperty customPropertyMulti; + public static CustomProperty customPropertySingle; + + public static Table TABLE4; + + @BeforeAll + public static void setup(TestInfo test) throws IOException, URISyntaxException { + registry = new JobHandlerRegistry(); + jobDAO = Entity.getJobDAO(); + collectionDao = Entity.getCollectionDAO(); + enumCleanupHandler = new EnumCleanupHandler(Entity.getCollectionDAO()); + LOG.info("Registering EnumCleanupHandler {}", enumCleanupHandler); + registry.register("EnumCleanupHandler", enumCleanupHandler); + TABLE_RESOURCE_TEST = new TableResourceTest(); + TABLE_RESOURCE_TEST.setup(test); + TYPE_RESOURCE_TEST = new TypeResourceTest(); + Type enumType = TYPE_RESOURCE_TEST.getEntityByName("enum", "", ADMIN_AUTH_HEADERS); + Type entityType = + TYPE_RESOURCE_TEST.getEntityByName(Entity.TABLE, "customProperties", ADMIN_AUTH_HEADERS); + + customPropertySingle = + new CustomProperty() + .withName("tableEnumCpSingle") + .withDescription("enum type custom property with multiselect = false") + .withPropertyType(enumType.getEntityReference()) + .withCustomPropertyConfig( + new CustomPropertyConfig() + .withConfig( + Map.of( + "values", + List.of("single1", "single2", "single3", "single4", "\"single5\""), + "multiSelect", + false))); + + customPropertyMulti = + new CustomProperty() + .withName("tableEnumCpMulti") + .withDescription("enum type custom property with multiselect = true") + .withPropertyType(enumType.getEntityReference()) + .withCustomPropertyConfig( + new CustomPropertyConfig() + .withConfig( + Map.of( + "values", + List.of("multi1", "multi2", "multi3", "multi4", "\"multi5\""), + "multiSelect", + true))); + CustomProperty[] customProperties = {customPropertySingle, customPropertyMulti}; + for (CustomProperty customProperty : customProperties) { + TYPE_RESOURCE_TEST.addAndCheckCustomProperty( + entityType.getId(), customProperty, OK, ADMIN_AUTH_HEADERS); + } + + CreateTable createTable4 = TABLE_RESOURCE_TEST.createRequest(test); + createTable4.withName("table4"); + TABLE4 = TABLE_RESOURCE_TEST.createAndCheckEntity(createTable4, ADMIN_AUTH_HEADERS); + } + + private BackgroundJob createBackgroundJob(EnumCleanupArgs enumCleanupArgs) { + BackgroundJob job = new BackgroundJob(); + job.setId(new SecureRandom().nextLong()); + job.setJobType(BackgroundJob.JobType.CUSTOM_PROPERTY_ENUM_CLEANUP); + job.setJobArgs(JsonUtils.pojoToJson(enumCleanupArgs)); + job.setCreatedBy(getPrincipalName(ADMIN_AUTH_HEADERS)); + job.setMethodName("EnumCleanupHandler"); + return job; + } + + @Test + public final void testRegisterEventHandler() { + EnumCleanupArgs enumCleanupArgs = + new EnumCleanupArgs() + .withPropertyName("name") + .withRemovedEnumKeys(List.of()) + .withEntityType("type"); + + BackgroundJob job = createBackgroundJob(enumCleanupArgs); + + // Verify that the handler is registered + JobHandler handler = registry.getHandler(job); + assertNotNull(handler); + assertInstanceOf(EnumCleanupHandler.class, handler); + + // Verify that an exception is thrown for a non-existent handler + job.setMethodName("NonExistentHandler"); + Exception exception = + assertThrows(BackgroundJobException.class, () -> registry.getHandler(job)); + assertEquals("No handler registered for NonExistentHandler", exception.getMessage()); + } + + @Test + public final void testBackgroundJobTriggerWithInvalidArgs() { + BackgroundJob job = new BackgroundJob(); + job.setId(new SecureRandom().nextLong()); + job.setJobArgs("invalidArgs"); + job.setMethodName("EnumCleanupHandler"); + + BackgroundJobException exception = + assertThrows(BackgroundJobException.class, () -> enumCleanupHandler.runJob(job)); + assertEquals( + "Failed to run EnumCleanupHandler job. Error:Invalid arguments invalidArgs", + exception.getMessage()); + } + + @Test + public final void testBackgroundJobTriggerWithUnrecognizedField() { + EnumCleanupArgs enumCleanupArgs = + new EnumCleanupArgs() + .withPropertyName("name") + .withRemovedEnumKeys(List.of()) + .withEntityType("type"); + + BackgroundJob job = createBackgroundJob(enumCleanupArgs); + job.setJobArgs( + "{\"bucket\":\"value\",\"propertyName\":\"name\",\"removedEnumKeys\":[],\"entityType\":\"type\"}"); + + BackgroundJobException exception = + assertThrows(BackgroundJobException.class, () -> enumCleanupHandler.runJob(job)); + assertEquals( + "Failed to run EnumCleanupHandler job. Error:Invalid arguments {\"bucket\":\"value\",\"propertyName\":\"name\",\"removedEnumKeys\":[],\"entityType\":\"type\"}", + exception.getMessage()); + } + + @Test + public final void testBackgroundJobTriggerWithValidArgs() { + + EnumCleanupArgs enumCleanupArgs = + new EnumCleanupArgs() + .withPropertyName(customPropertyMulti.getName()) + .withRemovedEnumKeys(List.of()) + .withEntityType("table"); + + BackgroundJob job = createBackgroundJob(enumCleanupArgs); + String jobArgs = JsonUtils.pojoToJson(enumCleanupArgs); + + long jobId = + Entity.getJobDAO() + .insertJob( + job.getJobType(), + new EnumCleanupHandler(collectionDao), + jobArgs, + job.getCreatedBy()); + Optional fetchedJobOptional = Entity.getJobDAO().fetchJobById(jobId); + assertTrue(fetchedJobOptional.isPresent(), "Job should be present"); + + BackgroundJob fetchedJob = fetchedJobOptional.get(); + String fetchedJobArgs = JsonUtils.pojoToJson(fetchedJob.getJobArgs()); + + // Assert the fetched job details + assertEquals(job.getJobType(), fetchedJob.getJobType(), "Job type should match"); + assertEquals(job.getMethodName(), fetchedJob.getMethodName(), "Method name should match"); + + EnumCleanupArgs actualArgs = JsonUtils.readValue(fetchedJobArgs, EnumCleanupArgs.class); + assertEquals(enumCleanupArgs, actualArgs, "Job arguments should match"); + assertEquals(job.getCreatedBy(), fetchedJob.getCreatedBy(), "Created by should match"); + } +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/OpenMetadataApplicationTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/OpenMetadataApplicationTest.java index 87c4e54903aa..35fe934705fa 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/OpenMetadataApplicationTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/OpenMetadataApplicationTest.java @@ -54,6 +54,7 @@ import org.openmetadata.service.jdbi3.CollectionDAO; import org.openmetadata.service.jdbi3.locator.ConnectionAwareAnnotationSqlLocator; import org.openmetadata.service.jdbi3.locator.ConnectionType; +import org.openmetadata.service.jobs.JobDAO; import org.openmetadata.service.migration.api.MigrationWorkflow; import org.openmetadata.service.resources.CollectionRegistry; import org.openmetadata.service.resources.databases.DatasourceConfig; @@ -240,6 +241,7 @@ public static void validateAndRunSystemDataMigrations( SearchRepository searchRepository = new SearchRepository(getEsConfig()); Entity.setSearchRepository(searchRepository); Entity.setCollectionDAO(jdbi.onDemand(CollectionDAO.class)); + Entity.setJobDAO(jdbi.onDemand(JobDAO.class)); Entity.initializeRepositories(config, jdbi); workflow.loadMigrations(); workflow.runMigrationWorkflows(); diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/metadata/TypeResourceTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/metadata/TypeResourceTest.java index f36480852e06..1e9c661f7209 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/resources/metadata/TypeResourceTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/metadata/TypeResourceTest.java @@ -244,12 +244,16 @@ void put_patch_customProperty_enum_200() throws IOException { enumFieldA.setCustomPropertyConfig( new CustomPropertyConfig().withConfig(new EnumConfig().withValues(List.of("A", "B")))); ChangeDescription change3 = getChangeDescription(tableEntity, MINOR_UPDATE); - assertResponseContains( - () -> - addCustomPropertyAndCheck( - tableEntity1.getId(), enumFieldA, ADMIN_AUTH_HEADERS, MINOR_UPDATE, change3), - Status.BAD_REQUEST, - "Existing Enum Custom Property values cannot be removed."); + fieldUpdated( + change3, + EntityUtil.getCustomField(enumFieldA, "customPropertyConfig"), + new CustomPropertyConfig().withConfig(new EnumConfig().withValues(List.of("A", "B", "C"))), + new CustomPropertyConfig().withConfig(new EnumConfig().withValues(List.of("A", "B")))); + tableEntity = + addCustomPropertyAndCheck( + tableEntity.getId(), enumFieldA, ADMIN_AUTH_HEADERS, MINOR_UPDATE, change3); + assertCustomProperties(new ArrayList<>(List.of(enumFieldA)), tableEntity.getCustomProperties()); + prevConfig = enumFieldA.getCustomPropertyConfig(); enumFieldA.setCustomPropertyConfig( new CustomPropertyConfig() @@ -264,8 +268,7 @@ void put_patch_customProperty_enum_200() throws IOException { ChangeDescription change5 = getChangeDescription(tableEntity, MINOR_UPDATE); enumFieldA.setCustomPropertyConfig( - new CustomPropertyConfig() - .withConfig(new EnumConfig().withValues(List.of("A", "B", "C", "D")))); + new CustomPropertyConfig().withConfig(new EnumConfig().withValues(List.of("A", "B", "D")))); fieldUpdated( change5, EntityUtil.getCustomField(enumFieldA, "customPropertyConfig"), diff --git a/openmetadata-spec/src/main/resources/json/schema/jobs/backgroundJob.json b/openmetadata-spec/src/main/resources/json/schema/jobs/backgroundJob.json new file mode 100644 index 000000000000..f5e95e69d8fe --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/jobs/backgroundJob.json @@ -0,0 +1,50 @@ +{ + "$id": "https://open-metadata.org/schema/jobs/backgroundJob.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "BackgroundJob", + "description": "Defines a background job that is triggered on insertion of new record in background_jobs table.", + "javaType": "org.openmetadata.schema.jobs.BackgroundJob", + "type": "object", + "properties": { + "id": { + "existingJavaType": "java.lang.Long", + "description": "Unique identifier for the job. This field is auto-incremented." + }, + "jobType": { + "type": "string", + "enum": ["CUSTOM_PROPERTY_ENUM_CLEANUP"], + "description": "Type of the job." + }, + "methodName": { + "type": "string", + "description": "JobHandler name of the method that will be executed for this job." + }, + "jobArgs": { + "oneOf": [ + { + "$ref": "./enumCleanupArgs.json" + } + ], + "description": "Object containing job arguments." + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED", "RUNNING","PENDING"], + "description": "Current status of the job." + }, + "createdBy": { + "type": "string", + "description": "User or Bot who triggered the background job." + }, + "createdAt": { + "description": "Timestamp when the job was created in Unix epoch time milliseconds.", + "$ref": "../type/basic.json#/definitions/timestamp" + }, + "updatedAt": { + "description": "Time when job was last updated in Unix epoch time milliseconds.", + "$ref": "../type/basic.json#/definitions/timestamp" + } + }, + "required": ["id", "jobType", "methodName", "jobArgs", "status", "createdBy", "createdAt", "updatedAt"], + "additionalProperties": false +} \ No newline at end of file diff --git a/openmetadata-spec/src/main/resources/json/schema/jobs/enumCleanupArgs.json b/openmetadata-spec/src/main/resources/json/schema/jobs/enumCleanupArgs.json new file mode 100644 index 000000000000..2c8c6246f308 --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/jobs/enumCleanupArgs.json @@ -0,0 +1,26 @@ +{ + "$id": "https://open-metadata.org/schema/jobs/enumCleanupArgs.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "EnumCleanupArgs", + "description": "Arguments for enum removal job.", + "type": "object", + "properties": { + "propertyName": { + "type": "string", + "description": "Name of the property." + }, + "removedEnumKeys": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of removed enum keys." + }, + "entityType": { + "type": "string", + "description": "Type of the entity." + } + }, + "required": ["propertyName", "removedEnumKeys", "entityType"], + "additionalProperties": false +} \ No newline at end of file diff --git a/openmetadata-ui/src/main/resources/ui/src/components/NavBar/NavBar.tsx b/openmetadata-ui/src/main/resources/ui/src/components/NavBar/NavBar.tsx index d5cfffd14b79..70c0e31606cc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/NavBar/NavBar.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/NavBar/NavBar.tsx @@ -31,7 +31,7 @@ import { AxiosError } from 'axios'; import classNames from 'classnames'; import { CookieStorage } from 'cookie-storage'; import i18next from 'i18next'; -import { debounce, upperCase } from 'lodash'; +import { debounce, startCase, upperCase } from 'lodash'; import { MenuInfo } from 'rc-menu/lib/interface'; import React, { useCallback, @@ -53,9 +53,11 @@ import { NOTIFICATION_READ_TIMER, SOCKET_EVENTS, } from '../../constants/constants'; +import { GlobalSettingsMenuCategory } from '../../constants/GlobalSettings.constants'; import { HELP_ITEMS_ENUM } from '../../constants/Navbar.constants'; import { useWebSocketConnector } from '../../context/WebSocketProvider/WebSocketProvider'; import { EntityTabs, EntityType } from '../../enums/entity.enum'; +import { BackgroundJob, JobType } from '../../generated/jobs/backgroundJob'; import { useApplicationStore } from '../../hooks/useApplicationStore'; import useCustomLocation from '../../hooks/useCustomLocation/useCustomLocation'; import { useDomainStore } from '../../hooks/useDomainStore'; @@ -67,6 +69,7 @@ import { shouldRequestPermission, } from '../../utils/BrowserNotificationUtils'; import { refreshPage } from '../../utils/CommonUtils'; +import { getCustomPropertyEntityPathname } from '../../utils/CustomProperty.utils'; import entityUtilClassBase from '../../utils/EntityUtilClassBase'; import { getEntityName } from '../../utils/EntityUtils'; import { @@ -81,6 +84,7 @@ import { import { isCommandKeyPress, Keys } from '../../utils/KeyboardUtil'; import { getHelpDropdownItems } from '../../utils/NavbarUtils'; import { + getSettingPath, inPageSearchOptions, isInPageSearchAllowed, } from '../../utils/RouterUtils'; @@ -252,15 +256,18 @@ const NavBar = ({ const showBrowserNotification = ( about: string, createdBy: string, - type: string + type: string, + backgroundJobData?: BackgroundJob ) => { if (!hasNotificationPermission()) { return; } + const entityType = getEntityType(about); const entityFQN = getEntityFQN(about) ?? ''; let body; let path: string; + switch (type) { case 'Task': body = t('message.user-assign-new-task', { @@ -280,6 +287,31 @@ const NavBar = ({ user: createdBy, }); path = prepareFeedLink(entityType as string, entityFQN as string); + + break; + + case 'BackgroundJob': { + if (!backgroundJobData) { + break; + } + + const { jobArgs, status, jobType } = backgroundJobData; + + if (jobType === JobType.CustomPropertyEnumCleanup) { + body = t('message.custom-property-update', { + propertyName: jobArgs.propertyName, + entityName: jobArgs.entityType, + status: startCase(status.toLowerCase()), + }); + + path = getSettingPath( + GlobalSettingsMenuCategory.CUSTOM_PROPERTIES, + getCustomPropertyEntityPathname(jobArgs.entityType) + ); + } + + break; + } } const notification = new Notification('Notification From OpenMetadata', { body: body, @@ -362,12 +394,25 @@ const NavBar = ({ onUpdateCSVExportJob(exportResponseData); } }); + socket.on(SOCKET_EVENTS.BACKGROUND_JOB_CHANNEL, (jobResponse) => { + if (jobResponse) { + const jobResponseData: BackgroundJob = JSON.parse(jobResponse); + showBrowserNotification( + '', + jobResponseData.createdBy, + 'BackgroundJob', + jobResponseData + ); + } + }); } return () => { socket && socket.off(SOCKET_EVENTS.TASK_CHANNEL); socket && socket.off(SOCKET_EVENTS.MENTION_CHANNEL); socket && socket.off(SOCKET_EVENTS.CSV_EXPORT_CHANNEL); + socket && socket.off(SOCKET_EVENTS.CSV_EXPORT_CHANNEL); + socket && socket.off(SOCKET_EVENTS.BACKGROUND_JOB_CHANNEL); }; }, [socket]); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/CustomProperty/EditCustomPropertyModal/EditCustomPropertyModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/CustomProperty/EditCustomPropertyModal/EditCustomPropertyModal.tsx index c3720bbda6d8..897131047caa 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/CustomProperty/EditCustomPropertyModal/EditCustomPropertyModal.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/CustomProperty/EditCustomPropertyModal/EditCustomPropertyModal.tsx @@ -28,6 +28,7 @@ import { FormItemLayout, } from '../../../../interface/FormUtils.interface'; import { generateFormFields } from '../../../../utils/formUtils'; +import Banner from '../../../common/Banner/Banner'; export interface FormData { description: string; @@ -107,9 +108,7 @@ const EditCustomPropertyModal: FC = ({ mode: 'tags', placeholder: t('label.enum-value-plural'), onChange: (value: string[]) => { - const enumConfig = customProperty.customPropertyConfig - ?.config as Config; - const updatedValues = uniq([...value, ...(enumConfig?.values ?? [])]); + const updatedValues = uniq([...value]); form.setFieldsValue({ customPropertyConfig: updatedValues }); }, open: false, @@ -164,6 +163,9 @@ const EditCustomPropertyModal: FC = ({ props: { 'data-testid': 'multiSelect', }, + formItemProps: { + style: { marginBottom: '0px' }, + }, id: 'root/multiSelect', formItemLayout: FormItemLayout.HORIZONTAL, }; @@ -233,9 +235,15 @@ const EditCustomPropertyModal: FC = ({ <> {hasEnumConfig && ( <> - {generateFormFields([enumConfigField])} - {note} - {generateFormFields([multiSelectField])} + {generateFormFields([enumConfigField, multiSelectField])} + {isSaving && ( + + )} )} diff --git a/openmetadata-ui/src/main/resources/ui/src/constants/constants.ts b/openmetadata-ui/src/main/resources/ui/src/constants/constants.ts index 08c93373b05e..96f5c4692e68 100644 --- a/openmetadata-ui/src/main/resources/ui/src/constants/constants.ts +++ b/openmetadata-ui/src/main/resources/ui/src/constants/constants.ts @@ -302,6 +302,7 @@ export const SOCKET_EVENTS = { DATA_INSIGHTS_JOB_BROADCAST_CHANNEL: 'dataInsightsJobStatus', BULK_ASSETS_CHANNEL: 'bulkAssetsChannel', CSV_IMPORT_CHANNEL: 'csvImportChannel', + BACKGROUND_JOB_CHANNEL: 'backgroundJobStatus', }; export const IN_PAGE_SEARCH_ROUTES: Record> = { diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/jobs/backgroundJob.ts b/openmetadata-ui/src/main/resources/ui/src/generated/jobs/backgroundJob.ts new file mode 100644 index 000000000000..95e4bf4dfce1 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/jobs/backgroundJob.ts @@ -0,0 +1,87 @@ +/* + * Copyright 2025 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. + */ +/** + * Defines a background job that is triggered on insertion of new record in background_jobs + * table. + */ +export interface BackgroundJob { + /** + * Timestamp when the job was created in Unix epoch time milliseconds. + */ + createdAt: number; + /** + * User or Bot who triggered the background job. + */ + createdBy: string; + /** + * Unique identifier for the job. This field is auto-incremented. + */ + id: any; + /** + * Object containing job arguments. + */ + jobArgs: EnumCleanupArgs; + /** + * Type of the job. + */ + jobType: JobType; + /** + * JobHandler name of the method that will be executed for this job. + */ + methodName: string; + /** + * Current status of the job. + */ + status: Status; + /** + * Time when job was last updated in Unix epoch time milliseconds. + */ + updatedAt: number; +} + +/** + * Object containing job arguments. + * + * Arguments for enum removal job. + */ +export interface EnumCleanupArgs { + /** + * Type of the entity. + */ + entityType: string; + /** + * Name of the property. + */ + propertyName: string; + /** + * List of removed enum keys. + */ + removedEnumKeys: string[]; +} + +/** + * Type of the job. + */ +export enum JobType { + CustomPropertyEnumCleanup = "CUSTOM_PROPERTY_ENUM_CLEANUP", +} + +/** + * Current status of the job. + */ +export enum Status { + Completed = "COMPLETED", + Failed = "FAILED", + Pending = "PENDING", + Running = "RUNNING", +} diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/jobs/enumCleanupArgs.ts b/openmetadata-ui/src/main/resources/ui/src/generated/jobs/enumCleanupArgs.ts new file mode 100644 index 000000000000..43ee09e0bea6 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/jobs/enumCleanupArgs.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2025 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. + */ +/** + * Arguments for enum removal job. + */ +export interface EnumCleanupArgs { + /** + * Type of the entity. + */ + entityType: string; + /** + * Name of the property. + */ + propertyName: string; + /** + * List of removed enum keys. + */ + removedEnumKeys: string[]; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json index d9eb209b715a..571d2a3f2463 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json @@ -1496,7 +1496,8 @@ "custom-properties-description": " Capture custom metadata to enrich your data assets by extending the attributes.", "custom-property-is-set-to-message": "{{fieldName}} is set to", "custom-property-name-validation": "Der Name muss mit Kleinbuchstaben beginnen und darf keine Leerzeichen, Unterstriche oder Punkte enthalten.", - "customize-landing-page-header": "Customize Landing Page for Persona \"<0>{{persona}}\"", + "custom-property-update": "Aktualisierung der benutzerdefinierten Eigenschaft '{{propertyName}}' in {{entityName}} ist {{status}}", + "customize-landing-page-header": "Customize {{pageName}} for Persona \"<0>{{persona}}\"", "customize-open-metadata-description": "Tailor the OpenMetadata UX to suit your organizational and team needs.", "data-asset-has-been-action-type": "Der Datenvermögenswert wurde {{actionType}}", "data-insight-alert-destination-description": "Senden Sie E-Mail-Benachrichtigungen an Administratoren oder Teams.", @@ -1585,6 +1586,7 @@ "entity-size-in-between": "{{entity}} Größe muss zwischen {{min}} und {{max}} liegen.", "entity-size-must-be-between-2-and-64": "{{entity}} Größe muss zwischen 2 und 64 liegen.", "entity-transfer-message": "Klicken Sie auf Bestätigen, wenn Sie {{entity}} von <0>{{from}} unter <0>{{to}} {{entity}} verschieben möchten.", + "enum-property-update-message": "Die Aktualisierung der Enum-Werte wurde gestartet. Sie werden benachrichtigt, sobald sie abgeschlossen ist.", "enum-with-description-update-note": "Updating existing value keys is not allowed; only the description can be edited. However, adding new values is allowed.", "error-self-signup-disabled": "Self-signup is currently disabled. To proceed, please reach out to your administrator for further assistance or to request access.", "error-team-transfer-message": "You cannot move to this team as Team Type {{dragTeam}} can't be {{dropTeam}} children", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json index 81a40b7d821d..412ff8de4a33 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json @@ -1496,7 +1496,8 @@ "custom-properties-description": " Capture custom metadata to enrich your data assets by extending the attributes.", "custom-property-is-set-to-message": "{{fieldName}} is set to", "custom-property-name-validation": "Name must start with lower case with no space, underscore, or dots.", - "customize-landing-page-header": "Customize Landing Page for Persona \"<0>{{persona}}\"", + "custom-property-update": "Custom property '{{propertyName}}' update in {{entityName}} is {{status}}", + "customize-landing-page-header": "Customize {{pageName}} for Persona \"<0>{{persona}}\"", "customize-open-metadata-description": "Tailor the OpenMetadata UX to suit your organizational and team needs.", "data-asset-has-been-action-type": "Data Asset has been {{actionType}}", "data-insight-alert-destination-description": "Send email notifications to admins or teams.", @@ -1585,6 +1586,7 @@ "entity-size-in-between": "{{entity}} size must be between {{min}} and {{max}}", "entity-size-must-be-between-2-and-64": "{{entity}} size must be between 2 and 64", "entity-transfer-message": "Click on Confirm if you’d like to move <0>{{from}} {{entity}} under <0>{{to}} {{entity}}.", + "enum-property-update-message": "Enum values update started. You will be notified once it's done.", "enum-with-description-update-note": "Updating existing value keys is not allowed; only the description can be edited. However, adding new values is allowed.", "error-self-signup-disabled": "Self-signup is currently disabled. To proceed, please reach out to your administrator for further assistance or to request access.", "error-team-transfer-message": "You cannot move to this team as Team Type {{dragTeam}} can't be {{dropTeam}} children", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json index 1bf28882a2ca..b2b5d1e6dffd 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json @@ -1496,7 +1496,8 @@ "custom-properties-description": "Captura metadatos personalizados para enriquecer tus activos de datos mediante la extensión de los atributos.", "custom-property-is-set-to-message": "{{fieldName}} is set to", "custom-property-name-validation": "El nombre debe comenzar con minúsculas sin espacios, guiones bajos o puntos.", - "customize-landing-page-header": "Personalizar la página de inicio para la persona \"<0>{{persona}}\"", + "custom-property-update": "La actualización de la propiedad personalizada '{{propertyName}}' en {{entityName}} está {{status}}", + "customize-landing-page-header": "Customize {{pageName}} for Persona \"<0>{{persona}}\"", "customize-open-metadata-description": "Adapte la experiencia de usuario de OpenMetadata para satisfacer las necesidades de su organización y equipo.", "data-asset-has-been-action-type": "El activo de datos ha sido {{actionType}}", "data-insight-alert-destination-description": "Enviar notificaciones por correo electrónico a administradores o equipos.", @@ -1585,6 +1586,7 @@ "entity-size-in-between": "El tamaño de {{entity}} debe estar entre {{min}} y {{max}}", "entity-size-must-be-between-2-and-64": "El tamaño de {{entity}} debe estar entre 2 y 64", "entity-transfer-message": "Haga clic en Confirmar si desea mover <0>{{from}} {{entity}} debajo de <0>{{to}} {{entity}}.", + "enum-property-update-message": "La actualización de valores enum ha comenzado. Se le notificará cuando esté terminada.", "enum-with-description-update-note": "Updating existing value keys is not allowed; only the description can be edited. However, adding new values is allowed.", "error-self-signup-disabled": "Self-signup is currently disabled. To proceed, please reach out to your administrator for further assistance or to request access.", "error-team-transfer-message": "No puede mover a este equipo ya que el Tipo de equipo {{dragTeam}} no puede ser hijo de {{dropTeam}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json index 173272d518b0..22dfa0874a21 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json @@ -1496,7 +1496,8 @@ "custom-properties-description": " Capturer des métadonnées personnalisées pour enrichir vos actifs de données en étendant leurs attributs.", "custom-property-is-set-to-message": "{{fieldName}} est réglé sur", "custom-property-name-validation": "Le nom doit commencer par une lettre minuscule, sans espace, ni tiret bas ou point.", - "customize-landing-page-header": "Personnalisez la page d'accueil pour le Persona \"<0>{{persona}}\"", + "custom-property-update": "La mise à jour de la propriété personnalisée '{{propertyName}}' dans {{entityName}} est {{status}}", + "customize-landing-page-header": "Customize {{pageName}} for Persona \"<0>{{persona}}\"", "customize-open-metadata-description": "Ajustez l'expérience utilisateur d'OpenMetadata pour répondre à vos besoins d'équipe et d'organisation.", "data-asset-has-been-action-type": "l'actif de données a été {{actionType}}", "data-insight-alert-destination-description": "Envoyez des notifications par email aux administrateurs ou aux équipes.", @@ -1585,6 +1586,7 @@ "entity-size-in-between": "{{entity}} taille doit être de {{min}} et {{max}}", "entity-size-must-be-between-2-and-64": "{{entity}} taille doit être comprise entre 2 et 64", "entity-transfer-message": "Cliquer sur Confirmer si vous souhaitez déplacer <0>{{from}} {{entity}} sous <0>{{to}} {{entity}}.", + "enum-property-update-message": "La mise à jour des valeurs enum a commencé. Vous serez notifié une fois terminée.", "enum-with-description-update-note": "Updating existing value keys is not allowed; only the description can be edited. However, adding new values is allowed.", "error-self-signup-disabled": "Self-signup is currently disabled. To proceed, please reach out to your administrator for further assistance or to request access.", "error-team-transfer-message": "Vous ne pouvez pas rejoindre cette équipe car le type d'équipe {{dragTeam}} ne peut être enfant de {{dropTeam}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json index 52663070a38d..45db37b1c182 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json @@ -1496,7 +1496,8 @@ "custom-properties-description": "Captura metadatos personalizados para enriquecer os teus activos de datos estendendo os atributos.", "custom-property-is-set-to-message": "{{fieldName}} está configurado para", "custom-property-name-validation": "O nome debe comezar en minúscula sen espazos, subliñados nin puntos.", - "customize-landing-page-header": "Personalizar a páxina de inicio para a persoa \"<0>{{persona}}\"", + "custom-property-update": "A actualización da propiedade personalizada '{{propertyName}}' en {{entityName}} está {{status}}", + "customize-landing-page-header": "Personalizar {{pageName}} para a persoa \"<0>{{persona}}\"", "customize-open-metadata-description": "Adapta a experiencia de usuario de OpenMetadata ás necesidades da túa organización e equipo.", "data-asset-has-been-action-type": "O activo de datos foi {{actionType}}", "data-insight-alert-destination-description": "Envía notificacións por correo electrónico aos administradores ou equipos.", @@ -1585,6 +1586,7 @@ "entity-size-in-between": "O tamaño de {{entity}} debe estar entre {{min}} e {{max}}", "entity-size-must-be-between-2-and-64": "O tamaño de {{entity}} debe estar entre 2 e 64", "entity-transfer-message": "Fai clic en Confirmar se desexas mover <0>{{from}} {{entity}} baixo <0>{{to}} {{entity}}.", + "enum-property-update-message": "Iniciouse a actualización dos valores enum. Notificaráselle cando remate.", "enum-with-description-update-note": "Non está permitido actualizar as claves de valores existentes; só se pode editar a descrición. Con todo, está permitido engadir novos valores.", "error-self-signup-disabled": "A inscrición automática está desactivada actualmente. Para continuar, contacta co teu administrador para obter asistencia ou solicitar acceso.", "error-team-transfer-message": "Non podes mover a este equipo xa que o Tipo de Equipo {{dragTeam}} non pode ser fillo de {{dropTeam}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json index f3806c469606..c042fa94d4a2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json @@ -1496,7 +1496,8 @@ "custom-properties-description": " Capture custom metadata to enrich your data assets by extending the attributes.", "custom-property-is-set-to-message": "{{fieldName}} is set to", "custom-property-name-validation": "השם חייב להתחיל באות קטנה ללא רווח, קו תחתון או נקודות.", - "customize-landing-page-header": "התאמה אישית של עמוד הנחיתה עבור דמות \"<0>{{persona}}\"", + "custom-property-update": "עדכון המאפיין המותאם אישית '{{propertyName}}' ב{{entityName}} הוא {{status}}", + "customize-landing-page-header": "Customize {{pageName}} for Persona \"<0>{{persona}}\"", "customize-open-metadata-description": "Tailor the OpenMetadata UX to suit your organizational and team needs.", "data-asset-has-been-action-type": "נכנס של {{actionType}} נתונים", "data-insight-alert-destination-description": "שלח התראות בדואר אלקטרוני למנהלים או לצוותים.", @@ -1585,6 +1586,7 @@ "entity-size-in-between": "{{entity}} יכול להיות בגודל בין {{min}} ל-{{max}}", "entity-size-must-be-between-2-and-64": "{{entity}} יכול להיות בגודל בין 2 ל-64", "entity-transfer-message": "לחץ על אישור אם ברצונך להעביר <0>{{from}} {{entity}} מתחת ל-<0>{{to}} {{entity}}.", + "enum-property-update-message": "עדכון ערכי enum החל. תקבל הודעה כשזה יסתיים.", "enum-with-description-update-note": "Updating existing value keys is not allowed; only the description can be edited. However, adding new values is allowed.", "error-self-signup-disabled": "Self-signup is currently disabled. To proceed, please reach out to your administrator for further assistance or to request access.", "error-team-transfer-message": "You cannot move to this team as Team Type {{dragTeam}} can't be {{dropTeam}} children", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json index 64b360352f1c..997fa869dbb2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json @@ -1496,7 +1496,8 @@ "custom-properties-description": " Capture custom metadata to enrich your data assets by extending the attributes.", "custom-property-is-set-to-message": "{{fieldName}} is set to", "custom-property-name-validation": "Name must start with lower case with no space, underscore, or dots.", - "customize-landing-page-header": "Customize Landing Page for Persona \"<0>{{persona}}\"", + "custom-property-update": "カスタムプロパティ'{{propertyName}}'の{{entityName}}での更新は{{status}}です", + "customize-landing-page-header": "Customize {{pageName}} for Persona \"<0>{{persona}}\"", "customize-open-metadata-description": "Tailor the OpenMetadata UX to suit your organizational and team needs.", "data-asset-has-been-action-type": "データアセットが{{actionType}}されました", "data-insight-alert-destination-description": "Send email notifications to admins or teams.", @@ -1585,6 +1586,7 @@ "entity-size-in-between": "{{entity}}のサイズは{{min}}以上{{max}}以下にしてください", "entity-size-must-be-between-2-and-64": "{{entity}}のサイズは2以上64以下", "entity-transfer-message": "Click on Confirm if you’d like to move <0>{{from}} {{entity}} under <0>{{to}} {{entity}}.", + "enum-property-update-message": "列挙型の値の更新が開始されました。完了後に通知されます。", "enum-with-description-update-note": "Updating existing value keys is not allowed; only the description can be edited. However, adding new values is allowed.", "error-self-signup-disabled": "Self-signup is currently disabled. To proceed, please reach out to your administrator for further assistance or to request access.", "error-team-transfer-message": "You cannot move to this team as Team Type {{dragTeam}} can't be {{dropTeam}} children", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json index 3bd3dd4c17db..17faea84e6ee 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json @@ -260,7 +260,6 @@ "custom-theme": "सानुकूल थीम", "customise": "सानुकूलित करा", "customize-entity": "{{entity}} सानुकूलित करा", - "customize-ui": "UI सानुकूलित करा", "dag": "Dag", "dag-view": "DAG दृश्य", "daily-active-users-on-the-platform": "प्लॅटफॉर्मवरील दैनिक सक्रिय वापरकर्ते", @@ -593,7 +592,6 @@ "hide-deleted-entity": "मिटवलेले {{entity}} लपवा", "history": "इतिहास", "home": "मुख्यपृष्ठ", - "homepage": "मुख्यपृष्ठ", "hour": "तास", "http-config-source": "HTTP कॉन्फिग स्रोत", "http-method": "HTTP पद्धत", @@ -792,7 +790,6 @@ "my-data": "माझा डेटा", "name": "नाव", "name-lowercase": "नाव", - "navigation": "नेव्हिगेशन", "need-help": "मदत हवी आहे", "new": "नवीन", "new-password": "नवीन पासवर्ड", @@ -1499,6 +1496,7 @@ "custom-properties-description": "तुमच्या डेटा ॲसेटंना समृद्ध करण्यासाठी सानुकूल मेटाडेटा कॅप्चर करा.", "custom-property-is-set-to-message": "{{fieldName}} सेट केले आहे", "custom-property-name-validation": "नाव लहान अक्षराने सुरू होणे आवश्यक आहे ज्यात कोणतीही जागा, अंडरस्कोर किंवा डॉट्स नाहीत.", + "custom-property-update": "सानुकूल गुणधर्म '{{propertyName}}' चे {{entityName}} मधील अद्यतन {{status}} आहे", "customize-landing-page-header": "व्यक्तिमत्व \"<0>{{persona}}\" साठी लँडिंग पृष्ठ सानुकूलित करा", "customize-open-metadata-description": "तुमच्या संस्थात्मक आणि टीमच्या गरजांसाठी OpenMetadata UX सानुकूलित करा.", "data-asset-has-been-action-type": "डेटा ॲसेट {{actionType}} केले आहे", @@ -1588,6 +1586,7 @@ "entity-size-in-between": "{{entity}} आकार {{min}} आणि {{max}} दरम्यान असणे आवश्यक आहे", "entity-size-must-be-between-2-and-64": "{{entity}} आकार 2 आणि 64 दरम्यान असणे आवश्यक आहे", "entity-transfer-message": "तुम्हाला <0>{{from}} {{entity}} ला <0>{{to}} {{entity}} अंतर्गत हलवायचे असल्यास पुष्टीवर क्लिक करा.", + "enum-property-update-message": "इनम व्हॅल्यूज अपडेट सुरू झाले आहे. संपल्यानंतर तुम्हाला सूचित केले जाईल.", "enum-with-description-update-note": "विद्यमान मूल्य की अद्यतनित करणे अनुमत नाही; फक्त वर्णन संपादित केले जाऊ शकते. तथापि, नवीन मूल्ये जोडणे अनुमत आहे.", "error-self-signup-disabled": "स्वयं-साइनअप सध्या अक्षम आहे. पुढे जाण्यासाठी, कृपया तुमच्या प्रशासकाशी संपर्क साधा किंवा प्रवेश विनंती करा.", "error-team-transfer-message": "तुम्ही या टीममध्ये हलवू शकत नाही कारण टीम प्रकार {{dragTeam}} {{dropTeam}} चे मूल होऊ शकत नाही", @@ -1700,7 +1699,6 @@ "no-config-available": "कोणतेही कनेक्शन कॉन्फिग उपलब्ध नाहीत.", "no-config-plural": "कोणतेही कॉन्फिग नाहीत.", "no-custom-properties-entity": "सध्या {{entity}} डेटा संपत्तीसाठी कोणताही कस्टम प्रॉपर्टी परिभाषित केलेली नाही. कस्टम प्रॉपर्टी कशी जोडावी ते जाणून घेण्यासाठी, कृपया <0>{{docs}} पहा.", - "no-customization-available": "या टॅबसाठी कोणतीही सानुकूलन उपलब्ध नाही", "no-data": "डेटा नाही", "no-data-assets": "OpenMetadata मध्ये स्वागत आहे! असे दिसते की अद्याप कोणतेही डेटा ॲसेट जोडलेले नाहीत. प्रारंभ करण्यासाठी आमच्या <0>कसे प्रारंभ करावे मार्गदर्शक तपासा.", "no-data-available": "डेटा उपलब्ध नाही.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json index 736db14c8319..258a7d9571d8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json @@ -1496,7 +1496,8 @@ "custom-properties-description": "Leg aangepaste metadata vast om uw data-assets te verrijken door het uitbreiden van de attributen.", "custom-property-is-set-to-message": "{{fieldName}} is set to", "custom-property-name-validation": "De naam moet beginnen met een kleine letter zonder spaties, underscores of punten.", - "customize-landing-page-header": "Pas de landingspagina aan voor Persona \"<0>{{persona}}\"", + "custom-property-update": "Aangepaste eigenschap '{{propertyName}}' update in {{entityName}} is {{status}}", + "customize-landing-page-header": "Customize {{pageName}} for Persona \"<0>{{persona}}\"", "customize-open-metadata-description": "De OpenMetadata UX aanpassen aan de behoeften van uw organisatie en team.", "data-asset-has-been-action-type": "Data-asset is {{actionType}}", "data-insight-alert-destination-description": "Stuur e-mailmeldingen naar beheerders of teams.", @@ -1585,6 +1586,7 @@ "entity-size-in-between": "{{entity}} grootte moet tussen {{min}} en {{max}} liggen", "entity-size-must-be-between-2-and-64": "{{entity}} grootte moet tussen 2 en 64 liggen", "entity-transfer-message": "Klik op Bevestigen als je <0>{{from}} {{entity}} wilt verplaatsen naar <0>{{to}} {{entity}}.", + "enum-property-update-message": "Enum-waardenupdate is gestart. U krijgt een melding zodra het klaar is.", "enum-with-description-update-note": "Updating existing value keys is not allowed; only the description can be edited. However, adding new values is allowed.", "error-self-signup-disabled": "Self-signup is currently disabled. To proceed, please reach out to your administrator for further assistance or to request access.", "error-team-transfer-message": "You cannot move to this team as Team Type {{dragTeam}} can't be {{dropTeam}} children", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json index 5c56d7b58813..d4eb3873660a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json @@ -1496,7 +1496,8 @@ "custom-properties-description": "متادیتای سفارشی را برای غنی‌سازی دارایی‌های داده‌ای خود با افزودن ویژگی‌های اضافی ضبط کنید.", "custom-property-is-set-to-message": "{{fieldName}} به مقدار", "custom-property-name-validation": "نام باید با حرف کوچک شروع شود و هیچ فاصله، زیرخط یا نقطه‌ای نداشته باشد.", - "customize-landing-page-header": "سفارشی‌سازی صفحه اصلی برای نقش \"<0>{{persona}}\"", + "custom-property-update": "بروزرسانی ویژگی سفارشی '{{propertyName}}' در {{entityName}} {{status}} است", + "customize-landing-page-header": "Customize {{pageName}} for Persona \"<0>{{persona}}\"", "customize-open-metadata-description": "تجربه کاربری OpenMetadata را برای نیازهای سازمانی و تیمی خود تنظیم کنید.", "data-asset-has-been-action-type": "دارایی داده‌ای {{actionType}} شده است", "data-insight-alert-destination-description": "اعلان‌های ایمیلی را به مدیران یا تیم‌ها ارسال کنید.", @@ -1585,6 +1586,7 @@ "entity-size-in-between": "اندازه {{entity}} باید بین {{min}} و {{max}} باشد.", "entity-size-must-be-between-2-and-64": "اندازه {{entity}} باید بین 2 تا 64 کاراکتر باشد.", "entity-transfer-message": "برای انتقال <0>{{from}} {{entity}} به <0>{{to}} {{entity}}، روی تأیید کلیک کنید.", + "enum-property-update-message": "به روز رسانی مقادیر enum شروع شد. پس از اتمام به شما اطلاع داده خواهد شد.", "enum-with-description-update-note": "Updating existing value keys is not allowed; only the description can be edited. However, adding new values is allowed.", "error-self-signup-disabled": "ثبت‌نام خودکار غیرفعال است. برای ادامه، لطفاً با مدیر سیستم تماس بگیرید یا درخواست دسترسی کنید.", "error-team-transfer-message": "شما نمی‌توانید به این تیم منتقل شوید، زیرا نوع تیم {{dragTeam}} نمی‌تواند زیرمجموعه {{dropTeam}} باشد.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json index 4c4cf535c277..2bf70963b6e7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json @@ -1496,7 +1496,8 @@ "custom-properties-description": " Capture custom metadata to enrich your data assets by extending the attributes.", "custom-property-is-set-to-message": "{{fieldName}} is set to", "custom-property-name-validation": "O nome deve começar com letra minúscula sem espaço, sublinhado ou pontos.", - "customize-landing-page-header": "Personalize a Página de Entrada para a Persona \"<0>{{persona}}\"", + "custom-property-update": "Atualização da propriedade personalizada '{{propertyName}}' em {{entityName}} está {{status}}", + "customize-landing-page-header": "Customize {{pageName}} for Persona \"<0>{{persona}}\"", "customize-open-metadata-description": "Tailor the OpenMetadata UX to suit your organizational and team needs.", "data-asset-has-been-action-type": "O Ativo de Dados foi {{actionType}}", "data-insight-alert-destination-description": "Envie notificações por e-mail para administradores ou equipes.", @@ -1585,6 +1586,7 @@ "entity-size-in-between": "O tamanho de {{entity}} deve ser entre {{min}} e {{max}}", "entity-size-must-be-between-2-and-64": "O tamanho de {{entity}} deve ser entre 2 e 64", "entity-transfer-message": "Clique em Confirmar se deseja mover <0>{{from}} {{entity}} para <0>{{to}} {{entity}}.", + "enum-property-update-message": "A atualização dos valores enum começou. Você será notificado quando estiver concluída.", "enum-with-description-update-note": "Updating existing value keys is not allowed; only the description can be edited. However, adding new values is allowed.", "error-self-signup-disabled": "Self-signup is currently disabled. To proceed, please reach out to your administrator for further assistance or to request access.", "error-team-transfer-message": "You cannot move to this team as Team Type {{dragTeam}} can't be {{dropTeam}} children", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json index 9fc8129ca7aa..75d8001dccfb 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json @@ -1496,6 +1496,7 @@ "custom-properties-description": " Capture custom metadata to enrich your data assets by extending the attributes.", "custom-property-is-set-to-message": "{{fieldName}} is set to", "custom-property-name-validation": "O nome deve começar com letra minúscula sem espaço, sublinhado ou pontos.", + "custom-property-update": "A atualização da propriedade personalizada '{{propertyName}}' em {{entityName}} está {{status}}", "customize-landing-page-header": "Customize {{pageName}} for Persona \"<0>{{persona}}\"", "customize-open-metadata-description": "Tailor the OpenMetadata UX to suit your organizational and team needs.", "data-asset-has-been-action-type": "O Ativo de Dados foi {{actionType}}", @@ -1585,6 +1586,7 @@ "entity-size-in-between": "O tamanho de {{entity}} deve ser entre {{min}} e {{max}}", "entity-size-must-be-between-2-and-64": "O tamanho de {{entity}} deve ser entre 2 e 64", "entity-transfer-message": "Clique em Confirmar se deseja mover <0>{{from}} {{entity}} para <0>{{to}} {{entity}}.", + "enum-property-update-message": "A atualização dos valores enum começou. Será notificado quando estiver concluída.", "enum-with-description-update-note": "Updating existing value keys is not allowed; only the description can be edited. However, adding new values is allowed.", "error-self-signup-disabled": "Self-signup is currently disabled. To proceed, please reach out to your administrator for further assistance or to request access.", "error-team-transfer-message": "You cannot move to this team as Team Type {{dragTeam}} can't be {{dropTeam}} children", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json index 6c9ac09dc76a..9e837dd550f8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json @@ -1496,7 +1496,8 @@ "custom-properties-description": " Capture custom metadata to enrich your data assets by extending the attributes.", "custom-property-is-set-to-message": "{{fieldName}} is set to", "custom-property-name-validation": "Имя должно начинаться со строчной буквы без пробелов, подчеркивания и точек.", - "customize-landing-page-header": "Customize Landing Page for Persona \"<0>{{persona}}\"", + "custom-property-update": "Обновление пользовательского свойства '{{propertyName}}' в {{entityName}} имеет статус {{status}}", + "customize-landing-page-header": "Customize {{pageName}} for Persona \"<0>{{persona}}\"", "customize-open-metadata-description": "Tailor the OpenMetadata UX to suit your organizational and team needs.", "data-asset-has-been-action-type": "Объект данных был {{actionType}}", "data-insight-alert-destination-description": "Отправляйте уведомления по электронной почте администраторам или командам.", @@ -1585,6 +1586,7 @@ "entity-size-in-between": "Размер {{entity}} должен быть между {{min}} и {{max}}", "entity-size-must-be-between-2-and-64": "Размер {{entity}} должен быть от 2 до 64", "entity-transfer-message": "Нажмите «Подтвердить», если вы хотите переместить <0>{{from}} {{entity}} в <0>{{to}} {{entity}}.", + "enum-property-update-message": "Началось обновление значений перечисления. Вы получите уведомление после завершения.", "enum-with-description-update-note": "Updating existing value keys is not allowed; only the description can be edited. However, adding new values is allowed.", "error-self-signup-disabled": "Self-signup is currently disabled. To proceed, please reach out to your administrator for further assistance or to request access.", "error-team-transfer-message": "You cannot move to this team as Team Type {{dragTeam}} can't be {{dropTeam}} children", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json index 22787ea96f88..8b2961a30852 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json @@ -1496,6 +1496,7 @@ "custom-properties-description": "จับข้อมูลเมตาที่กำหนดเองเพื่อเสริมสินทรัพย์ข้อมูลของคุณโดยการขยายคุณสมบัติ", "custom-property-is-set-to-message": "{{fieldName}} ถูกตั้งค่าเป็น", "custom-property-name-validation": "ชื่อจะต้องเริ่มต้นด้วยตัวพิมพ์เล็ก ไม่มีช่องว่าง, ขีดล่าง, หรือจุด", + "custom-property-update": "การอัปเดตคุณสมบัติที่กำหนดเอง '{{propertyName}}' ใน {{entityName}} คือ {{status}}", "customize-landing-page-header": "ปรับแต่ง {{pageName}} สำหรับบุคลิกภาพ \"<0>{{persona}}\"", "customize-open-metadata-description": "ปรับแต่ง UX ของ OpenMetadata ให้เหมาะสมกับความต้องการขององค์กรและทีมของคุณ", "data-asset-has-been-action-type": "สินทรัพย์ข้อมูลได้ทำการ {{actionType}}", @@ -1585,6 +1586,7 @@ "entity-size-in-between": "ขนาดของ {{entity}} ต้องอยู่ระหว่าง {{min}} และ {{max}}", "entity-size-must-be-between-2-and-64": "ขนาดของ {{entity}} ต้องอยู่ระหว่าง 2 และ 64", "entity-transfer-message": "คลิกที่ยืนยันหากคุณต้องการย้าย <0>{{from}} {{entity}} ไปยัง <0>{{to}} {{entity}}", + "enum-property-update-message": "การอัปเดตค่า enum เริ่มต้นแล้ว คุณจะได้รับแจ้งเมื่อเสร็จสิ้น", "enum-with-description-update-note": "การอัปเดตคีย์ค่าที่มีอยู่ไม่อนุญาต; สามารถปรับแต่งได้เพียงคำอธิบายเท่านั้น อย่างไรก็ตาม การเพิ่มค่าผลใหม่เป็นสิ่งที่อนุญาต", "error-self-signup-disabled": "การลงทะเบียนตนเองถูกปิดการใช้งานในขณะนี้ หากต้องการดำเนินการ โปรดติดต่อผู้ดูแลระบบของคุณเพื่อขอความช่วยเหลือเพิ่มเติมหรือต้องการการเข้าถึง", "error-team-transfer-message": "คุณไม่สามารถย้ายไปยังทีมนี้ได้เนื่องจากประเภททีม {{dragTeam}} ไม่สามารถเป็นลูกของ {{dropTeam}} ได้", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json index 4bcd4f27a5d1..a87fac0ad9e9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json @@ -1496,7 +1496,8 @@ "custom-properties-description": " 获取自定义元数据, 通过扩展属性来丰富数据资产", "custom-property-is-set-to-message": "{{fieldName}}设置为", "custom-property-name-validation": "命名首字母必须是小写字母不能为空格、下划线或点号", - "customize-landing-page-header": "为用户角色\"<0>{{persona}}\"自定义登陆页面", + "custom-property-update": "自定义属性'{{propertyName}}'在{{entityName}}中的更新{{status}}", + "customize-landing-page-header": "Customize {{pageName}} for Persona \"<0>{{persona}}\"", "customize-open-metadata-description": "自定义 OpenMetadata, 以满足您的组织和团队需求", "data-asset-has-been-action-type": "数据资产已{{actionType}}", "data-insight-alert-destination-description": "发送通知邮件给管理员或团队", @@ -1585,6 +1586,7 @@ "entity-size-in-between": "{{entity}}大小须介于{{min}}和{{max}}之间", "entity-size-must-be-between-2-and-64": "{{entity}}大小必须介于2和64之间", "entity-transfer-message": "如果您想将<0>{{from}} {{entity}} 移动到<0>{{to}} {{entity}}中, 请单击确认", + "enum-property-update-message": "枚举值更新已开始。完成后您将收到通知。", "enum-with-description-update-note": "Updating existing value keys is not allowed; only the description can be edited. However, adding new values is allowed.", "error-self-signup-disabled": "Self-signup is currently disabled. To proceed, please reach out to your administrator for further assistance or to request access.", "error-team-transfer-message": "由于团队类型{{dragTeam}}不能是{{dropTeam}}的子团队, 因此您无法移动到此团队", diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/CustomProperty.utils.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/CustomProperty.utils.test.ts new file mode 100644 index 000000000000..beb633b91869 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/CustomProperty.utils.test.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2025 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 { getCustomPropertyEntityPathname } from './CustomProperty.utils'; + +describe('CustomProperty.utils', () => { + it('getCustomPropertyEntityPathname should return entityPath[0] if entityPath is found', () => { + const expectedPath = 'glossaryTerm'; + const entityType = 'glossaryTerm'; + + expect(getCustomPropertyEntityPathname(entityType)).toEqual(expectedPath); + }); + + it('getCustomPropertyEntityPathname should return empty string if entityPath is not found', () => { + const entityType = 'randomEntity'; + + expect(getCustomPropertyEntityPathname(entityType)).toEqual(''); + }); + + it('getCustomPropertyEntityPathname should return empty string if entityType is empty', () => { + const entityType = 'glossary'; + + expect(getCustomPropertyEntityPathname(entityType)).toEqual('glossaries'); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/CustomProperty.utils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/CustomProperty.utils.ts new file mode 100644 index 000000000000..d5334431003b --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/CustomProperty.utils.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2025 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 { ENTITY_PATH } from '../constants/constants'; + +export const getCustomPropertyEntityPathname = (entityType: string) => { + const entityPathEntries = Object.entries(ENTITY_PATH); + const entityPath = entityPathEntries.find(([, path]) => path === entityType); + + return entityPath ? entityPath[0] : ''; +}; From ab16ee659b758f698a20ccffe8153f8e0d7de63b Mon Sep 17 00:00:00 2001 From: Suman Maharana Date: Tue, 7 Jan 2025 12:41:56 +0530 Subject: [PATCH 075/294] Fixes #18816 - Airbyte api endpoint (#19207) * Fix Airbyte api endpoint * Fix Airbyte api endpoint --- .../source/pipeline/airbyte/client.py | 5 ++-- .../connectors/pipeline/airbyte/index.md | 6 +++++ .../connectors/pipeline/airbyte/yaml.md | 26 +++++++++++++++++++ .../connectors/pipeline/airbyte/index.md | 6 +++++ .../connectors/pipeline/airbyte/yaml.md | 26 +++++++++++++++++++ .../pipeline/airbyteConnection.json | 6 +++++ .../public/locales/en-US/Pipeline/Airbyte.md | 6 +++++ .../connections/pipeline/airbyteConnection.ts | 4 +++ 8 files changed, 83 insertions(+), 2 deletions(-) diff --git a/ingestion/src/metadata/ingestion/source/pipeline/airbyte/client.py b/ingestion/src/metadata/ingestion/source/pipeline/airbyte/client.py index 63a6817cf84a..ab342831deaf 100644 --- a/ingestion/src/metadata/ingestion/source/pipeline/airbyte/client.py +++ b/ingestion/src/metadata/ingestion/source/pipeline/airbyte/client.py @@ -20,6 +20,7 @@ from metadata.ingestion.ometa.client import REST, APIError, ClientConfig from metadata.utils.constants import AUTHORIZATION_HEADER, NO_ACCESS_TOKEN from metadata.utils.credentials import generate_http_basic_token +from metadata.utils.helpers import clean_uri class AirbyteClient: @@ -30,8 +31,8 @@ class AirbyteClient: def __init__(self, config: AirbyteConnection): self.config = config client_config: ClientConfig = ClientConfig( - base_url=str(self.config.hostPort), - api_version="api/v1", + base_url=clean_uri(self.config.hostPort), + api_version=self.config.apiVersion, auth_header=AUTHORIZATION_HEADER, auth_token=lambda: (NO_ACCESS_TOKEN, 0), ) diff --git a/openmetadata-docs/content/v1.4.x/connectors/pipeline/airbyte/index.md b/openmetadata-docs/content/v1.4.x/connectors/pipeline/airbyte/index.md index 50d6fb14ad30..3af86179b6c7 100644 --- a/openmetadata-docs/content/v1.4.x/connectors/pipeline/airbyte/index.md +++ b/openmetadata-docs/content/v1.4.x/connectors/pipeline/airbyte/index.md @@ -38,6 +38,12 @@ Configure and schedule Airbyte metadata and profiler workflows from the OpenMeta - **Host and Port**: Pipeline Service Management UI URL +- **Username**: Username to connect to Airbyte. + +- **Password**: Password to connect to Airbyte. + +- **API Version**: Version of the Airbyte REST API by default `api/v1`. + {% /extraContent %} {% partial file="/v1.4/connectors/test-connection.md" /%} diff --git a/openmetadata-docs/content/v1.4.x/connectors/pipeline/airbyte/yaml.md b/openmetadata-docs/content/v1.4.x/connectors/pipeline/airbyte/yaml.md index 31fb1b194052..3aeed4bbee23 100644 --- a/openmetadata-docs/content/v1.4.x/connectors/pipeline/airbyte/yaml.md +++ b/openmetadata-docs/content/v1.4.x/connectors/pipeline/airbyte/yaml.md @@ -59,6 +59,23 @@ This is a sample config for Airbyte: **hostPort**: Pipeline Service Management UI URL +{% /codeInfo %} + +{% codeInfo srNumber=2 %} + +**username**: Username to connect to Airbyte. + +{% /codeInfo %} + +{% codeInfo srNumber=3 %} + +**password**: Password to connect to Airbyte. + +{% /codeInfo %} + +{% codeInfo srNumber=4 %} + +**apiVersion**: Version of the Airbyte REST API by default `api/v1`. {% /codeInfo %} @@ -86,6 +103,15 @@ source: ```yaml {% srNumber=1 %} hostPort: http://localhost:8000 ``` +```yaml {% srNumber=2 %} + username: +``` +```yaml {% srNumber=3 %} + password: +``` +```yaml {% srNumber=4 %} + apiVersion: api/v1 +``` {% partial file="/v1.4/connectors/yaml/pipeline/source-config.md" /%} diff --git a/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/pipeline/airbyte/index.md b/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/pipeline/airbyte/index.md index 9fb026318f51..4c0abb7b60a9 100644 --- a/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/pipeline/airbyte/index.md +++ b/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/pipeline/airbyte/index.md @@ -38,6 +38,12 @@ Configure and schedule Airbyte metadata and profiler workflows from the OpenMeta - **Host and Port**: Pipeline Service Management UI URL +- **Username**: Username to connect to Airbyte. + +- **Password**: Password to connect to Airbyte. + +- **API Version**: Version of the Airbyte REST API by default `api/v1`. + {% /extraContent %} {% partial file="/v1.6/connectors/test-connection.md" /%} diff --git a/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/pipeline/airbyte/yaml.md b/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/pipeline/airbyte/yaml.md index 4337cf7db36f..94c2e71439ce 100644 --- a/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/pipeline/airbyte/yaml.md +++ b/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/pipeline/airbyte/yaml.md @@ -59,6 +59,23 @@ This is a sample config for Airbyte: **hostPort**: Pipeline Service Management UI URL +{% /codeInfo %} + +{% codeInfo srNumber=2 %} + +**username**: Username to connect to Airbyte. + +{% /codeInfo %} + +{% codeInfo srNumber=3 %} + +**password**: Password to connect to Airbyte. + +{% /codeInfo %} + +{% codeInfo srNumber=4 %} + +**apiVersion**: Version of the Airbyte REST API by default `api/v1`. {% /codeInfo %} @@ -86,6 +103,15 @@ source: ```yaml {% srNumber=1 %} hostPort: http://localhost:8000 ``` +```yaml {% srNumber=2 %} + username: +``` +```yaml {% srNumber=3 %} + password: +``` +```yaml {% srNumber=4 %} + apiVersion: api/v1 +``` {% partial file="/v1.6/connectors/yaml/pipeline/source-config.md" /%} diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/services/connections/pipeline/airbyteConnection.json b/openmetadata-spec/src/main/resources/json/schema/entity/services/connections/pipeline/airbyteConnection.json index 867292cfe4ea..7ce93dc3eae3 100644 --- a/openmetadata-spec/src/main/resources/json/schema/entity/services/connections/pipeline/airbyteConnection.json +++ b/openmetadata-spec/src/main/resources/json/schema/entity/services/connections/pipeline/airbyteConnection.json @@ -37,6 +37,12 @@ "type": "string", "format": "password" }, + "apiVersion": { + "title": "API Version", + "description": "Airbyte API version.", + "type": "string", + "default": "api/v1" + }, "supportsMetadataExtraction": { "title": "Supports Metadata Extraction", "$ref": "../connectionBasicType.json#/definitions/supportsMetadataExtraction" diff --git a/openmetadata-ui/src/main/resources/ui/public/locales/en-US/Pipeline/Airbyte.md b/openmetadata-ui/src/main/resources/ui/public/locales/en-US/Pipeline/Airbyte.md index 930ce605284d..75b314b0d7ca 100644 --- a/openmetadata-ui/src/main/resources/ui/public/locales/en-US/Pipeline/Airbyte.md +++ b/openmetadata-ui/src/main/resources/ui/public/locales/en-US/Pipeline/Airbyte.md @@ -25,3 +25,9 @@ $$section ### Password $(id="password") Password to connect to Airbyte. $$ + +$$section +### Api Version $(id="apiVersion") + +Version of the Airbyte REST API by default `api/v1`. +$$ \ No newline at end of file diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/connections/pipeline/airbyteConnection.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/connections/pipeline/airbyteConnection.ts index ed2040e65ebd..1b9d08252392 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/connections/pipeline/airbyteConnection.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/connections/pipeline/airbyteConnection.ts @@ -14,6 +14,10 @@ * Airbyte Metadata Database Connection Config */ export interface AirbyteConnection { + /** + * Airbyte API version. + */ + apiVersion?: string; /** * Pipeline Service Management/UI URL. */ From f763575cfe58daf74a80913a1cfcbd5d107aff4d Mon Sep 17 00:00:00 2001 From: Suman Maharana Date: Tue, 7 Jan 2025 16:37:30 +0530 Subject: [PATCH 076/294] Fixes #17747: dbt update owners (#19144) * Fixes 17747: dbt update owners * update messages * addressed comments * py_format * py_format * Added tests --- .../source/database/dbt/dbt_service.py | 11 +++++ .../ingestion/source/database/dbt/metadata.py | 42 ++++++++++++++++++ ingestion/tests/unit/test_dbt.py | 8 ++++ .../connectors/yaml/dbt/source-config-def.md | 2 + .../v1.4/connectors/yaml/dbt/source-config.md | 1 + .../connectors/yaml/dbt/source-config-def.md | 2 + .../v1.6/connectors/yaml/dbt/source-config.md | 1 + .../workflows/dbt/ingest-dbt-owner.md | 14 ++++-- .../schemas/metadataIngestion/dbtPipeline.md | 1 + .../workflows/dbt/ingest-dbt-owner.md | 14 ++++-- .../schemas/metadataIngestion/dbtPipeline.md | 1 + .../dbt/dbt-features/dbt-update-owners.webp | Bin 0 -> 13144 bytes .../schema/metadataIngestion/dbtPipeline.json | 5 +++ .../locales/en-US/Database/workflows/dbt.md | 10 +++++ .../metadataIngestion/dbtPipeline.ts | 8 ++-- 15 files changed, 111 insertions(+), 9 deletions(-) create mode 100644 openmetadata-docs/images/v1.6/features/ingestion/workflows/dbt/dbt-features/dbt-update-owners.webp diff --git a/ingestion/src/metadata/ingestion/source/database/dbt/dbt_service.py b/ingestion/src/metadata/ingestion/source/database/dbt/dbt_service.py index 5021f4c5fb22..ec6ea35c97e7 100644 --- a/ingestion/src/metadata/ingestion/source/database/dbt/dbt_service.py +++ b/ingestion/src/metadata/ingestion/source/database/dbt/dbt_service.py @@ -122,6 +122,11 @@ class DbtServiceTopology(ServiceTopology): processor="process_dbt_descriptions", nullable=True, ), + NodeStage( + type_=DataModelLink, + processor="process_dbt_owners", + nullable=True, + ), ], ) process_dbt_tests: Annotated[ @@ -293,6 +298,12 @@ def process_dbt_descriptions(self, data_model_link: DataModelLink): Method to process DBT descriptions using patch APIs """ + @abstractmethod + def process_dbt_owners(self, data_model_link: DataModelLink): + """ + Method to process DBT owners using patch APIs + """ + def get_dbt_tests(self) -> dict: """ Prepare the DBT tests diff --git a/ingestion/src/metadata/ingestion/source/database/dbt/metadata.py b/ingestion/src/metadata/ingestion/source/database/dbt/metadata.py index 5c1b2cf81e31..e74abafff914 100644 --- a/ingestion/src/metadata/ingestion/source/database/dbt/metadata.py +++ b/ingestion/src/metadata/ingestion/source/database/dbt/metadata.py @@ -61,6 +61,7 @@ from metadata.ingestion.lineage.models import ConnectionTypeDialectMapper from metadata.ingestion.lineage.sql_lineage import get_lineage_by_query from metadata.ingestion.models.ometa_classification import OMetaTagAndClassification +from metadata.ingestion.models.patch_request import PatchedEntity, PatchRequest from metadata.ingestion.models.table_metadata import ColumnDescription from metadata.ingestion.ometa.ometa_api import OpenMetadata from metadata.ingestion.source.database.column_type_parser import ColumnTypeParser @@ -886,6 +887,47 @@ def process_dbt_descriptions(self, data_model_link: DataModelLink): f"to update dbt description: {exc}" ) + def process_dbt_owners( + self, data_model_link: DataModelLink + ) -> Iterable[Either[PatchedEntity]]: + """ + Method to process DBT owners + """ + table_entity: Table = data_model_link.table_entity + if table_entity: + logger.debug( + f"Processing DBT owners for: {table_entity.fullyQualifiedName.root}" + ) + try: + data_model = data_model_link.datamodel + if ( + data_model.resourceType != DbtCommonEnum.SOURCE.value + and self.source_config.dbtUpdateOwners + ): + logger.debug( + f"Overwriting owners with DBT owners: {table_entity.fullyQualifiedName.root}" + ) + if data_model.owners: + new_entity = deepcopy(table_entity) + new_entity.owners = data_model.owners + yield Either( + right=PatchRequest( + original_entity=table_entity, + new_entity=new_entity, + override_metadata=True, + ) + ) + + except Exception as exc: # pylint: disable=broad-except + yield Either( + left=StackTraceError( + name=str(table_entity.fullyQualifiedName.root), + error=f"Failed to parse the node" + f"{table_entity.fullyQualifiedName.root} to update dbt owner: {exc}", + stackTrace=traceback.format_exc(), + ) + ) + def create_dbt_tests_definition( self, dbt_test: dict ) -> Iterable[Either[CreateTestDefinitionRequest]]: diff --git a/ingestion/tests/unit/test_dbt.py b/ingestion/tests/unit/test_dbt.py index 1c2db6edc0a2..ffe24f33728f 100644 --- a/ingestion/tests/unit/test_dbt.py +++ b/ingestion/tests/unit/test_dbt.py @@ -53,6 +53,7 @@ "dbtRunResultsFilePath": "sample/dbt_files/run_results.json", "dbtSourcesFilePath": "sample/dbt_files/sources.json", }, + "dbtUpdateOwners": True, } }, }, @@ -675,6 +676,7 @@ def check_yield_datamodel(self, dbt_objects, expected_data_models): data_model_link.right.table_entity.fullyQualifiedName.root, EXPECTED_DATA_MODEL_FQNS, ) + self.check_process_dbt_owners(data_model_link.right) data_model_list.append(data_model_link.right.datamodel) for _, (expected, original) in enumerate( @@ -682,6 +684,12 @@ def check_yield_datamodel(self, dbt_objects, expected_data_models): ): self.assertEqual(expected, original) + def check_process_dbt_owners(self, data_model_link): + process_dbt_owners = self.dbt_source_obj.process_dbt_owners(data_model_link) + for entity in process_dbt_owners: + entity_owner = entity.right.new_entity.owners + self.assertEqual(entity_owner, MOCK_OWNER) + @patch("metadata.ingestion.ometa.mixins.es_mixin.ESMixin.es_search_from_fqn") def test_upstream_nodes_for_lineage(self, es_search_from_fqn): expected_upstream_nodes = [ diff --git a/openmetadata-docs/content/partials/v1.4/connectors/yaml/dbt/source-config-def.md b/openmetadata-docs/content/partials/v1.4/connectors/yaml/dbt/source-config-def.md index 1938909593e3..eb3c11a332e9 100644 --- a/openmetadata-docs/content/partials/v1.4/connectors/yaml/dbt/source-config-def.md +++ b/openmetadata-docs/content/partials/v1.4/connectors/yaml/dbt/source-config-def.md @@ -4,6 +4,8 @@ **dbtUpdateDescriptions**: Configuration to update the description from dbt or not. If set to true descriptions from dbt will override the already present descriptions on the entity. For more details visit [here](/connectors/ingestion/workflows/dbt/ingest-dbt-descriptions) +**dbtUpdateOwners**: Configuration to update the owner from dbt or not. If set to true owners from dbt will override the already present owners on the entity. For more details visit [here](/connectors/ingestion/workflows/dbt/ingest-dbt-owner) + **includeTags**: true or false, to ingest tags from dbt. Default is true. **dbtClassificationName**: Custom OpenMetadata Classification name for dbt tags. diff --git a/openmetadata-docs/content/partials/v1.4/connectors/yaml/dbt/source-config.md b/openmetadata-docs/content/partials/v1.4/connectors/yaml/dbt/source-config.md index a697981792aa..8f7662283dcf 100644 --- a/openmetadata-docs/content/partials/v1.4/connectors/yaml/dbt/source-config.md +++ b/openmetadata-docs/content/partials/v1.4/connectors/yaml/dbt/source-config.md @@ -1,5 +1,6 @@ ```yaml {% srNumber=120 %} # dbtUpdateDescriptions: true or false + # dbtUpdateOwners: true or false # includeTags: true or false # dbtClassificationName: dbtTags # databaseFilterPattern: diff --git a/openmetadata-docs/content/partials/v1.6/connectors/yaml/dbt/source-config-def.md b/openmetadata-docs/content/partials/v1.6/connectors/yaml/dbt/source-config-def.md index 1938909593e3..eb3c11a332e9 100644 --- a/openmetadata-docs/content/partials/v1.6/connectors/yaml/dbt/source-config-def.md +++ b/openmetadata-docs/content/partials/v1.6/connectors/yaml/dbt/source-config-def.md @@ -4,6 +4,8 @@ **dbtUpdateDescriptions**: Configuration to update the description from dbt or not. If set to true descriptions from dbt will override the already present descriptions on the entity. For more details visit [here](/connectors/ingestion/workflows/dbt/ingest-dbt-descriptions) +**dbtUpdateOwners**: Configuration to update the owner from dbt or not. If set to true owners from dbt will override the already present owners on the entity. For more details visit [here](/connectors/ingestion/workflows/dbt/ingest-dbt-owner) + **includeTags**: true or false, to ingest tags from dbt. Default is true. **dbtClassificationName**: Custom OpenMetadata Classification name for dbt tags. diff --git a/openmetadata-docs/content/partials/v1.6/connectors/yaml/dbt/source-config.md b/openmetadata-docs/content/partials/v1.6/connectors/yaml/dbt/source-config.md index a697981792aa..8f7662283dcf 100644 --- a/openmetadata-docs/content/partials/v1.6/connectors/yaml/dbt/source-config.md +++ b/openmetadata-docs/content/partials/v1.6/connectors/yaml/dbt/source-config.md @@ -1,5 +1,6 @@ ```yaml {% srNumber=120 %} # dbtUpdateDescriptions: true or false + # dbtUpdateOwners: true or false # includeTags: true or false # dbtClassificationName: dbtTags # databaseFilterPattern: diff --git a/openmetadata-docs/content/v1.4.x/connectors/ingestion/workflows/dbt/ingest-dbt-owner.md b/openmetadata-docs/content/v1.4.x/connectors/ingestion/workflows/dbt/ingest-dbt-owner.md index 2a246172fa5f..f32625e71410 100644 --- a/openmetadata-docs/content/v1.4.x/connectors/ingestion/workflows/dbt/ingest-dbt-owner.md +++ b/openmetadata-docs/content/v1.4.x/connectors/ingestion/workflows/dbt/ingest-dbt-owner.md @@ -129,8 +129,16 @@ After running the ingestion workflow with dbt you can see the created user or te -{% note %} +## Overriding the existing table Owners -If a table already has a owner linked to it, owner from the dbt will not update the current owner. +To establish a unified and reliable system for owners, a single source of truth is necessary. It either is directly OpenMetadata, if individuals want to go there and keep updating, or if they prefer to keep it centralized in dbt, then we can always rely on that directly. -{% /note %} +When the `Update Owners` toggle is enabled during the configuration of dbt ingestion, existing owners of tables will be overwritten with the dbt owners. + +If toggle is disabled during the configuration of dbt ingestion, dbt owners will only be updated for tables in OpenMetadata that currently have no owners. Existing owners will remain unchanged and will not be overwritten with dbt owners. + +{% image + src="/images/v1.6/features/ingestion/workflows/dbt/dbt-features/dbt-update-owners.webp" + alt="update-dbt-owners" + caption="Update dbt Owners" + /%} diff --git a/openmetadata-docs/content/v1.4.x/main-concepts/metadata-standard/schemas/metadataIngestion/dbtPipeline.md b/openmetadata-docs/content/v1.4.x/main-concepts/metadata-standard/schemas/metadataIngestion/dbtPipeline.md index 842dfc102670..0b27c51b5ddc 100644 --- a/openmetadata-docs/content/v1.4.x/main-concepts/metadata-standard/schemas/metadataIngestion/dbtPipeline.md +++ b/openmetadata-docs/content/v1.4.x/main-concepts/metadata-standard/schemas/metadataIngestion/dbtPipeline.md @@ -12,6 +12,7 @@ slug: /main-concepts/metadata-standard/schemas/metadataingestion/dbtpipeline - **`type`**: Pipeline type. Refer to *#/definitions/dbtConfigType*. Default: `DBT`. - **`dbtConfigSource`**: Available sources to fetch DBT catalog and manifest files. - **`dbtUpdateDescriptions`** *(boolean)*: Optional configuration to update the description from DBT or not. Default: `False`. +- **`dbtUpdateOwners`** *(boolean)*: Optional configuration to update the owner from DBT or not. Default: `False`. - **`includeTags`** *(boolean)*: Optional configuration to toggle the tags ingestion. Default: `True`. - **`dbtClassificationName`** *(string)*: Custom OpenMetadata Classification name for dbt tags. Default: `dbtTags`. - **`schemaFilterPattern`**: Regex to only fetch tables or databases that matches the pattern. Refer to *../type/filterPattern.json#/definitions/filterPattern*. diff --git a/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/ingestion/workflows/dbt/ingest-dbt-owner.md b/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/ingestion/workflows/dbt/ingest-dbt-owner.md index b85174d24ad0..0fbdbc7a7dea 100644 --- a/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/ingestion/workflows/dbt/ingest-dbt-owner.md +++ b/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/ingestion/workflows/dbt/ingest-dbt-owner.md @@ -138,8 +138,16 @@ After running the ingestion workflow with dbt you can see the created user or te -{% note %} +## Overriding the existing table Owners -If a table already has a owner linked to it, owner from the dbt will not update the current owner. +To establish a unified and reliable system for owners, a single source of truth is necessary. It either is directly OpenMetadata, if individuals want to go there and keep updating, or if they prefer to keep it centralized in dbt, then we can always rely on that directly. -{% /note %} +When the `Update Owners` toggle is enabled during the configuration of dbt ingestion, existing owners of tables will be overwritten with the dbt owners. + +If toggle is disabled during the configuration of dbt ingestion, dbt owners will only be updated for tables in OpenMetadata that currently have no owners. Existing owners will remain unchanged and will not be overwritten with dbt owners. + +{% image + src="/images/v1.6/features/ingestion/workflows/dbt/dbt-features/dbt-update-owners.webp" + alt="update-dbt-owners" + caption="Update dbt Owners" + /%} diff --git a/openmetadata-docs/content/v1.6.x-SNAPSHOT/main-concepts/metadata-standard/schemas/metadataIngestion/dbtPipeline.md b/openmetadata-docs/content/v1.6.x-SNAPSHOT/main-concepts/metadata-standard/schemas/metadataIngestion/dbtPipeline.md index 842dfc102670..fff8d454a043 100644 --- a/openmetadata-docs/content/v1.6.x-SNAPSHOT/main-concepts/metadata-standard/schemas/metadataIngestion/dbtPipeline.md +++ b/openmetadata-docs/content/v1.6.x-SNAPSHOT/main-concepts/metadata-standard/schemas/metadataIngestion/dbtPipeline.md @@ -12,6 +12,7 @@ slug: /main-concepts/metadata-standard/schemas/metadataingestion/dbtpipeline - **`type`**: Pipeline type. Refer to *#/definitions/dbtConfigType*. Default: `DBT`. - **`dbtConfigSource`**: Available sources to fetch DBT catalog and manifest files. - **`dbtUpdateDescriptions`** *(boolean)*: Optional configuration to update the description from DBT or not. Default: `False`. +- **`dbtUpdateOwners`** *(boolean)*: Optional configuration to update the owners from DBT or not. Default: `False`. - **`includeTags`** *(boolean)*: Optional configuration to toggle the tags ingestion. Default: `True`. - **`dbtClassificationName`** *(string)*: Custom OpenMetadata Classification name for dbt tags. Default: `dbtTags`. - **`schemaFilterPattern`**: Regex to only fetch tables or databases that matches the pattern. Refer to *../type/filterPattern.json#/definitions/filterPattern*. diff --git a/openmetadata-docs/images/v1.6/features/ingestion/workflows/dbt/dbt-features/dbt-update-owners.webp b/openmetadata-docs/images/v1.6/features/ingestion/workflows/dbt/dbt-features/dbt-update-owners.webp new file mode 100644 index 0000000000000000000000000000000000000000..b9e0bd91c42b631caaf1a61f23a968bb42d1cfc8 GIT binary patch literal 13144 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 O0sq@yO3Gox!2bty=c Date: Wed, 8 Jan 2025 11:35:56 +0530 Subject: [PATCH 077/294] Fix dbtcloud task/run date (#19265) --- .../source/pipeline/dbtcloud/metadata.py | 52 ++++++++----------- 1 file changed, 22 insertions(+), 30 deletions(-) diff --git a/ingestion/src/metadata/ingestion/source/pipeline/dbtcloud/metadata.py b/ingestion/src/metadata/ingestion/source/pipeline/dbtcloud/metadata.py index f0df49e3123a..6420276e73ec 100644 --- a/ingestion/src/metadata/ingestion/source/pipeline/dbtcloud/metadata.py +++ b/ingestion/src/metadata/ingestion/source/pipeline/dbtcloud/metadata.py @@ -276,8 +276,16 @@ def yield_pipeline_status( Get Pipeline Status """ try: - task_status = [ - TaskStatus( + + pipeline_fqn = fqn.build( + metadata=self.metadata, + entity_type=Pipeline, + service_name=self.context.get().pipeline_service, + pipeline_name=self.context.get().pipeline, + ) + + for task in self.client.get_runs(job_id=int(pipeline_details.id)) or []: + task_status = TaskStatus( name=str(task.id), executionStatus=STATUS_MAP.get(task.state, StatusType.Pending), startTime=( @@ -303,37 +311,21 @@ def yield_pipeline_status( else None ), ) - for task in self.client.get_runs(job_id=int(pipeline_details.id)) or [] - ] - pipeline_status = PipelineStatus( - executionStatus=STATUS_MAP.get( - pipeline_details.state, StatusType.Pending - ), - taskStatus=task_status, - timestamp=Timestamp( - datetime_to_ts( - datetime.strptime( - pipeline_details.created_at, "%Y-%m-%d %H:%M:%S.%f%z" - ) - if pipeline_details.created_at - else None - ) - ), - ) + pipeline_status = PipelineStatus( + executionStatus=task_status.executionStatus, + taskStatus=[task_status], + timestamp=task_status.endTime + if task_status.endTime + else task_status.startTime, + ) - pipeline_fqn = fqn.build( - metadata=self.metadata, - entity_type=Pipeline, - service_name=self.context.get().pipeline_service, - pipeline_name=self.context.get().pipeline, - ) - yield Either( - right=OMetaPipelineStatus( - pipeline_fqn=pipeline_fqn, - pipeline_status=pipeline_status, + yield Either( + right=OMetaPipelineStatus( + pipeline_fqn=pipeline_fqn, + pipeline_status=pipeline_status, + ) ) - ) except Exception as exc: yield Either( From e656f11d2fbc0faba7d088c5e177bee7fcf903d8 Mon Sep 17 00:00:00 2001 From: Mohit Yadav <105265192+mohityadav766@users.noreply.github.com> Date: Wed, 8 Jan 2025 11:51:38 +0530 Subject: [PATCH 078/294] [OMOperations] Add Reset Password option (#19253) * Add Reset Password option * Add Email option * Add option to set initial password in yaml (cherry picked from commit dba3e58e4c94af7f1753f85a5020fd2ac615a810) --- .../service/util/OpenMetadataOperations.java | 64 ++++++++++++++++++- .../openmetadata/service/util/UserUtil.java | 31 +++++---- 2 files changed, 81 insertions(+), 14 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/util/OpenMetadataOperations.java b/openmetadata-service/src/main/java/org/openmetadata/service/util/OpenMetadataOperations.java index 02be91fc84ea..3e55072b5b7e 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/util/OpenMetadataOperations.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/util/OpenMetadataOperations.java @@ -4,7 +4,9 @@ import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; import static org.openmetadata.service.Entity.FIELD_OWNERS; import static org.openmetadata.service.formatter.decorators.MessageDecorator.getDateStringEpochMilli; +import static org.openmetadata.service.jdbi3.UserRepository.AUTH_MECHANISM_FIELD; import static org.openmetadata.service.util.AsciiTable.printOpenMetadataText; +import static org.openmetadata.service.util.UserUtil.updateUserWithHashedPwd; import ch.qos.logback.classic.Level; import ch.qos.logback.classic.Logger; @@ -23,6 +25,7 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -41,6 +44,8 @@ import org.openmetadata.schema.entity.app.App; import org.openmetadata.schema.entity.app.AppRunRecord; import org.openmetadata.schema.entity.services.ingestionPipelines.IngestionPipeline; +import org.openmetadata.schema.entity.teams.User; +import org.openmetadata.schema.services.connections.metadata.AuthProvider; import org.openmetadata.schema.settings.Settings; import org.openmetadata.schema.settings.SettingsType; import org.openmetadata.schema.system.EventPublisherJob; @@ -60,6 +65,7 @@ import org.openmetadata.service.jdbi3.ListFilter; import org.openmetadata.service.jdbi3.MigrationDAO; import org.openmetadata.service.jdbi3.SystemRepository; +import org.openmetadata.service.jdbi3.UserRepository; import org.openmetadata.service.jdbi3.locator.ConnectionType; import org.openmetadata.service.migration.api.MigrationWorkflow; import org.openmetadata.service.resources.CollectionRegistry; @@ -166,13 +172,12 @@ public Integer repair() { public Integer syncEmailFromEnv() { try { parseConfig(); - Entity.setCollectionDAO(jdbi.onDemand(CollectionDAO.class)); - SystemRepository systemRepository = new SystemRepository(); Settings updatedSettings = new Settings() .withConfigType(SettingsType.EMAIL_CONFIGURATION) .withConfigValue(config.getSmtpSettings()); - systemRepository.createOrUpdate(updatedSettings); + Entity.getSystemRepository().createOrUpdate(updatedSettings); + LOG.info("Synced Email Configuration from Environment."); return 0; } catch (Exception e) { LOG.error("Email Sync failed due to ", e); @@ -221,6 +226,58 @@ public Integer dropCreate() { } } + @Command(name = "reset-password", description = "Reset the password for a user.") + public Integer resetUserPassword( + @Option( + names = {"-e", "--email"}, + description = "Email for which to reset the password.", + required = true) + String email, + @Option( + names = {"-p", "--password"}, + description = "Enter user password", + arity = "0..1", + interactive = true, + required = true) + char[] password) { + try { + LOG.info("Resetting password for user : {}", email); + if (nullOrEmpty(password)) { + throw new IllegalArgumentException("Password cannot be empty."); + } + parseConfig(); + CollectionRegistry.initialize(); + AuthProvider authProvider = config.getAuthenticationConfiguration().getProvider(); + + // Only Basic Auth provider is supported for password reset + if (!authProvider.equals(AuthProvider.BASIC)) { + LOG.error("Auth Provider is Not Basic. Cannot apply Password"); + return 1; + } + + UserRepository userRepository = (UserRepository) Entity.getEntityRepository(Entity.USER); + Set fieldList = new HashSet<>(userRepository.getPatchFields().getFieldList()); + fieldList.add(AUTH_MECHANISM_FIELD); + User originalUser = userRepository.getByEmail(null, email, new EntityUtil.Fields(fieldList)); + + // Check if the user is a bot user + if (Boolean.TRUE.equals(originalUser.getIsBot())) { + LOG.error("Bot user : {} cannot have password.", originalUser.getName()); + return 1; + } + + User updatedUser = JsonUtils.deepCopy(originalUser, User.class); + String inputPwd = new String(password); + updateUserWithHashedPwd(updatedUser, inputPwd); + UserUtil.addOrUpdateUser(updatedUser); + LOG.info("Password updated successfully."); + return 0; + } catch (Exception e) { + LOG.error("Failed to reset user password.", e); + return 1; + } + } + @Command( name = "migrate", description = "Migrates the OpenMetadata database schema and search index mappings.") @@ -652,6 +709,7 @@ private void parseConfig() throws Exception { collectionDAO = jdbi.onDemand(CollectionDAO.class); Entity.setSearchRepository(searchRepository); Entity.setCollectionDAO(collectionDAO); + Entity.setSystemRepository(new SystemRepository()); Entity.initializeRepositories(config, jdbi); } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/util/UserUtil.java b/openmetadata-service/src/main/java/org/openmetadata/service/util/UserUtil.java index 01fe23b3a6c4..6e27e01efdd6 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/util/UserUtil.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/util/UserUtil.java @@ -19,6 +19,7 @@ import static org.openmetadata.schema.type.Include.NON_DELETED; import static org.openmetadata.service.Entity.ADMIN_ROLE; import static org.openmetadata.service.Entity.ADMIN_USER_NAME; +import static org.openmetadata.service.jdbi3.UserRepository.AUTH_MECHANISM_FIELD; import at.favre.lib.crypto.bcrypt.BCrypt; import java.util.ArrayList; @@ -62,8 +63,18 @@ private UserUtil() { public static void addUsers( AuthProvider authProvider, Set adminUsers, String domain, Boolean isAdmin) { try { - for (String username : adminUsers) { - createOrUpdateUser(authProvider, username, domain, isAdmin); + for (String keyValue : adminUsers) { + String userName = ""; + String password = ""; + if (keyValue.contains(":")) { + String[] keyValueArray = keyValue.split(":"); + userName = keyValueArray[0]; + password = keyValueArray[1]; + } else { + userName = keyValue; + password = getPassword(userName); + } + createOrUpdateUser(authProvider, userName, password, domain, isAdmin); } } catch (Exception ex) { LOG.error("[BootstrapUser] Encountered Exception while bootstrapping admin user", ex); @@ -71,27 +82,26 @@ public static void addUsers( } private static void createOrUpdateUser( - AuthProvider authProvider, String username, String domain, Boolean isAdmin) { + AuthProvider authProvider, String username, String password, String domain, Boolean isAdmin) { UserRepository userRepository = (UserRepository) Entity.getEntityRepository(Entity.USER); User updatedUser = null; try { // Create Required Fields List Set fieldList = new HashSet<>(userRepository.getPatchFields().getFieldList()); - fieldList.add("authenticationMechanism"); + fieldList.add(AUTH_MECHANISM_FIELD); // Fetch Original User, is available User originalUser = userRepository.getByName(null, username, new Fields(fieldList)); if (Boolean.FALSE.equals(originalUser.getIsBot()) - && Boolean.FALSE.equals(originalUser.getIsAdmin())) { + && Boolean.TRUE.equals(originalUser.getIsAdmin())) { updatedUser = originalUser; // Update Auth Mechanism if not present, and send mail to the user if (authProvider.equals(AuthProvider.BASIC)) { if (originalUser.getAuthenticationMechanism() == null || originalUser.getAuthenticationMechanism().equals(new AuthenticationMechanism())) { - String randomPwd = getPassword(username); - updateUserWithHashedPwd(updatedUser, randomPwd); - EmailUtil.sendInviteMailToAdmin(updatedUser, randomPwd); + updateUserWithHashedPwd(updatedUser, password); + EmailUtil.sendInviteMailToAdmin(updatedUser, password); } } else { updatedUser.setAuthenticationMechanism(new AuthenticationMechanism()); @@ -114,9 +124,8 @@ private static void createOrUpdateUser( updatedUser = user(username, domain, username).withIsAdmin(isAdmin).withIsEmailVerified(true); // Update Auth Mechanism if not present, and send mail to the user if (authProvider.equals(AuthProvider.BASIC)) { - String randomPwd = getPassword(username); - updateUserWithHashedPwd(updatedUser, randomPwd); - EmailUtil.sendInviteMailToAdmin(updatedUser, randomPwd); + updateUserWithHashedPwd(updatedUser, password); + EmailUtil.sendInviteMailToAdmin(updatedUser, password); } } From 525ab871f5c0c8f0527d9f0c281ffaf00393f430 Mon Sep 17 00:00:00 2001 From: RounakDhillon <162090200+RounakDhillon@users.noreply.github.com> Date: Wed, 8 Jan 2025 11:34:51 +0530 Subject: [PATCH 079/294] Docs: Format Note Mention In Advanced Search (#19277) Co-authored-by: Rounak Dhillon Co-authored-by: Ashish Gupta (cherry picked from commit ab0f64eaba96d5adafcb265b427561688b75e14f) --- .../v1.4.x/how-to-guides/data-discovery/advanced.md | 13 +++++++++++++ .../v1.5.x/how-to-guides/data-discovery/advanced.md | 13 +++++++++++++ .../how-to-guides/data-discovery/advanced.md | 13 +++++++++++++ 3 files changed, 39 insertions(+) diff --git a/openmetadata-docs/content/v1.4.x/how-to-guides/data-discovery/advanced.md b/openmetadata-docs/content/v1.4.x/how-to-guides/data-discovery/advanced.md index 2f55f76168c8..14dd7b0d831d 100644 --- a/openmetadata-docs/content/v1.4.x/how-to-guides/data-discovery/advanced.md +++ b/openmetadata-docs/content/v1.4.x/how-to-guides/data-discovery/advanced.md @@ -33,6 +33,19 @@ caption="Add Complex Queries using Advanced Search" For example, we can set up a complex query as follows: - Group one set of conditions together by defining the `Owner`. You can add multiple conditions to define different owners and use the `OR` condition to ensure that the owner is any one among them. +{% note noteType="Tip" %} +### Note on Custom Properties in Elasticsearch Search + +Elasticsearch does not support searching for custom properties with the following formats: + +- **Time** +- **DateTime** +- Any date formats other than `yyyy-MM-dd` + +Please ensure that custom properties adhere to these constraints for compatibility with Elasticsearch search functionality. + +{% /note %} + {% image src="/images/v1.4/how-to-guides/discovery/adv3.png" alt="Grouped Condition based on the Owner of the Data Assets" diff --git a/openmetadata-docs/content/v1.5.x/how-to-guides/data-discovery/advanced.md b/openmetadata-docs/content/v1.5.x/how-to-guides/data-discovery/advanced.md index 3a8b3d04db5d..6139eeb42d9b 100644 --- a/openmetadata-docs/content/v1.5.x/how-to-guides/data-discovery/advanced.md +++ b/openmetadata-docs/content/v1.5.x/how-to-guides/data-discovery/advanced.md @@ -33,6 +33,19 @@ caption="Add Complex Queries using Advanced Search" For example, we can set up a complex query as follows: - Group one set of conditions together by defining the `Owner`. You can add multiple conditions to define different owners and use the `OR` condition to ensure that the owner is any one among them. +{% note noteType="Tip" %} +### Note on Custom Properties in Elasticsearch Search + +Elasticsearch does not support searching for custom properties with the following formats: + +- **Time** +- **DateTime** +- Any date formats other than `yyyy-MM-dd` + +Please ensure that custom properties adhere to these constraints for compatibility with Elasticsearch search functionality. + +{% /note %} + {% image src="/images/v1.5/how-to-guides/discovery/adv3.png" alt="Grouped Condition based on the Owner of the Data Assets" diff --git a/openmetadata-docs/content/v1.6.x-SNAPSHOT/how-to-guides/data-discovery/advanced.md b/openmetadata-docs/content/v1.6.x-SNAPSHOT/how-to-guides/data-discovery/advanced.md index 291d69b4556f..400ab4c6f301 100644 --- a/openmetadata-docs/content/v1.6.x-SNAPSHOT/how-to-guides/data-discovery/advanced.md +++ b/openmetadata-docs/content/v1.6.x-SNAPSHOT/how-to-guides/data-discovery/advanced.md @@ -33,6 +33,19 @@ caption="Add Complex Queries using Advanced Search" For example, we can set up a complex query as follows: - Group one set of conditions together by defining the `Owner`. You can add multiple conditions to define different owners and use the `OR` condition to ensure that the owner is any one among them. +{% note noteType="Tip" %} +### Note on Custom Properties in Elasticsearch Search + +Elasticsearch does not support searching for custom properties with the following formats: + +- **Time** +- **DateTime** +- Any date formats other than `yyyy-MM-dd` + +Please ensure that custom properties adhere to these constraints for compatibility with Elasticsearch search functionality. + +{% /note %} + {% image src="/images/v1.6/how-to-guides/discovery/adv3.png" alt="Grouped Condition based on the Owner of the Data Assets" From 26829f73d9c0ed1c94efbb884b213e99209d61e5 Mon Sep 17 00:00:00 2001 From: Mohit Yadav <105265192+mohityadav766@users.noreply.github.com> Date: Fri, 20 Dec 2024 11:40:39 +0530 Subject: [PATCH 080/294] [Code Improv] Added Mappers (#19111) - Cherry pick --- .../userTask/impl/CreateApprovalTaskImpl.java | 4 +- .../service/jdbi3/EntityRepository.java | 7 +- .../service/jdbi3/TestCaseRepository.java | 19 +---- .../jdbi3/TestCaseResultRepository.java | 24 +----- .../service/mapper/EntityMapper.java | 45 ++++++++++ .../mapper/EntityTimeSeriesMapper.java | 7 ++ .../analytics/WebAnalyticEventMapper.java | 17 ++++ .../analytics/WebAnalyticEventResource.java | 14 +-- .../resources/apis/APICollectionMapper.java | 20 +++++ .../resources/apis/APICollectionResource.java | 14 +-- .../resources/apis/APIEndpointMapper.java | 21 +++++ .../resources/apis/APIEndpointResource.java | 19 ++--- .../service/resources/apps/AppMapper.java | 72 ++++++++++++++++ .../resources/apps/AppMarketPlaceMapper.java | 68 +++++++++++++++ .../apps/AppMarketPlaceResource.java | 62 ++------------ .../service/resources/apps/AppResource.java | 79 +---------------- .../resources/automations/WorkflowMapper.java | 31 +++++++ .../automations/WorkflowResource.java | 23 +---- .../service/resources/bots/BotMapper.java | 85 +++++++++++++++++++ .../service/resources/bots/BotResource.java | 85 ++++--------------- .../service/resources/charts/ChartMapper.java | 21 +++++ .../resources/charts/ChartResource.java | 16 +--- .../resources/dashboards/DashboardMapper.java | 23 +++++ .../dashboards/DashboardResource.java | 19 ++--- .../resources/databases/DatabaseMapper.java | 19 +++++ .../resources/databases/DatabaseResource.java | 14 +-- .../databases/DatabaseSchemaMapper.java | 19 +++++ .../databases/DatabaseSchemaResource.java | 16 ++-- .../databases/StoredProcedureMapper.java | 20 +++++ .../databases/StoredProcedureResource.java | 15 +--- .../resources/databases/TableMapper.java | 53 ++++++++++++ .../resources/databases/TableResource.java | 49 ++--------- .../datainsight/DataInsightChartMapper.java | 19 +++++ .../datainsight/DataInsightChartResource.java | 16 +--- .../datamodels/DashboardDataModelMapper.java | 25 ++++++ .../DashboardDataModelResource.java | 21 +---- .../resources/docstore/DocStoreMapper.java | 44 ++++++++++ .../resources/docstore/DocStoreResource.java | 29 +------ .../resources/domains/DataProductMapper.java | 34 ++++++++ .../domains/DataProductResource.java | 29 ++----- .../resources/domains/DomainMapper.java | 28 ++++++ .../resources/domains/DomainResource.java | 22 +---- .../resources/dqtests/TestCaseMapper.java | 27 ++++++ .../TestCaseResolutionStatusMapper.java | 28 ++++++ .../TestCaseResolutionStatusResource.java | 39 ++------- .../dqtests/TestCaseResultMapper.java | 36 ++++++++ .../dqtests/TestCaseResultResource.java | 8 +- .../dqtests/TestDefinitionMapper.java | 19 +++++ .../dqtests/TestDefinitionResource.java | 17 +--- .../resources/dqtests/TestSuiteMapper.java | 31 +++++++ .../subscription/EventSubscriptionMapper.java | 46 ++++++++++ .../EventSubscriptionResource.java | 37 +------- .../service/resources/feeds/FeedMapper.java | 56 ++++++++++++ .../service/resources/feeds/FeedResource.java | 60 +------------ .../service/resources/feeds/PostMapper.java | 17 ++++ .../resources/feeds/SuggestionMapper.java | 72 ++++++++++++++++ .../resources/feeds/SuggestionsResource.java | 62 +------------- .../resources/glossary/GlossaryMapper.java | 14 +++ .../resources/glossary/GlossaryResource.java | 17 +--- .../glossary/GlossaryTermMapper.java | 24 ++++++ .../glossary/GlossaryTermResource.java | 24 ++---- .../governance/WorkflowDefinitionMapper.java | 18 ++++ .../WorkflowDefinitionResource.java | 16 +--- .../service/resources/kpi/KpiMapper.java | 22 +++++ .../service/resources/kpi/KpiResource.java | 17 +--- .../resources/metrics/MetricMapper.java | 20 +++++ .../resources/metrics/MetricResource.java | 15 +--- .../resources/mlmodels/MlModelMapper.java | 25 ++++++ .../resources/mlmodels/MlModelResource.java | 20 +---- .../resources/pipelines/PipelineMapper.java | 23 +++++ .../resources/pipelines/PipelineResource.java | 18 +--- .../resources/policies/PolicyMapper.java | 20 +++++ .../resources/policies/PolicyResource.java | 18 +--- .../service/resources/query/QueryMapper.java | 29 +++++++ .../resources/query/QueryResource.java | 24 +----- .../searchindex/SearchIndexMapper.java | 20 +++++ .../searchindex/SearchIndexResource.java | 19 ++--- .../apiservices/APIServiceMapper.java | 14 +++ .../apiservices/APIServiceResource.java | 14 ++- .../dashboard/DashboardServiceMapper.java | 15 ++++ .../dashboard/DashboardServiceResource.java | 14 ++- .../database/DatabaseServiceMapper.java | 14 +++ .../database/DatabaseServiceResource.java | 14 ++- .../IngestionPipelineMapper.java | 31 +++++++ .../IngestionPipelineResource.java | 21 +---- .../messaging/MessagingServiceMapper.java | 15 ++++ .../messaging/MessagingServiceResource.java | 14 ++- .../metadata/MetadataServiceMapper.java | 14 +++ .../metadata/MetadataServiceResource.java | 15 +--- .../mlmodel/MlModelServiceMapper.java | 14 +++ .../mlmodel/MlModelServiceResource.java | 14 ++- .../pipeline/PipelineServiceMapper.java | 14 +++ .../pipeline/PipelineServiceResource.java | 14 ++- .../searchIndexes/SearchServiceMapper.java | 14 +++ .../searchIndexes/SearchServiceResource.java | 14 ++- .../storage/StorageServiceMapper.java | 14 +++ .../storage/StorageServiceResource.java | 14 ++- .../resources/storages/ContainerMapper.java | 25 ++++++ .../resources/storages/ContainerResource.java | 22 ++--- .../resources/tags/ClassificationMapper.java | 15 ++++ .../tags/ClassificationResource.java | 21 ++--- .../service/resources/tags/TagMapper.java | 21 +++++ .../service/resources/tags/TagResource.java | 26 ++---- .../resources/teams/PersonaMapper.java | 15 ++++ .../resources/teams/PersonaResource.java | 12 +-- .../service/resources/teams/RoleMapper.java | 20 +++++ .../service/resources/teams/RoleResource.java | 21 +---- .../service/resources/teams/TeamMapper.java | 33 +++++++ .../service/resources/teams/TeamResource.java | 30 +------ .../service/resources/teams/UserMapper.java | 33 +++++++ .../service/resources/topics/TopicMapper.java | 27 ++++++ .../resources/topics/TopicResource.java | 22 +---- .../service/resources/types/TypeMapper.java | 15 ++++ .../service/resources/types/TypeResource.java | 13 +-- .../service/security/Authorizer.java | 2 + .../service/security/DefaultAuthorizer.java | 9 ++ .../service/security/NoopAuthorizer.java | 5 ++ .../api/tests/createTestCaseResult.json | 4 + .../api/tests/createTestCaseResult.ts | 10 ++- 119 files changed, 1838 insertions(+), 1095 deletions(-) create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/mapper/EntityMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/mapper/EntityTimeSeriesMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/analytics/WebAnalyticEventMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/apis/APICollectionMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/apis/APIEndpointMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/apps/AppMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/apps/AppMarketPlaceMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/automations/WorkflowMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/bots/BotMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/charts/ChartMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/dashboards/DashboardMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/databases/DatabaseMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/databases/DatabaseSchemaMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/databases/StoredProcedureMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/databases/TableMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/datainsight/DataInsightChartMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/datamodels/DashboardDataModelMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/docstore/DocStoreMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/domains/DataProductMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/domains/DomainMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseResolutionStatusMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseResultMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestDefinitionMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestSuiteMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/events/subscription/EventSubscriptionMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/feeds/FeedMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/feeds/PostMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/feeds/SuggestionMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/glossary/GlossaryMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/glossary/GlossaryTermMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/governance/WorkflowDefinitionMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/kpi/KpiMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/metrics/MetricMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/mlmodels/MlModelMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/pipelines/PipelineMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/policies/PolicyMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/query/QueryMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/searchindex/SearchIndexMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/services/apiservices/APIServiceMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/services/dashboard/DashboardServiceMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/services/database/DatabaseServiceMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/services/ingestionpipelines/IngestionPipelineMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/services/messaging/MessagingServiceMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/services/metadata/MetadataServiceMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/services/mlmodel/MlModelServiceMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/services/pipeline/PipelineServiceMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/services/searchIndexes/SearchServiceMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/services/storage/StorageServiceMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/storages/ContainerMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/tags/ClassificationMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/tags/TagMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/PersonaMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/RoleMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/TeamMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/UserMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/topics/TopicMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/types/TypeMapper.java 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 af14ad5c3841..c20411d94689 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 @@ -26,7 +26,7 @@ import org.openmetadata.service.exception.EntityNotFoundException; import org.openmetadata.service.governance.workflows.WorkflowHandler; import org.openmetadata.service.jdbi3.FeedRepository; -import org.openmetadata.service.resources.feeds.FeedResource; +import org.openmetadata.service.resources.feeds.FeedMapper; import org.openmetadata.service.resources.feeds.MessageParser; import org.openmetadata.service.util.WebsocketNotificationHandler; @@ -91,7 +91,7 @@ private Thread createApprovalTask(GlossaryTerm entity, List ass } catch (EntityNotFoundException ex) { TaskDetails taskDetails = new TaskDetails() - .withAssignees(FeedResource.formatAssignees(assignees)) + .withAssignees(FeedMapper.formatAssignees(assignees)) .withType(TaskType.RequestApproval) .withStatus(TaskStatus.Open); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java index 4c517924e745..b86eca1e1fe3 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java @@ -2056,11 +2056,12 @@ public final void validateUsers(List entityReferences) { } } - private boolean validateIfAllRefsAreEntityType(List list, String entityType) { + private static boolean validateIfAllRefsAreEntityType( + List list, String entityType) { return list.stream().allMatch(obj -> obj.getType().equals(entityType)); } - public final void validateReviewers(List entityReferences) { + public static void validateReviewers(List entityReferences) { if (!nullOrEmpty(entityReferences)) { boolean areAllTeam = validateIfAllRefsAreEntityType(entityReferences, TEAM); boolean areAllUsers = validateIfAllRefsAreEntityType(entityReferences, USER); @@ -2418,7 +2419,7 @@ protected void checkSystemEntityDeletion(T entity) { } } - public final List validateOwners(List owners) { + public static List validateOwners(List owners) { if (nullOrEmpty(owners)) { return null; } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseRepository.java index 1603c24d445d..e986c2381651 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseRepository.java @@ -33,7 +33,6 @@ import org.openmetadata.schema.EntityTimeSeriesInterface; import org.openmetadata.schema.api.feed.CloseTask; import org.openmetadata.schema.api.feed.ResolveTask; -import org.openmetadata.schema.api.tests.CreateTestCaseResult; import org.openmetadata.schema.entity.data.Table; import org.openmetadata.schema.entity.teams.User; import org.openmetadata.schema.tests.TestCase; @@ -306,26 +305,10 @@ private void updateLogicalTestSuite(UUID testSuiteId) { public RestUtil.PutResponse addTestCaseResult( String updatedBy, UriInfo uriInfo, String fqn, TestCaseResult testCaseResult) { - // TODO: REMOVED ONCE DEPRECATED IN TEST CASE RESOURCE - CreateTestCaseResult createTestCaseResult = - new CreateTestCaseResult() - .withTimestamp(testCaseResult.getTimestamp()) - .withTestCaseStatus(testCaseResult.getTestCaseStatus()) - .withResult(testCaseResult.getResult()) - .withSampleData(testCaseResult.getSampleData()) - .withTestResultValue(testCaseResult.getTestResultValue()) - .withPassedRows(testCaseResult.getPassedRows()) - .withFailedRows(testCaseResult.getFailedRows()) - .withPassedRowsPercentage(testCaseResult.getPassedRowsPercentage()) - .withFailedRowsPercentage(testCaseResult.getFailedRowsPercentage()) - .withIncidentId(testCaseResult.getIncidentId()) - .withMaxBound(testCaseResult.getMaxBound()) - .withMinBound(testCaseResult.getMinBound()); - TestCaseResultRepository testCaseResultRepository = (TestCaseResultRepository) Entity.getEntityTimeSeriesRepository(TEST_CASE_RESULT); Response response = - testCaseResultRepository.addTestCaseResult(updatedBy, uriInfo, fqn, createTestCaseResult); + testCaseResultRepository.addTestCaseResult(updatedBy, uriInfo, fqn, testCaseResult); return new RestUtil.PutResponse<>( Response.Status.CREATED, (TestCaseResult) response.getEntity(), ENTITY_UPDATED); } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseResultRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseResultRepository.java index 4e247fbab2bc..0e42f27d26cb 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseResultRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseResultRepository.java @@ -17,7 +17,6 @@ import javax.ws.rs.core.UriInfo; import lombok.SneakyThrows; import org.openmetadata.common.utils.CommonUtil; -import org.openmetadata.schema.api.tests.CreateTestCaseResult; import org.openmetadata.schema.tests.ResultSummary; import org.openmetadata.schema.tests.TestCase; import org.openmetadata.schema.tests.TestSuite; @@ -80,9 +79,8 @@ public ResultList getTestCaseResults(String fqn, Long startTs, L } public Response addTestCaseResult( - String updatedBy, UriInfo uriInfo, String fqn, CreateTestCaseResult createTestCaseResult) { + String updatedBy, UriInfo uriInfo, String fqn, TestCaseResult testCaseResult) { TestCase testCase = Entity.getEntityByName(TEST_CASE, fqn, "", Include.ALL); - TestCaseResult testCaseResult = getTestCaseResult(createTestCaseResult, testCase); if (testCaseResult.getTestCaseStatus() == TestCaseStatus.Success) { testCaseRepository.deleteTestCaseFailedRowsSample(testCase.getId()); } @@ -299,24 +297,4 @@ public boolean hasTestCaseFailure(String fqn) throws IOException { .anyMatch( testCaseResult -> testCaseResult.getTestCaseStatus().equals(TestCaseStatus.Failed)); } - - private TestCaseResult getTestCaseResult( - CreateTestCaseResult createTestCaseResults, TestCase testCase) { - RestUtil.validateTimestampMilliseconds(createTestCaseResults.getTimestamp()); - return new TestCaseResult() - .withId(UUID.randomUUID()) - .withTestCaseFQN(testCase.getFullyQualifiedName()) - .withTimestamp(createTestCaseResults.getTimestamp()) - .withTestCaseStatus(createTestCaseResults.getTestCaseStatus()) - .withResult(createTestCaseResults.getResult()) - .withSampleData(createTestCaseResults.getSampleData()) - .withTestResultValue(createTestCaseResults.getTestResultValue()) - .withPassedRows(createTestCaseResults.getPassedRows()) - .withFailedRows(createTestCaseResults.getFailedRows()) - .withPassedRowsPercentage(createTestCaseResults.getPassedRowsPercentage()) - .withFailedRowsPercentage(createTestCaseResults.getFailedRowsPercentage()) - .withIncidentId(createTestCaseResults.getIncidentId()) - .withMaxBound(createTestCaseResults.getMaxBound()) - .withMinBound(createTestCaseResults.getMinBound()); - } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/mapper/EntityMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/mapper/EntityMapper.java new file mode 100644 index 000000000000..a3be330862e4 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/mapper/EntityMapper.java @@ -0,0 +1,45 @@ +package org.openmetadata.service.mapper; + +import static org.openmetadata.schema.type.Include.NON_DELETED; +import static org.openmetadata.service.jdbi3.EntityRepository.validateOwners; +import static org.openmetadata.service.jdbi3.EntityRepository.validateReviewers; +import static org.openmetadata.service.util.EntityUtil.getEntityReferences; + +import java.util.List; +import java.util.UUID; +import org.openmetadata.common.utils.CommonUtil; +import org.openmetadata.schema.CreateEntity; +import org.openmetadata.schema.EntityInterface; +import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.service.Entity; + +public interface EntityMapper { + T createToEntity(C create, String user); + + default T copy(T entity, CreateEntity request, String updatedBy) { + List owners = validateOwners(request.getOwners()); + EntityReference domain = validateDomain(request.getDomain()); + validateReviewers(request.getReviewers()); + entity.setId(UUID.randomUUID()); + entity.setName(request.getName()); + entity.setDisplayName(request.getDisplayName()); + entity.setDescription(request.getDescription()); + entity.setOwners(owners); + entity.setDomain(domain); + entity.setTags(request.getTags()); + entity.setDataProducts(getEntityReferences(Entity.DATA_PRODUCT, request.getDataProducts())); + entity.setLifeCycle(request.getLifeCycle()); + entity.setExtension(request.getExtension()); + entity.setUpdatedBy(updatedBy); + entity.setUpdatedAt(System.currentTimeMillis()); + entity.setReviewers(request.getReviewers()); + return entity; + } + + default EntityReference validateDomain(String domainFqn) { + if (CommonUtil.nullOrEmpty(domainFqn)) { + return null; + } + return Entity.getEntityReferenceByName(Entity.DOMAIN, domainFqn, NON_DELETED); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/mapper/EntityTimeSeriesMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/mapper/EntityTimeSeriesMapper.java new file mode 100644 index 000000000000..242cb8929698 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/mapper/EntityTimeSeriesMapper.java @@ -0,0 +1,7 @@ +package org.openmetadata.service.mapper; + +import org.openmetadata.schema.EntityTimeSeriesInterface; + +public interface EntityTimeSeriesMapper { + T createToEntity(C create, String user); +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/analytics/WebAnalyticEventMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/analytics/WebAnalyticEventMapper.java new file mode 100644 index 000000000000..b6880919dd87 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/analytics/WebAnalyticEventMapper.java @@ -0,0 +1,17 @@ +package org.openmetadata.service.resources.analytics; + +import org.openmetadata.schema.analytics.WebAnalyticEvent; +import org.openmetadata.schema.api.tests.CreateWebAnalyticEvent; +import org.openmetadata.service.mapper.EntityMapper; + +public class WebAnalyticEventMapper + implements EntityMapper { + @Override + public WebAnalyticEvent createToEntity(CreateWebAnalyticEvent create, String user) { + return copy(new WebAnalyticEvent(), create, user) + .withName(create.getName()) + .withDisplayName(create.getDisplayName()) + .withDescription(create.getDescription()) + .withEventType(create.getEventType()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/analytics/WebAnalyticEventResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/analytics/WebAnalyticEventResource.java index 5ce147ef770e..d5ec469b4262 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/analytics/WebAnalyticEventResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/analytics/WebAnalyticEventResource.java @@ -70,6 +70,7 @@ public class WebAnalyticEventResource public static final String COLLECTION_PATH = WebAnalyticEventRepository.COLLECTION_PATH; static final String FIELDS = "owners"; private static final Pattern HTML_PATTERN = Pattern.compile(".*\\<[^>]+>.*", Pattern.DOTALL); + private final WebAnalyticEventMapper mapper = new WebAnalyticEventMapper(); public WebAnalyticEventResource(Authorizer authorizer, Limits limits) { super(Entity.WEB_ANALYTIC_EVENT, authorizer, limits); @@ -169,7 +170,7 @@ public Response create( @Context SecurityContext securityContext, @Valid CreateWebAnalyticEvent create) { WebAnalyticEvent webAnalyticEvent = - getWebAnalyticEvent(create, securityContext.getUserPrincipal().getName()); + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return create(uriInfo, securityContext, webAnalyticEvent); } @@ -192,7 +193,7 @@ public Response createOrUpdate( @Context SecurityContext securityContext, @Valid CreateWebAnalyticEvent create) { WebAnalyticEvent webAnalyticEvent = - getWebAnalyticEvent(create, securityContext.getUserPrincipal().getName()); + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return createOrUpdate(uriInfo, securityContext, webAnalyticEvent); } @@ -554,15 +555,6 @@ public ResultList listWebAnalyticEventData( return repository.getWebAnalyticEventData(eventType, startTs, endTs); } - private WebAnalyticEvent getWebAnalyticEvent(CreateWebAnalyticEvent create, String user) { - return repository - .copy(new WebAnalyticEvent(), create, user) - .withName(create.getName()) - .withDisplayName(create.getDisplayName()) - .withDescription(create.getDescription()) - .withEventType(create.getEventType()); - } - public static WebAnalyticEventData sanitizeWebAnalyticEventData( WebAnalyticEventData webAnalyticEventDataInput) { Object inputData = webAnalyticEventDataInput.getEventData(); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/apis/APICollectionMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/apis/APICollectionMapper.java new file mode 100644 index 000000000000..60d3e9c44e77 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/apis/APICollectionMapper.java @@ -0,0 +1,20 @@ +package org.openmetadata.service.resources.apis; + +import static org.openmetadata.service.util.EntityUtil.getEntityReference; +import static org.openmetadata.service.util.EntityUtil.getEntityReferences; + +import org.openmetadata.schema.api.data.CreateAPICollection; +import org.openmetadata.schema.entity.data.APICollection; +import org.openmetadata.service.Entity; +import org.openmetadata.service.mapper.EntityMapper; + +public class APICollectionMapper implements EntityMapper { + @Override + public APICollection createToEntity(CreateAPICollection create, String user) { + return copy(new APICollection(), create, user) + .withService(getEntityReference(Entity.API_SERVICE, create.getService())) + .withEndpointURL(create.getEndpointURL()) + .withApiEndpoints(getEntityReferences(Entity.API_ENDPOINT, create.getApiEndpoints())) + .withSourceHash(create.getSourceHash()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/apis/APICollectionResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/apis/APICollectionResource.java index 0f2581f1b0cc..b73d02c0c25d 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/apis/APICollectionResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/apis/APICollectionResource.java @@ -73,6 +73,7 @@ @Collection(name = "apiCollections") public class APICollectionResource extends EntityResource { public static final String COLLECTION_PATH = "v1/apiCollections/"; + private final APICollectionMapper mapper = new APICollectionMapper(); static final String FIELDS = "owners,apiEndpoints,tags,extension,domain,sourceHash"; @Override @@ -310,7 +311,7 @@ public Response create( @Context SecurityContext securityContext, @Valid CreateAPICollection create) { APICollection apiCollection = - getAPICollection(create, securityContext.getUserPrincipal().getName()); + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return create(uriInfo, securityContext, apiCollection); } @@ -392,7 +393,7 @@ public Response createOrUpdate( @Context SecurityContext securityContext, @Valid CreateAPICollection create) { APICollection apiCollection = - getAPICollection(create, securityContext.getUserPrincipal().getName()); + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return createOrUpdate(uriInfo, securityContext, apiCollection); } @@ -510,13 +511,4 @@ public Response restore( @Valid RestoreEntity restore) { return restoreEntity(uriInfo, securityContext, restore.getId()); } - - private APICollection getAPICollection(CreateAPICollection create, String user) { - return repository - .copy(new APICollection(), create, user) - .withService(getEntityReference(Entity.API_SERVICE, create.getService())) - .withEndpointURL(create.getEndpointURL()) - .withApiEndpoints(getEntityReferences(Entity.API_ENDPOINT, create.getApiEndpoints())) - .withSourceHash(create.getSourceHash()); - } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/apis/APIEndpointMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/apis/APIEndpointMapper.java new file mode 100644 index 000000000000..f197475efcf0 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/apis/APIEndpointMapper.java @@ -0,0 +1,21 @@ +package org.openmetadata.service.resources.apis; + +import static org.openmetadata.service.util.EntityUtil.getEntityReference; + +import org.openmetadata.schema.api.data.CreateAPIEndpoint; +import org.openmetadata.schema.entity.data.APIEndpoint; +import org.openmetadata.service.Entity; +import org.openmetadata.service.mapper.EntityMapper; + +public class APIEndpointMapper implements EntityMapper { + @Override + public APIEndpoint createToEntity(CreateAPIEndpoint create, String user) { + return copy(new APIEndpoint(), create, user) + .withApiCollection(getEntityReference(Entity.API_COLLCECTION, create.getApiCollection())) + .withRequestMethod(create.getRequestMethod()) + .withEndpointURL(create.getEndpointURL()) + .withRequestSchema(create.getRequestSchema()) + .withResponseSchema(create.getResponseSchema()) + .withSourceHash(create.getSourceHash()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/apis/APIEndpointResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/apis/APIEndpointResource.java index 5060606c2a2a..40a853aec442 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/apis/APIEndpointResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/apis/APIEndpointResource.java @@ -69,6 +69,8 @@ @Collection(name = "apiEndpoints") public class APIEndpointResource extends EntityResource { public static final String COLLECTION_PATH = "v1/apiEndpoints/"; + private final APIEndpointMapper mapper = new APIEndpointMapper(); + static final String FIELDS = "owners,followers,tags,extension,domain,dataProducts,sourceHash"; @Override @@ -304,7 +306,8 @@ public Response create( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateAPIEndpoint create) { - APIEndpoint apiEndpoint = getAPIEndpoint(create, securityContext.getUserPrincipal().getName()); + APIEndpoint apiEndpoint = + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return create(uriInfo, securityContext, apiEndpoint); } @@ -385,7 +388,8 @@ public Response createOrUpdate( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateAPIEndpoint create) { - APIEndpoint apiEndpoint = getAPIEndpoint(create, securityContext.getUserPrincipal().getName()); + APIEndpoint apiEndpoint = + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return createOrUpdate(uriInfo, securityContext, apiEndpoint); } @@ -552,15 +556,4 @@ public Response restore( @Valid RestoreEntity restore) { return restoreEntity(uriInfo, securityContext, restore.getId()); } - - private APIEndpoint getAPIEndpoint(CreateAPIEndpoint create, String user) { - return repository - .copy(new APIEndpoint(), create, user) - .withApiCollection(getEntityReference(Entity.API_COLLCECTION, create.getApiCollection())) - .withRequestMethod(create.getRequestMethod()) - .withEndpointURL(create.getEndpointURL()) - .withRequestSchema(create.getRequestSchema()) - .withResponseSchema(create.getResponseSchema()) - .withSourceHash(create.getSourceHash()); - } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/apps/AppMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/apps/AppMapper.java new file mode 100644 index 000000000000..62b941626361 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/apps/AppMapper.java @@ -0,0 +1,72 @@ +package org.openmetadata.service.resources.apps; + +import static org.openmetadata.service.Entity.BOT; +import static org.openmetadata.service.jdbi3.EntityRepository.validateOwners; + +import java.util.List; +import java.util.UUID; +import org.openmetadata.common.utils.CommonUtil; +import org.openmetadata.schema.entity.app.App; +import org.openmetadata.schema.entity.app.AppMarketPlaceDefinition; +import org.openmetadata.schema.entity.app.CreateApp; +import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.schema.type.Include; +import org.openmetadata.service.Entity; +import org.openmetadata.service.jdbi3.AppMarketPlaceRepository; +import org.openmetadata.service.jdbi3.AppRepository; +import org.openmetadata.service.mapper.EntityMapper; +import org.openmetadata.service.util.EntityUtil; + +public class AppMapper implements EntityMapper { + @Override + public App createToEntity(CreateApp createAppRequest, String updatedBy) { + AppMarketPlaceRepository appMarketPlaceRepository = + (AppMarketPlaceRepository) Entity.getEntityRepository(Entity.APP_MARKET_PLACE_DEF); + AppMarketPlaceDefinition marketPlaceDefinition = + appMarketPlaceRepository.getByName( + null, + createAppRequest.getName(), + new EntityUtil.Fields(appMarketPlaceRepository.getAllowedFields())); + List owners = validateOwners(createAppRequest.getOwners()); + App app = + new App() + .withId(UUID.randomUUID()) + .withName(marketPlaceDefinition.getName()) + .withDisplayName(createAppRequest.getDisplayName()) + .withDescription(createAppRequest.getDescription()) + .withOwners(owners) + .withUpdatedBy(updatedBy) + .withUpdatedAt(System.currentTimeMillis()) + .withDeveloper(marketPlaceDefinition.getDeveloper()) + .withDeveloperUrl(marketPlaceDefinition.getDeveloperUrl()) + .withPrivacyPolicyUrl(marketPlaceDefinition.getPrivacyPolicyUrl()) + .withSupportEmail(marketPlaceDefinition.getSupportEmail()) + .withClassName(marketPlaceDefinition.getClassName()) + .withAppType(marketPlaceDefinition.getAppType()) + .withScheduleType(marketPlaceDefinition.getScheduleType()) + .withAppConfiguration(createAppRequest.getAppConfiguration()) + .withRuntime(marketPlaceDefinition.getRuntime()) + .withPermission(marketPlaceDefinition.getPermission()) + .withAppSchedule(createAppRequest.getAppSchedule()) + .withAppLogoUrl(marketPlaceDefinition.getAppLogoUrl()) + .withAppScreenshots(marketPlaceDefinition.getAppScreenshots()) + .withFeatures(marketPlaceDefinition.getFeatures()) + .withSourcePythonClass(marketPlaceDefinition.getSourcePythonClass()) + .withAllowConfiguration(marketPlaceDefinition.getAllowConfiguration()) + .withSystem(marketPlaceDefinition.getSystem()) + .withSupportsInterrupt(marketPlaceDefinition.getSupportsInterrupt()); + + // validate Bot if provided + validateAndAddBot(app, createAppRequest.getBot()); + return app; + } + + private void validateAndAddBot(App app, String botName) { + AppRepository appRepository = (AppRepository) Entity.getEntityRepository(Entity.APPLICATION); + if (!CommonUtil.nullOrEmpty(botName)) { + app.setBot(Entity.getEntityReferenceByName(BOT, botName, Include.NON_DELETED)); + } else { + app.setBot(appRepository.createNewAppBot(app)); + } + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/apps/AppMarketPlaceMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/apps/AppMarketPlaceMapper.java new file mode 100644 index 000000000000..532cf057b101 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/apps/AppMarketPlaceMapper.java @@ -0,0 +1,68 @@ +package org.openmetadata.service.resources.apps; + +import javax.ws.rs.BadRequestException; +import org.openmetadata.schema.entity.app.AppMarketPlaceDefinition; +import org.openmetadata.schema.entity.app.AppType; +import org.openmetadata.schema.entity.app.CreateAppMarketPlaceDefinitionReq; +import org.openmetadata.schema.entity.services.ingestionPipelines.PipelineServiceClientResponse; +import org.openmetadata.sdk.PipelineServiceClientInterface; +import org.openmetadata.service.mapper.EntityMapper; +import org.openmetadata.service.util.JsonUtils; + +public class AppMarketPlaceMapper + implements EntityMapper { + private PipelineServiceClientInterface pipelineServiceClient; + + public AppMarketPlaceMapper(PipelineServiceClientInterface pipelineServiceClient) { + this.pipelineServiceClient = pipelineServiceClient; + } + + @Override + public AppMarketPlaceDefinition createToEntity( + CreateAppMarketPlaceDefinitionReq create, String user) { + AppMarketPlaceDefinition app = + copy(new AppMarketPlaceDefinition(), create, user) + .withDeveloper(create.getDeveloper()) + .withDeveloperUrl(create.getDeveloperUrl()) + .withSupportEmail(create.getSupportEmail()) + .withPrivacyPolicyUrl(create.getPrivacyPolicyUrl()) + .withClassName(create.getClassName()) + .withAppType(create.getAppType()) + .withScheduleType(create.getScheduleType()) + .withRuntime(create.getRuntime()) + .withAppConfiguration(create.getAppConfiguration()) + .withPermission(create.getPermission()) + .withAppLogoUrl(create.getAppLogoUrl()) + .withAppScreenshots(create.getAppScreenshots()) + .withFeatures(create.getFeatures()) + .withSourcePythonClass(create.getSourcePythonClass()) + .withAllowConfiguration(create.getAllowConfiguration()) + .withSystem(create.getSystem()) + .withSupportsInterrupt(create.getSupportsInterrupt()); + + // Validate App + validateApplication(app); + return app; + } + + private void validateApplication(AppMarketPlaceDefinition app) { + // Check if the className Exists in classPath + if (app.getAppType().equals(AppType.Internal)) { + // Check class name exists + try { + Class.forName(app.getClassName()); + } catch (ClassNotFoundException e) { + throw new BadRequestException( + "Application Cannot be registered, because the classname cannot be found on the Classpath."); + } + } else { + PipelineServiceClientResponse response = pipelineServiceClient.validateAppRegistration(app); + if (response.getCode() != 200) { + throw new BadRequestException( + String.format( + "Application Cannot be registered, Error from Pipeline Service Client. Status Code : %s , Reponse : %s", + response.getCode(), JsonUtils.pojoToJson(response))); + } + } + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/apps/AppMarketPlaceResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/apps/AppMarketPlaceResource.java index 11540d291828..374ab5ba719a 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/apps/AppMarketPlaceResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/apps/AppMarketPlaceResource.java @@ -18,7 +18,6 @@ import javax.validation.Valid; import javax.validation.constraints.Max; import javax.validation.constraints.Min; -import javax.ws.rs.BadRequestException; import javax.ws.rs.Consumes; import javax.ws.rs.DELETE; import javax.ws.rs.DefaultValue; @@ -39,9 +38,7 @@ import org.openmetadata.schema.api.data.RestoreEntity; import org.openmetadata.schema.entity.app.App; import org.openmetadata.schema.entity.app.AppMarketPlaceDefinition; -import org.openmetadata.schema.entity.app.AppType; import org.openmetadata.schema.entity.app.CreateAppMarketPlaceDefinitionReq; -import org.openmetadata.schema.entity.services.ingestionPipelines.PipelineServiceClientResponse; import org.openmetadata.schema.type.EntityHistory; import org.openmetadata.schema.type.Include; import org.openmetadata.sdk.PipelineServiceClientInterface; @@ -55,7 +52,6 @@ import org.openmetadata.service.resources.Collection; import org.openmetadata.service.resources.EntityResource; import org.openmetadata.service.security.Authorizer; -import org.openmetadata.service.util.JsonUtils; import org.openmetadata.service.util.ResultList; @Path("/v1/apps/marketplace") @@ -70,7 +66,7 @@ public class AppMarketPlaceResource extends EntityResource { public static final String COLLECTION_PATH = "/v1/apps/marketplace/"; private PipelineServiceClientInterface pipelineServiceClient; - + private AppMarketPlaceMapper mapper; static final String FIELDS = "owners,tags"; @Override @@ -80,6 +76,7 @@ public void initialize(OpenMetadataApplicationConfig config) { PipelineServiceClientFactory.createPipelineServiceClient( config.getPipelineServiceClientConfiguration()); + mapper = new AppMarketPlaceMapper(pipelineServiceClient); // Initialize Default Installed Applications List createAppMarketPlaceDefinitionReqs = getEntitiesFromSeedData( @@ -87,7 +84,7 @@ public void initialize(OpenMetadataApplicationConfig config) { String.format(".*json/data/%s/.*\\.json$", entityType), CreateAppMarketPlaceDefinitionReq.class); for (CreateAppMarketPlaceDefinitionReq definitionReq : createAppMarketPlaceDefinitionReqs) { - AppMarketPlaceDefinition definition = getApplicationDefinition(definitionReq, "admin"); + AppMarketPlaceDefinition definition = mapper.createToEntity(definitionReq, "admin"); // Update Fully Qualified Name repository.setFullyQualifiedName(definition); this.repository.createOrUpdate(null, definition); @@ -309,7 +306,7 @@ public Response create( @Context SecurityContext securityContext, @Valid CreateAppMarketPlaceDefinitionReq create) { AppMarketPlaceDefinition app = - getApplicationDefinition(create, securityContext.getUserPrincipal().getName()); + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return create(uriInfo, securityContext, app); } @@ -389,7 +386,7 @@ public Response createOrUpdate( @Context SecurityContext securityContext, @Valid CreateAppMarketPlaceDefinitionReq create) { AppMarketPlaceDefinition app = - getApplicationDefinition(create, securityContext.getUserPrincipal().getName()); + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return createOrUpdate(uriInfo, securityContext, app); } @@ -459,53 +456,4 @@ public Response restoreApp( @Valid RestoreEntity restore) { return restoreEntity(uriInfo, securityContext, restore.getId()); } - - private AppMarketPlaceDefinition getApplicationDefinition( - CreateAppMarketPlaceDefinitionReq create, String updatedBy) { - AppMarketPlaceDefinition app = - repository - .copy(new AppMarketPlaceDefinition(), create, updatedBy) - .withDeveloper(create.getDeveloper()) - .withDeveloperUrl(create.getDeveloperUrl()) - .withSupportEmail(create.getSupportEmail()) - .withPrivacyPolicyUrl(create.getPrivacyPolicyUrl()) - .withClassName(create.getClassName()) - .withAppType(create.getAppType()) - .withScheduleType(create.getScheduleType()) - .withRuntime(create.getRuntime()) - .withAppConfiguration(create.getAppConfiguration()) - .withPermission(create.getPermission()) - .withAppLogoUrl(create.getAppLogoUrl()) - .withAppScreenshots(create.getAppScreenshots()) - .withFeatures(create.getFeatures()) - .withSourcePythonClass(create.getSourcePythonClass()) - .withAllowConfiguration(create.getAllowConfiguration()) - .withSystem(create.getSystem()) - .withSupportsInterrupt(create.getSupportsInterrupt()); - - // Validate App - validateApplication(app); - return app; - } - - private void validateApplication(AppMarketPlaceDefinition app) { - // Check if the className Exists in classPath - if (app.getAppType().equals(AppType.Internal)) { - // Check class name exists - try { - Class.forName(app.getClassName()); - } catch (ClassNotFoundException e) { - throw new BadRequestException( - "Application Cannot be registered, because the classname cannot be found on the Classpath."); - } - } else { - PipelineServiceClientResponse response = pipelineServiceClient.validateAppRegistration(app); - if (response.getCode() != 200) { - throw new BadRequestException( - String.format( - "Application Cannot be registered, Error from Pipeline Service Client. Status Code : %s , Reponse : %s", - response.getCode(), JsonUtils.pojoToJson(response))); - } - } - } } 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 21a88f23d942..4edb03b13059 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 @@ -3,7 +3,6 @@ import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; import static org.openmetadata.schema.type.Include.ALL; import static org.openmetadata.service.Entity.APPLICATION; -import static org.openmetadata.service.Entity.BOT; import static org.openmetadata.service.Entity.FIELD_OWNERS; import static org.openmetadata.service.jdbi3.EntityRepository.getEntitiesFromSeedData; @@ -43,12 +42,10 @@ import javax.ws.rs.core.SecurityContext; import javax.ws.rs.core.UriInfo; import lombok.extern.slf4j.Slf4j; -import org.openmetadata.common.utils.CommonUtil; import org.openmetadata.schema.ServiceEntityInterface; import org.openmetadata.schema.api.data.RestoreEntity; import org.openmetadata.schema.entity.app.App; import org.openmetadata.schema.entity.app.AppExtension; -import org.openmetadata.schema.entity.app.AppMarketPlaceDefinition; import org.openmetadata.schema.entity.app.AppRunRecord; import org.openmetadata.schema.entity.app.AppType; import org.openmetadata.schema.entity.app.CreateApp; @@ -105,6 +102,7 @@ public class AppResource extends EntityResource { public static final List SCHEDULED_TYPES = List.of(ScheduleType.Scheduled, ScheduleType.ScheduledOrManual, ScheduleType.NoSchedule); public static final String SLACK_APPLICATION = "SlackApplication"; + private final AppMapper mapper = new AppMapper(); @Override public void initialize(OpenMetadataApplicationConfig config) { @@ -133,18 +131,10 @@ private void loadDefaultApplications(List defaultAppCreateRequests) { // Get Create App Requests for (CreateApp createApp : defaultAppCreateRequests) { try { - AppMarketPlaceDefinition definition = - repository - .getMarketPlace() - .getByName( - null, - createApp.getName(), - new EntityUtil.Fields(repository.getMarketPlace().getAllowedFields())); App app = getAppForInit(createApp.getName()); if (app == null) { app = - getApplication(definition, createApp, "admin") - .withFullyQualifiedName(createApp.getName()); + mapper.createToEntity(createApp, "admin").withFullyQualifiedName(createApp.getName()); repository.initializeEntity(app); } @@ -621,15 +611,7 @@ public App getVersion( }) public Response create( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateApp create) { - - AppMarketPlaceDefinition definition = - repository - .getMarketPlace() - .getByName( - uriInfo, - create.getName(), - new EntityUtil.Fields(repository.getMarketPlace().getAllowedFields())); - App app = getApplication(definition, create, securityContext.getUserPrincipal().getName()); + App app = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); limits.enforceLimits( securityContext, getResourceContext(), @@ -749,14 +731,7 @@ public Response patchApplication( public Response createOrUpdate( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateApp create) throws SchedulerException { - AppMarketPlaceDefinition definition = - repository - .getMarketPlace() - .getByName( - uriInfo, - create.getName(), - new EntityUtil.Fields(repository.getMarketPlace().getAllowedFields())); - App app = getApplication(definition, create, securityContext.getUserPrincipal().getName()); + App app = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); AppScheduler.getInstance().deleteScheduledApplication(app); if (SCHEDULED_TYPES.contains(app.getScheduleType())) { ApplicationHandler.getInstance() @@ -1100,52 +1075,6 @@ private void decryptOrNullify( } } - private App getApplication( - AppMarketPlaceDefinition marketPlaceDefinition, - CreateApp createAppRequest, - String updatedBy) { - List owners = repository.validateOwners(createAppRequest.getOwners()); - App app = - new App() - .withId(UUID.randomUUID()) - .withName(marketPlaceDefinition.getName()) - .withDisplayName(createAppRequest.getDisplayName()) - .withDescription(createAppRequest.getDescription()) - .withOwners(owners) - .withUpdatedBy(updatedBy) - .withUpdatedAt(System.currentTimeMillis()) - .withDeveloper(marketPlaceDefinition.getDeveloper()) - .withDeveloperUrl(marketPlaceDefinition.getDeveloperUrl()) - .withPrivacyPolicyUrl(marketPlaceDefinition.getPrivacyPolicyUrl()) - .withSupportEmail(marketPlaceDefinition.getSupportEmail()) - .withClassName(marketPlaceDefinition.getClassName()) - .withAppType(marketPlaceDefinition.getAppType()) - .withScheduleType(marketPlaceDefinition.getScheduleType()) - .withAppConfiguration(createAppRequest.getAppConfiguration()) - .withRuntime(marketPlaceDefinition.getRuntime()) - .withPermission(marketPlaceDefinition.getPermission()) - .withAppSchedule(createAppRequest.getAppSchedule()) - .withAppLogoUrl(marketPlaceDefinition.getAppLogoUrl()) - .withAppScreenshots(marketPlaceDefinition.getAppScreenshots()) - .withFeatures(marketPlaceDefinition.getFeatures()) - .withSourcePythonClass(marketPlaceDefinition.getSourcePythonClass()) - .withAllowConfiguration(marketPlaceDefinition.getAllowConfiguration()) - .withSystem(marketPlaceDefinition.getSystem()) - .withSupportsInterrupt(marketPlaceDefinition.getSupportsInterrupt()); - - // validate Bot if provided - validateAndAddBot(app, createAppRequest.getBot()); - return app; - } - - private void validateAndAddBot(App app, String botName) { - if (!CommonUtil.nullOrEmpty(botName)) { - app.setBot(Entity.getEntityReferenceByName(BOT, botName, Include.NON_DELETED)); - } else { - app.setBot(repository.createNewAppBot(app)); - } - } - private IngestionPipeline getIngestionPipeline( UriInfo uriInfo, SecurityContext securityContext, App app) { EntityReference pipelineRef = app.getPipelines().get(0); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/automations/WorkflowMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/automations/WorkflowMapper.java new file mode 100644 index 000000000000..2aa3af07e0d6 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/automations/WorkflowMapper.java @@ -0,0 +1,31 @@ +package org.openmetadata.service.resources.automations; + +import org.openmetadata.schema.entity.automations.CreateWorkflow; +import org.openmetadata.schema.entity.automations.Workflow; +import org.openmetadata.schema.services.connections.metadata.OpenMetadataConnection; +import org.openmetadata.service.OpenMetadataApplicationConfig; +import org.openmetadata.service.mapper.EntityMapper; +import org.openmetadata.service.util.OpenMetadataConnectionBuilder; + +public class WorkflowMapper implements EntityMapper { + private final OpenMetadataApplicationConfig openMetadataApplicationConfig; + + public WorkflowMapper(OpenMetadataApplicationConfig config) { + this.openMetadataApplicationConfig = config; + } + + @Override + public Workflow createToEntity(CreateWorkflow create, String user) { + OpenMetadataConnection openMetadataServerConnection = + new OpenMetadataConnectionBuilder(openMetadataApplicationConfig).build(); + return copy(new Workflow(), create, user) + .withDescription(create.getDescription()) + .withRequest(create.getRequest()) + .withWorkflowType(create.getWorkflowType()) + .withDisplayName(create.getDisplayName()) + .withResponse(create.getResponse()) + .withStatus(create.getStatus()) + .withOpenMetadataServerConnection(openMetadataServerConnection) + .withName(create.getName()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/automations/WorkflowResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/automations/WorkflowResource.java index 0ec7efa9df40..01f22137ccad 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/automations/WorkflowResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/automations/WorkflowResource.java @@ -83,7 +83,7 @@ public class WorkflowResource extends EntityResource { public static final String COLLECTION_PATH = "/v1/automations/workflows"; static final String FIELDS = "owners"; - + private WorkflowMapper mapper; private PipelineServiceClientInterface pipelineServiceClient; private OpenMetadataApplicationConfig openMetadataApplicationConfig; @@ -94,7 +94,7 @@ public WorkflowResource(Authorizer authorizer, Limits limits) { @Override public void initialize(OpenMetadataApplicationConfig config) { this.openMetadataApplicationConfig = config; - + this.mapper = new WorkflowMapper(config); this.pipelineServiceClient = PipelineServiceClientFactory.createPipelineServiceClient( config.getPipelineServiceClientConfiguration()); @@ -331,7 +331,7 @@ public Response create( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateWorkflow create) { - Workflow workflow = getWorkflow(create, securityContext.getUserPrincipal().getName()); + Workflow workflow = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); Response response = create(uriInfo, securityContext, unmask(workflow)); return Response.fromResponse(response) .entity(decryptOrNullify(securityContext, (Workflow) response.getEntity())) @@ -452,7 +452,7 @@ public Response createOrUpdate( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateWorkflow create) { - Workflow workflow = getWorkflow(create, securityContext.getUserPrincipal().getName()); + Workflow workflow = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); workflow = unmask(workflow); Response response = createOrUpdate(uriInfo, securityContext, workflow); return Response.fromResponse(response) @@ -539,21 +539,6 @@ public Response restoreWorkflow( .build(); } - private Workflow getWorkflow(CreateWorkflow create, String user) { - OpenMetadataConnection openMetadataServerConnection = - new OpenMetadataConnectionBuilder(openMetadataApplicationConfig).build(); - return repository - .copy(new Workflow(), create, user) - .withDescription(create.getDescription()) - .withRequest(create.getRequest()) - .withWorkflowType(create.getWorkflowType()) - .withDisplayName(create.getDisplayName()) - .withResponse(create.getResponse()) - .withStatus(create.getStatus()) - .withOpenMetadataServerConnection(openMetadataServerConnection) - .withName(create.getName()); - } - private Workflow unmask(Workflow workflow) { repository.setFullyQualifiedName(workflow); Workflow originalWorkflow; diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/bots/BotMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/bots/BotMapper.java new file mode 100644 index 000000000000..170dbbcc7ec1 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/bots/BotMapper.java @@ -0,0 +1,85 @@ +package org.openmetadata.service.resources.bots; + +import static org.openmetadata.service.util.EntityUtil.getEntityReference; + +import java.util.List; +import org.openmetadata.schema.EntityInterface; +import org.openmetadata.schema.api.CreateBot; +import org.openmetadata.schema.entity.Bot; +import org.openmetadata.schema.entity.teams.User; +import org.openmetadata.schema.type.Include; +import org.openmetadata.schema.type.Relationship; +import org.openmetadata.service.Entity; +import org.openmetadata.service.exception.CatalogExceptionMessage; +import org.openmetadata.service.jdbi3.BotRepository; +import org.openmetadata.service.jdbi3.CollectionDAO; +import org.openmetadata.service.jdbi3.EntityRepository; +import org.openmetadata.service.mapper.EntityMapper; +import org.openmetadata.service.util.EntityUtil; + +public class BotMapper implements EntityMapper { + @Override + public Bot createToEntity(CreateBot create, String user) { + BotRepository repository = (BotRepository) Entity.getEntityRepository(Entity.BOT); + Bot bot = getBot(create, user); + Bot originalBot = retrieveBot(bot.getName()); + User botUser = retrieveUser(bot); + if (botUser != null && !Boolean.TRUE.equals(botUser.getIsBot())) { + throw new IllegalArgumentException( + String.format("User [%s] is not a bot user", botUser.getName())); + } + if (userHasRelationshipWithAnyBot(botUser, originalBot)) { + List userBotRelationship = + retrieveBotRelationshipsFor(botUser); + bot = + repository.get( + null, + userBotRelationship.stream().findFirst().orElseThrow().getId(), + EntityUtil.Fields.EMPTY_FIELDS); + throw new IllegalArgumentException( + CatalogExceptionMessage.userAlreadyBot(botUser.getName(), bot.getName())); + } + // TODO: review this flow on https://github.com/open-metadata/OpenMetadata/issues/8321 + if (originalBot != null) { + bot.setProvider(originalBot.getProvider()); + } + return bot; + } + + private Bot getBot(CreateBot create, String user) { + return copy(new Bot(), create, user) + .withBotUser(getEntityReference(Entity.USER, create.getBotUser())) + .withProvider(create.getProvider()) + .withFullyQualifiedName(create.getName()); + } + + private boolean userHasRelationshipWithAnyBot(User user, Bot botUser) { + if (user == null) { + return false; + } + List userBotRelationship = + retrieveBotRelationshipsFor(user); + return !userBotRelationship.isEmpty() + && (botUser == null + || userBotRelationship.stream() + .anyMatch(relationship -> !relationship.getId().equals(botUser.getId()))); + } + + private List retrieveBotRelationshipsFor(User user) { + BotRepository repository = (BotRepository) Entity.getEntityRepository(Entity.BOT); + return repository.findFromRecords(user.getId(), Entity.USER, Relationship.CONTAINS, Entity.BOT); + } + + private User retrieveUser(Bot bot) { + EntityRepository userRepository = + Entity.getEntityRepository(Entity.USER); + return (User) + userRepository.findByNameOrNull( + bot.getBotUser().getFullyQualifiedName(), Include.NON_DELETED); + } + + private Bot retrieveBot(String botName) { + BotRepository repository = (BotRepository) Entity.getEntityRepository(Entity.BOT); + return repository.findByNameOrNull(botName, Include.NON_DELETED); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/bots/BotResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/bots/BotResource.java index 457485dcf575..eaf240ec5618 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/bots/BotResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/bots/BotResource.java @@ -48,30 +48,24 @@ import javax.ws.rs.core.SecurityContext; import javax.ws.rs.core.UriInfo; import lombok.extern.slf4j.Slf4j; -import org.openmetadata.schema.EntityInterface; import org.openmetadata.schema.api.CreateBot; import org.openmetadata.schema.api.data.RestoreEntity; import org.openmetadata.schema.entity.Bot; +import org.openmetadata.schema.entity.teams.Role; import org.openmetadata.schema.entity.teams.User; import org.openmetadata.schema.type.EntityHistory; import org.openmetadata.schema.type.Include; -import org.openmetadata.schema.type.Relationship; import org.openmetadata.schema.utils.EntityInterfaceUtil; import org.openmetadata.service.Entity; import org.openmetadata.service.OpenMetadataApplicationConfig; -import org.openmetadata.service.exception.CatalogExceptionMessage; import org.openmetadata.service.jdbi3.BotRepository; -import org.openmetadata.service.jdbi3.CollectionDAO.EntityRelationshipRecord; -import org.openmetadata.service.jdbi3.EntityRepository; import org.openmetadata.service.jdbi3.ListFilter; import org.openmetadata.service.jdbi3.UserRepository; import org.openmetadata.service.limits.Limits; import org.openmetadata.service.resources.Collection; import org.openmetadata.service.resources.EntityResource; -import org.openmetadata.service.resources.teams.RoleResource; import org.openmetadata.service.security.Authorizer; import org.openmetadata.service.security.SecurityUtil; -import org.openmetadata.service.util.EntityUtil; import org.openmetadata.service.util.ResultList; import org.openmetadata.service.util.UserUtil; @@ -87,6 +81,7 @@ @Collection(name = "bots", order = 4, requiredForOps = true) // initialize after user resource public class BotResource extends EntityResource { public static final String COLLECTION_PATH = "/v1/bots/"; + private final BotMapper mapper = new BotMapper(); public BotResource(Authorizer authorizer, Limits limits) { super(Entity.BOT, authorizer, limits); @@ -105,7 +100,17 @@ public void initialize(OpenMetadataApplicationConfig config) throws IOException .withIsAdmin(false); user.setRoles( listOrEmpty(botUser.getRoles()).stream() - .map(entityReference -> RoleResource.getRole(entityReference.getName())) + .map( + entityReference -> { + Role role = + Entity.getEntityByName( + Entity.ROLE, + entityReference.getName(), + "id", + Include.NON_DELETED, + true); + return role.getEntityReference(); + }) .toList()); // Add or update User Bot UserUtil.addOrUpdateBotUser(user); @@ -300,7 +305,7 @@ public Bot getVersion( }) public Response create( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateBot create) { - Bot bot = getBot(securityContext, create); + Bot bot = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return create(uriInfo, securityContext, bot); } @@ -321,7 +326,7 @@ public Response create( }) public Response createOrUpdate( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateBot create) { - Bot bot = getBot(securityContext, create); + Bot bot = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return createOrUpdate(uriInfo, securityContext, bot); } @@ -449,64 +454,4 @@ public Response restoreBot( @Valid RestoreEntity restore) { return restoreEntity(uriInfo, securityContext, restore.getId()); } - - private Bot getBot(CreateBot create, String user) { - return repository - .copy(new Bot(), create, user) - .withBotUser(getEntityReference(Entity.USER, create.getBotUser())) - .withProvider(create.getProvider()) - .withFullyQualifiedName(create.getName()); - } - - private boolean userHasRelationshipWithAnyBot(User user, Bot botUser) { - if (user == null) { - return false; - } - List userBotRelationship = retrieveBotRelationshipsFor(user); - return !userBotRelationship.isEmpty() - && (botUser == null - || userBotRelationship.stream() - .anyMatch(relationship -> !relationship.getId().equals(botUser.getId()))); - } - - private List retrieveBotRelationshipsFor(User user) { - return repository.findFromRecords(user.getId(), Entity.USER, Relationship.CONTAINS, Entity.BOT); - } - - private Bot getBot(SecurityContext securityContext, CreateBot create) { - Bot bot = getBot(create, securityContext.getUserPrincipal().getName()); - Bot originalBot = retrieveBot(bot.getName()); - User botUser = retrieveUser(bot); - if (botUser != null && !Boolean.TRUE.equals(botUser.getIsBot())) { - throw new IllegalArgumentException( - String.format("User [%s] is not a bot user", botUser.getName())); - } - if (userHasRelationshipWithAnyBot(botUser, originalBot)) { - List userBotRelationship = retrieveBotRelationshipsFor(botUser); - bot = - repository.get( - null, - userBotRelationship.stream().findFirst().orElseThrow().getId(), - EntityUtil.Fields.EMPTY_FIELDS); - throw new IllegalArgumentException( - CatalogExceptionMessage.userAlreadyBot(botUser.getName(), bot.getName())); - } - // TODO: review this flow on https://github.com/open-metadata/OpenMetadata/issues/8321 - if (originalBot != null) { - bot.setProvider(originalBot.getProvider()); - } - return bot; - } - - private User retrieveUser(Bot bot) { - EntityRepository userRepository = - Entity.getEntityRepository(Entity.USER); - return (User) - userRepository.findByNameOrNull( - bot.getBotUser().getFullyQualifiedName(), Include.NON_DELETED); - } - - private Bot retrieveBot(String botName) { - return repository.findByNameOrNull(botName, Include.NON_DELETED); - } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/charts/ChartMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/charts/ChartMapper.java new file mode 100644 index 000000000000..095077f0a8e5 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/charts/ChartMapper.java @@ -0,0 +1,21 @@ +package org.openmetadata.service.resources.charts; + +import static org.openmetadata.service.util.EntityUtil.getEntityReferences; + +import org.openmetadata.schema.api.data.CreateChart; +import org.openmetadata.schema.entity.data.Chart; +import org.openmetadata.service.Entity; +import org.openmetadata.service.mapper.EntityMapper; +import org.openmetadata.service.util.EntityUtil; + +public class ChartMapper implements EntityMapper { + @Override + public Chart createToEntity(CreateChart create, String user) { + return copy(new Chart(), create, user) + .withService(EntityUtil.getEntityReference(Entity.DASHBOARD_SERVICE, create.getService())) + .withChartType(create.getChartType()) + .withSourceUrl(create.getSourceUrl()) + .withSourceHash(create.getSourceHash()) + .withDashboards(getEntityReferences(Entity.DASHBOARD, create.getDashboards())); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/charts/ChartResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/charts/ChartResource.java index 31dc3da0797d..b9d8ca6268b0 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/charts/ChartResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/charts/ChartResource.java @@ -61,7 +61,6 @@ import org.openmetadata.service.resources.Collection; import org.openmetadata.service.resources.EntityResource; import org.openmetadata.service.security.Authorizer; -import org.openmetadata.service.util.EntityUtil; import org.openmetadata.service.util.ResultList; @Path("/v1/charts") @@ -74,6 +73,7 @@ @Collection(name = "charts") public class ChartResource extends EntityResource { public static final String COLLECTION_PATH = "v1/charts/"; + private final ChartMapper mapper = new ChartMapper(); static final String FIELDS = "owners,followers,tags,domain,dataProducts,sourceHash,dashboards"; @Override @@ -300,7 +300,7 @@ public Response create( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateChart create) { - Chart chart = getChart(create, securityContext.getUserPrincipal().getName()); + Chart chart = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return create(uriInfo, securityContext, chart); } @@ -379,7 +379,7 @@ public Response createOrUpdate( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateChart create) { - Chart chart = getChart(create, securityContext.getUserPrincipal().getName()); + Chart chart = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return createOrUpdate(uriInfo, securityContext, chart); } @@ -523,14 +523,4 @@ public Response restoreChart( @Valid RestoreEntity restore) { return restoreEntity(uriInfo, securityContext, restore.getId()); } - - private Chart getChart(CreateChart create, String user) { - return repository - .copy(new Chart(), create, user) - .withService(EntityUtil.getEntityReference(Entity.DASHBOARD_SERVICE, create.getService())) - .withChartType(create.getChartType()) - .withSourceUrl(create.getSourceUrl()) - .withSourceHash(create.getSourceHash()) - .withDashboards(getEntityReferences(Entity.DASHBOARD, create.getDashboards())); - } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/dashboards/DashboardMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/dashboards/DashboardMapper.java new file mode 100644 index 000000000000..c7ba5a1084a5 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/dashboards/DashboardMapper.java @@ -0,0 +1,23 @@ +package org.openmetadata.service.resources.dashboards; + +import static org.openmetadata.service.util.EntityUtil.getEntityReference; +import static org.openmetadata.service.util.EntityUtil.getEntityReferences; + +import org.openmetadata.schema.api.data.CreateDashboard; +import org.openmetadata.schema.entity.data.Dashboard; +import org.openmetadata.service.Entity; +import org.openmetadata.service.mapper.EntityMapper; + +public class DashboardMapper implements EntityMapper { + @Override + public Dashboard createToEntity(CreateDashboard create, String user) { + return copy(new Dashboard(), create, user) + .withService(getEntityReference(Entity.DASHBOARD_SERVICE, create.getService())) + .withCharts(getEntityReferences(Entity.CHART, create.getCharts())) + .withDataModels(getEntityReferences(Entity.DASHBOARD_DATA_MODEL, create.getDataModels())) + .withSourceUrl(create.getSourceUrl()) + .withDashboardType(create.getDashboardType()) + .withProject(create.getProject()) + .withSourceHash(create.getSourceHash()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/dashboards/DashboardResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/dashboards/DashboardResource.java index 19aea5208efa..bc26f5a1138d 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/dashboards/DashboardResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/dashboards/DashboardResource.java @@ -76,6 +76,7 @@ public class DashboardResource extends EntityResource { + @Override + public Database createToEntity(CreateDatabase create, String user) { + return copy(new Database(), create, user) + .withService(getEntityReference(Entity.DATABASE_SERVICE, create.getService())) + .withSourceUrl(create.getSourceUrl()) + .withRetentionPeriod(create.getRetentionPeriod()) + .withSourceHash(create.getSourceHash()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/databases/DatabaseResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/databases/DatabaseResource.java index e46ce51b8ff7..646f0b0ee53b 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/databases/DatabaseResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/databases/DatabaseResource.java @@ -77,6 +77,7 @@ @Collection(name = "databases") public class DatabaseResource extends EntityResource { public static final String COLLECTION_PATH = "v1/databases/"; + private final DatabaseMapper mapper = new DatabaseMapper(); static final String FIELDS = "owners,databaseSchemas,usageSummary,location,tags,extension,domain,sourceHash"; @@ -310,7 +311,7 @@ public Response create( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateDatabase create) { - Database database = getDatabase(create, securityContext.getUserPrincipal().getName()); + Database database = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return create(uriInfo, securityContext, database); } @@ -390,7 +391,7 @@ public Response createOrUpdate( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateDatabase create) { - Database database = getDatabase(create, securityContext.getUserPrincipal().getName()); + Database database = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return createOrUpdate(uriInfo, securityContext, database); } @@ -705,13 +706,4 @@ public Database deleteDataProfilerConfig( Database database = repository.deleteDatabaseProfilerConfig(id); return addHref(uriInfo, database); } - - private Database getDatabase(CreateDatabase create, String user) { - return repository - .copy(new Database(), create, user) - .withService(getEntityReference(Entity.DATABASE_SERVICE, create.getService())) - .withSourceUrl(create.getSourceUrl()) - .withRetentionPeriod(create.getRetentionPeriod()) - .withSourceHash(create.getSourceHash()); - } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/databases/DatabaseSchemaMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/databases/DatabaseSchemaMapper.java new file mode 100644 index 000000000000..b6d9e9c6fc43 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/databases/DatabaseSchemaMapper.java @@ -0,0 +1,19 @@ +package org.openmetadata.service.resources.databases; + +import static org.openmetadata.service.util.EntityUtil.getEntityReference; + +import org.openmetadata.schema.api.data.CreateDatabaseSchema; +import org.openmetadata.schema.entity.data.DatabaseSchema; +import org.openmetadata.service.Entity; +import org.openmetadata.service.mapper.EntityMapper; + +public class DatabaseSchemaMapper implements EntityMapper { + @Override + public DatabaseSchema createToEntity(CreateDatabaseSchema create, String user) { + return copy(new DatabaseSchema(), create, user) + .withDatabase(getEntityReference(Entity.DATABASE, create.getDatabase())) + .withSourceUrl(create.getSourceUrl()) + .withRetentionPeriod(create.getRetentionPeriod()) + .withSourceHash(create.getSourceHash()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/databases/DatabaseSchemaResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/databases/DatabaseSchemaResource.java index 72bbb666f508..fc0e4becc212 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/databases/DatabaseSchemaResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/databases/DatabaseSchemaResource.java @@ -75,6 +75,7 @@ @Collection(name = "databaseSchemas") public class DatabaseSchemaResource extends EntityResource { + private final DatabaseSchemaMapper mapper = new DatabaseSchemaMapper(); public static final String COLLECTION_PATH = "v1/databaseSchemas/"; static final String FIELDS = "owners,tables,usageSummary,tags,extension,domain,sourceHash"; @@ -309,7 +310,8 @@ public Response create( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateDatabaseSchema create) { - DatabaseSchema schema = getDatabaseSchema(create, securityContext.getUserPrincipal().getName()); + DatabaseSchema schema = + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return create(uriInfo, securityContext, schema); } @@ -390,7 +392,8 @@ public Response createOrUpdate( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateDatabaseSchema create) { - DatabaseSchema schema = getDatabaseSchema(create, securityContext.getUserPrincipal().getName()); + DatabaseSchema schema = + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return createOrUpdate(uriInfo, securityContext, schema); } @@ -739,13 +742,4 @@ public Response searchSchemaEntityRelationship( return Entity.getSearchRepository() .searchSchemaEntityRelationship(fqn, upstreamDepth, downstreamDepth, queryFilter, deleted); } - - private DatabaseSchema getDatabaseSchema(CreateDatabaseSchema create, String user) { - return repository - .copy(new DatabaseSchema(), create, user) - .withDatabase(getEntityReference(Entity.DATABASE, create.getDatabase())) - .withSourceUrl(create.getSourceUrl()) - .withRetentionPeriod(create.getRetentionPeriod()) - .withSourceHash(create.getSourceHash()); - } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/databases/StoredProcedureMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/databases/StoredProcedureMapper.java new file mode 100644 index 000000000000..ca1013b8566c --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/databases/StoredProcedureMapper.java @@ -0,0 +1,20 @@ +package org.openmetadata.service.resources.databases; + +import static org.openmetadata.service.util.EntityUtil.getEntityReference; + +import org.openmetadata.schema.api.data.CreateStoredProcedure; +import org.openmetadata.schema.entity.data.StoredProcedure; +import org.openmetadata.service.Entity; +import org.openmetadata.service.mapper.EntityMapper; + +public class StoredProcedureMapper implements EntityMapper { + @Override + public StoredProcedure createToEntity(CreateStoredProcedure create, String user) { + return 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/databases/StoredProcedureResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/databases/StoredProcedureResource.java index 84626a53062b..f83976818d33 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 @@ -42,6 +42,7 @@ @Collection(name = "storedProcedures") public class StoredProcedureResource extends EntityResource { + private final StoredProcedureMapper mapper = new StoredProcedureMapper(); public static final String COLLECTION_PATH = "v1/storedProcedures/"; static final String FIELDS = "owners,tags,followers,extension,domain,sourceHash"; @@ -271,7 +272,7 @@ public Response create( @Context SecurityContext securityContext, @Valid CreateStoredProcedure create) { StoredProcedure storedProcedure = - getStoredProcedure(create, securityContext.getUserPrincipal().getName()); + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return create(uriInfo, securityContext, storedProcedure); } @@ -353,7 +354,7 @@ public Response createOrUpdate( @Context SecurityContext securityContext, @Valid CreateStoredProcedure create) { StoredProcedure storedProcedure = - getStoredProcedure(create, securityContext.getUserPrincipal().getName()); + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return createOrUpdate(uriInfo, securityContext, storedProcedure); } @@ -527,14 +528,4 @@ public Response restoreStoredProcedure( @Valid RestoreEntity restore) { return restoreEntity(uriInfo, securityContext, restore.getId()); } - - private StoredProcedure getStoredProcedure(CreateStoredProcedure create, String user) { - return repository - .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/databases/TableMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/databases/TableMapper.java new file mode 100644 index 000000000000..48b609724064 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/databases/TableMapper.java @@ -0,0 +1,53 @@ +package org.openmetadata.service.resources.databases; + +import static org.openmetadata.service.util.EntityUtil.getEntityReference; + +import java.util.UUID; +import org.openmetadata.schema.api.data.CreateTable; +import org.openmetadata.schema.api.tests.CreateCustomMetric; +import org.openmetadata.schema.entity.data.Table; +import org.openmetadata.schema.tests.CustomMetric; +import org.openmetadata.service.Entity; +import org.openmetadata.service.mapper.EntityMapper; + +public class TableMapper implements EntityMapper { + @Override + public Table createToEntity(CreateTable create, String user) { + return validateNewTable( + copy(new Table(), create, user) + .withColumns(create.getColumns()) + .withSourceUrl(create.getSourceUrl()) + .withLocationPath(create.getLocationPath()) + .withTableConstraints(create.getTableConstraints()) + .withTablePartition(create.getTablePartition()) + .withTableType(create.getTableType()) + .withFileFormat(create.getFileFormat()) + .withSchemaDefinition(create.getSchemaDefinition()) + .withTableProfilerConfig(create.getTableProfilerConfig()) + .withDatabaseSchema( + getEntityReference(Entity.DATABASE_SCHEMA, create.getDatabaseSchema()))) + .withDatabaseSchema(getEntityReference(Entity.DATABASE_SCHEMA, create.getDatabaseSchema())) + .withRetentionPeriod(create.getRetentionPeriod()) + .withSourceHash(create.getSourceHash()); + } + + public CustomMetric createCustomMetricToEntity(CreateCustomMetric create, String user) { + return new CustomMetric() + .withId(UUID.randomUUID()) + .withDescription(create.getDescription()) + .withName(create.getName()) + .withColumnName(create.getColumnName()) + .withOwners(create.getOwners()) + .withExpression(create.getExpression()) + .withUpdatedBy(user) + .withUpdatedAt(System.currentTimeMillis()); + } + + public static Table validateNewTable(Table table) { + table.setId(UUID.randomUUID()); + DatabaseUtil.validateConstraints(table.getColumns(), table.getTableConstraints()); + DatabaseUtil.validateTablePartition(table.getColumns(), table.getTablePartition()); + DatabaseUtil.validateColumns(table.getColumns()); + return table; + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/databases/TableResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/databases/TableResource.java index 529aa0769776..d74e7b13587c 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/databases/TableResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/databases/TableResource.java @@ -90,6 +90,7 @@ @Consumes(MediaType.APPLICATION_JSON) @Collection(name = "tables") public class TableResource extends EntityResource { + private final TableMapper mapper = new TableMapper(); public static final String COLLECTION_PATH = "v1/tables/"; static final String FIELDS = "tableConstraints,tablePartition,usageSummary,owners,customMetrics,columns," @@ -367,7 +368,7 @@ public Response create( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateTable create) { - Table table = getTable(create, securityContext.getUserPrincipal().getName()); + Table table = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return create(uriInfo, securityContext, table); } @@ -391,7 +392,7 @@ public Response createOrUpdate( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateTable create) { - Table table = getTable(create, securityContext.getUserPrincipal().getName()); + Table table = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return createOrUpdate(uriInfo, securityContext, table); } @@ -1152,7 +1153,9 @@ public Table addCustomMetric( OperationContext operationContext = new OperationContext(entityType, MetadataOperation.EDIT_DATA_PROFILE); authorizer.authorize(securityContext, operationContext, getResourceContextById(id)); - CustomMetric customMetric = getCustomMetric(securityContext, createCustomMetric); + CustomMetric customMetric = + mapper.createCustomMetricToEntity( + createCustomMetric, securityContext.getUserPrincipal().getName()); Table table = repository.addCustomMetric(id, customMetric); return addHref(uriInfo, table); } @@ -1312,44 +1315,4 @@ public Response searchEntityRelationship( return Entity.getSearchRepository() .searchEntityRelationship(fqn, upstreamDepth, downstreamDepth, queryFilter, deleted); } - - public static Table validateNewTable(Table table) { - table.setId(UUID.randomUUID()); - DatabaseUtil.validateConstraints(table.getColumns(), table.getTableConstraints()); - DatabaseUtil.validateTablePartition(table.getColumns(), table.getTablePartition()); - DatabaseUtil.validateColumns(table.getColumns()); - return table; - } - - private Table getTable(CreateTable create, String user) { - return validateNewTable( - repository - .copy(new Table(), create, user) - .withColumns(create.getColumns()) - .withSourceUrl(create.getSourceUrl()) - .withLocationPath(create.getLocationPath()) - .withTableConstraints(create.getTableConstraints()) - .withTablePartition(create.getTablePartition()) - .withTableType(create.getTableType()) - .withFileFormat(create.getFileFormat()) - .withSchemaDefinition(create.getSchemaDefinition()) - .withTableProfilerConfig(create.getTableProfilerConfig()) - .withDatabaseSchema( - getEntityReference(Entity.DATABASE_SCHEMA, create.getDatabaseSchema()))) - .withDatabaseSchema(getEntityReference(Entity.DATABASE_SCHEMA, create.getDatabaseSchema())) - .withRetentionPeriod(create.getRetentionPeriod()) - .withSourceHash(create.getSourceHash()); - } - - private CustomMetric getCustomMetric(SecurityContext securityContext, CreateCustomMetric create) { - return new CustomMetric() - .withId(UUID.randomUUID()) - .withDescription(create.getDescription()) - .withName(create.getName()) - .withColumnName(create.getColumnName()) - .withOwners(create.getOwners()) - .withExpression(create.getExpression()) - .withUpdatedBy(securityContext.getUserPrincipal().getName()) - .withUpdatedAt(System.currentTimeMillis()); - } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/datainsight/DataInsightChartMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/datainsight/DataInsightChartMapper.java new file mode 100644 index 000000000000..8d3523e47133 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/datainsight/DataInsightChartMapper.java @@ -0,0 +1,19 @@ +package org.openmetadata.service.resources.datainsight; + +import org.openmetadata.schema.api.dataInsight.CreateDataInsightChart; +import org.openmetadata.schema.dataInsight.DataInsightChart; +import org.openmetadata.service.mapper.EntityMapper; + +public class DataInsightChartMapper + implements EntityMapper { + @Override + public DataInsightChart createToEntity(CreateDataInsightChart create, String user) { + return copy(new DataInsightChart(), create, user) + .withName(create.getName()) + .withDescription(create.getDescription()) + .withDataIndexType(create.getDataIndexType()) + .withDimensions(create.getDimensions()) + .withMetrics(create.getMetrics()) + .withDisplayName(create.getDisplayName()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/datainsight/DataInsightChartResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/datainsight/DataInsightChartResource.java index da081c044bab..50a9c365f963 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/datainsight/DataInsightChartResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/datainsight/DataInsightChartResource.java @@ -65,6 +65,7 @@ public class DataInsightChartResource extends EntityResource { + private final DataInsightChartMapper mapper = new DataInsightChartMapper(); public static final String COLLECTION_PATH = DataInsightChartRepository.COLLECTION_PATH; public static final String FIELDS = "owners"; private final SearchRepository searchRepository; @@ -299,7 +300,7 @@ public Response create( @Context SecurityContext securityContext, @Valid CreateDataInsightChart create) { DataInsightChart dataInsightChart = - getDataInsightChart(create, securityContext.getUserPrincipal().getName()); + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return create(uriInfo, securityContext, dataInsightChart); } @@ -381,7 +382,7 @@ public Response createOrUpdate( @Context SecurityContext securityContext, @Valid CreateDataInsightChart create) { DataInsightChart dataInsightChart = - getDataInsightChart(create, securityContext.getUserPrincipal().getName()); + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return createOrUpdate(uriInfo, securityContext, dataInsightChart); } @@ -536,15 +537,4 @@ public Response listDataInsightChartResult( return searchRepository.listDataInsightChartResult( startTs, endTs, tier, team, dataInsightChartName, size, from, queryFilter, dataReportIndex); } - - private DataInsightChart getDataInsightChart(CreateDataInsightChart create, String user) { - return repository - .copy(new DataInsightChart(), create, user) - .withName(create.getName()) - .withDescription(create.getDescription()) - .withDataIndexType(create.getDataIndexType()) - .withDimensions(create.getDimensions()) - .withMetrics(create.getMetrics()) - .withDisplayName(create.getDisplayName()); - } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/datamodels/DashboardDataModelMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/datamodels/DashboardDataModelMapper.java new file mode 100644 index 000000000000..3785f1500b01 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/datamodels/DashboardDataModelMapper.java @@ -0,0 +1,25 @@ +package org.openmetadata.service.resources.datamodels; + +import org.openmetadata.schema.api.data.CreateDashboardDataModel; +import org.openmetadata.schema.entity.data.DashboardDataModel; +import org.openmetadata.service.Entity; +import org.openmetadata.service.mapper.EntityMapper; +import org.openmetadata.service.resources.databases.DatabaseUtil; +import org.openmetadata.service.util.EntityUtil; + +public class DashboardDataModelMapper + implements EntityMapper { + @Override + public DashboardDataModel createToEntity(CreateDashboardDataModel create, String user) { + DatabaseUtil.validateColumns(create.getColumns()); + return copy(new DashboardDataModel(), create, user) + .withService(EntityUtil.getEntityReference(Entity.DASHBOARD_SERVICE, create.getService())) + .withDataModelType(create.getDataModelType()) + .withSql(create.getSql()) + .withDataModelType(create.getDataModelType()) + .withServiceType(create.getServiceType()) + .withColumns(create.getColumns()) + .withProject(create.getProject()) + .withSourceHash(create.getSourceHash()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/datamodels/DashboardDataModelResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/datamodels/DashboardDataModelResource.java index 5be6a779a363..4250b3304b5b 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/datamodels/DashboardDataModelResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/datamodels/DashboardDataModelResource.java @@ -56,9 +56,7 @@ import org.openmetadata.service.limits.Limits; import org.openmetadata.service.resources.Collection; import org.openmetadata.service.resources.EntityResource; -import org.openmetadata.service.resources.databases.DatabaseUtil; import org.openmetadata.service.security.Authorizer; -import org.openmetadata.service.util.EntityUtil; import org.openmetadata.service.util.ResultList; @Path("/v1/dashboard/datamodels") @@ -71,6 +69,7 @@ @Collection(name = "datamodels") public class DashboardDataModelResource extends EntityResource { + private final DashboardDataModelMapper mapper = new DashboardDataModelMapper(); public static final String COLLECTION_PATH = "/v1/dashboard/datamodels"; protected static final String FIELDS = "owners,tags,followers,domain,sourceHash,extension"; @@ -300,7 +299,7 @@ public Response create( @Context SecurityContext securityContext, @Valid CreateDashboardDataModel create) { DashboardDataModel dashboardDataModel = - getDataModel(create, securityContext.getUserPrincipal().getName()); + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return create(uriInfo, securityContext, dashboardDataModel); } @@ -382,7 +381,7 @@ public Response createOrUpdate( @Context SecurityContext securityContext, @Valid CreateDashboardDataModel create) { DashboardDataModel dashboardDataModel = - getDataModel(create, securityContext.getUserPrincipal().getName()); + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return createOrUpdate(uriInfo, securityContext, dashboardDataModel); } @@ -541,18 +540,4 @@ public Response restoreDataModel( @Valid RestoreEntity restore) { return restoreEntity(uriInfo, securityContext, restore.getId()); } - - private DashboardDataModel getDataModel(CreateDashboardDataModel create, String user) { - DatabaseUtil.validateColumns(create.getColumns()); - return repository - .copy(new DashboardDataModel(), create, user) - .withService(EntityUtil.getEntityReference(Entity.DASHBOARD_SERVICE, create.getService())) - .withDataModelType(create.getDataModelType()) - .withSql(create.getSql()) - .withDataModelType(create.getDataModelType()) - .withServiceType(create.getServiceType()) - .withColumns(create.getColumns()) - .withProject(create.getProject()) - .withSourceHash(create.getSourceHash()); - } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/docstore/DocStoreMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/docstore/DocStoreMapper.java new file mode 100644 index 000000000000..7f1e259c6e57 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/docstore/DocStoreMapper.java @@ -0,0 +1,44 @@ +package org.openmetadata.service.resources.docstore; + +import javax.ws.rs.core.Response; +import org.openmetadata.schema.email.EmailTemplate; +import org.openmetadata.schema.email.TemplateValidationResponse; +import org.openmetadata.schema.entities.docStore.CreateDocument; +import org.openmetadata.schema.entities.docStore.Document; +import org.openmetadata.service.Entity; +import org.openmetadata.service.exception.CustomExceptionMessage; +import org.openmetadata.service.jdbi3.DocumentRepository; +import org.openmetadata.service.mapper.EntityMapper; +import org.openmetadata.service.security.Authorizer; +import org.openmetadata.service.util.JsonUtils; +import org.openmetadata.service.util.email.DefaultTemplateProvider; + +public class DocStoreMapper implements EntityMapper { + public DocStoreMapper(Authorizer authorizer) { + this.authorizer = authorizer; + } + + private final Authorizer authorizer; + + @Override + public Document createToEntity(CreateDocument create, String user) { + DocumentRepository documentRepository = + (DocumentRepository) Entity.getEntityRepository(Entity.DOCUMENT); + // Validate email template + if (create.getEntityType().equals(DefaultTemplateProvider.ENTITY_TYPE_EMAIL_TEMPLATE)) { + // Only Admins Can do these operations + authorizer.authorizeAdmin(user); + String content = JsonUtils.convertValue(create.getData(), EmailTemplate.class).getTemplate(); + TemplateValidationResponse validationResp = + documentRepository.validateEmailTemplate(create.getName(), content); + if (Boolean.FALSE.equals(validationResp.getIsValid())) { + throw new CustomExceptionMessage( + Response.status(400).entity(validationResp).build(), validationResp.getMessage()); + } + } + return copy(new Document(), create, user) + .withFullyQualifiedName(create.getFullyQualifiedName()) + .withData(create.getData()) + .withEntityType(create.getEntityType()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/docstore/DocStoreResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/docstore/DocStoreResource.java index fb69f12fec28..a0420f7122db 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/docstore/DocStoreResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/docstore/DocStoreResource.java @@ -57,16 +57,13 @@ import org.openmetadata.schema.type.MetadataOperation; import org.openmetadata.service.Entity; import org.openmetadata.service.OpenMetadataApplicationConfig; -import org.openmetadata.service.exception.CustomExceptionMessage; import org.openmetadata.service.jdbi3.DocumentRepository; import org.openmetadata.service.jdbi3.ListFilter; import org.openmetadata.service.limits.Limits; import org.openmetadata.service.resources.Collection; import org.openmetadata.service.resources.EntityResource; import org.openmetadata.service.security.Authorizer; -import org.openmetadata.service.util.JsonUtils; import org.openmetadata.service.util.ResultList; -import org.openmetadata.service.util.email.DefaultTemplateProvider; @Slf4j @Path("/v1/docStore") @@ -76,6 +73,7 @@ @Collection(name = "knowledgePanel", order = 2) public class DocStoreResource extends EntityResource { public static final String COLLECTION_PATH = "/v1/docStore"; + private DocStoreMapper mapper; @Override public Document addHref(UriInfo uriInfo, Document doc) { @@ -91,6 +89,7 @@ protected List getEntitySpecificOperations() { public DocStoreResource(Authorizer authorizer, Limits limits) { super(Entity.DOCUMENT, authorizer, limits); + this.mapper = new DocStoreMapper(authorizer); } public static class DocumentList extends ResultList { @@ -301,7 +300,7 @@ public Response create( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateDocument cd) { - Document doc = getDocument(cd, securityContext); + Document doc = mapper.createToEntity(cd, securityContext.getUserPrincipal().getName()); return create(uriInfo, securityContext, doc); } @@ -324,7 +323,7 @@ public Response createOrUpdate( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateDocument cd) { - Document doc = getDocument(cd, securityContext); + Document doc = mapper.createToEntity(cd, securityContext.getUserPrincipal().getName()); return createOrUpdate(uriInfo, securityContext, doc); } @@ -489,24 +488,4 @@ public Response resetEmailTemplate( .build(); } } - - private Document getDocument(CreateDocument cd, SecurityContext securityContext) { - // Validate email template - if (cd.getEntityType().equals(DefaultTemplateProvider.ENTITY_TYPE_EMAIL_TEMPLATE)) { - // Only Admins Can do these operations - authorizer.authorizeAdmin(securityContext); - String content = JsonUtils.convertValue(cd.getData(), EmailTemplate.class).getTemplate(); - TemplateValidationResponse validationResp = - repository.validateEmailTemplate(cd.getName(), content); - if (Boolean.FALSE.equals(validationResp.getIsValid())) { - throw new CustomExceptionMessage( - Response.status(400).entity(validationResp).build(), validationResp.getMessage()); - } - } - return repository - .copy(new Document(), cd, securityContext.getUserPrincipal().getName()) - .withFullyQualifiedName(cd.getFullyQualifiedName()) - .withData(cd.getData()) - .withEntityType(cd.getEntityType()); - } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/domains/DataProductMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/domains/DataProductMapper.java new file mode 100644 index 000000000000..f91693a86367 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/domains/DataProductMapper.java @@ -0,0 +1,34 @@ +package org.openmetadata.service.resources.domains; + +import static org.openmetadata.common.utils.CommonUtil.listOrEmpty; +import static org.openmetadata.service.util.EntityUtil.getEntityReferences; + +import java.util.ArrayList; +import java.util.List; +import org.openmetadata.schema.api.domains.CreateDataProduct; +import org.openmetadata.schema.entity.domains.DataProduct; +import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.schema.type.Include; +import org.openmetadata.service.Entity; +import org.openmetadata.service.mapper.EntityMapper; +import org.openmetadata.service.util.EntityUtil; + +public class DataProductMapper implements EntityMapper { + @Override + public DataProduct createToEntity(CreateDataProduct create, String user) { + List experts = create.getExperts(); + DataProduct dataProduct = + copy(new DataProduct(), create, user) + .withFullyQualifiedName(create.getName()) + .withStyle(create.getStyle()) + .withExperts( + EntityUtil.populateEntityReferences(getEntityReferences(Entity.USER, experts))); + dataProduct.withAssets(new ArrayList<>()); + for (EntityReference asset : listOrEmpty(create.getAssets())) { + asset = Entity.getEntityReference(asset, Include.NON_DELETED); + dataProduct.getAssets().add(asset); + dataProduct.getAssets().sort(EntityUtil.compareEntityReference); + } + return dataProduct; + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/domains/DataProductResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/domains/DataProductResource.java index f5a20bbe770a..1794f49d42c8 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/domains/DataProductResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/domains/DataProductResource.java @@ -13,7 +13,6 @@ package org.openmetadata.service.resources.domains; -import static org.openmetadata.common.utils.CommonUtil.listOrEmpty; import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; import io.swagger.v3.oas.annotations.ExternalDocumentation; @@ -25,8 +24,6 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; -import java.util.ArrayList; -import java.util.List; import java.util.UUID; import javax.json.JsonPatch; import javax.validation.Valid; @@ -64,7 +61,6 @@ import org.openmetadata.service.resources.Collection; import org.openmetadata.service.resources.EntityResource; import org.openmetadata.service.security.Authorizer; -import org.openmetadata.service.util.EntityUtil; import org.openmetadata.service.util.ResultList; @Slf4j @@ -79,6 +75,7 @@ @Collection(name = "dataProducts", order = 4) // initialize after user resource public class DataProductResource extends EntityResource { public static final String COLLECTION_PATH = "/v1/dataProducts/"; + private final DataProductMapper mapper = new DataProductMapper(); static final String FIELDS = "domain,owners,experts,assets,extension"; public DataProductResource(Authorizer authorizer, Limits limits) { @@ -285,7 +282,8 @@ public Response create( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateDataProduct create) { - DataProduct dataProduct = getDataProduct(create, securityContext.getUserPrincipal().getName()); + DataProduct dataProduct = + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return create(uriInfo, securityContext, dataProduct); } @@ -309,7 +307,8 @@ public Response createOrUpdate( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateDataProduct create) { - DataProduct dataProduct = getDataProduct(create, securityContext.getUserPrincipal().getName()); + DataProduct dataProduct = + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return createOrUpdate(uriInfo, securityContext, dataProduct); } @@ -464,22 +463,4 @@ public Response delete( String name) { return deleteByName(uriInfo, securityContext, name, true, true); } - - private DataProduct getDataProduct(CreateDataProduct create, String user) { - List experts = create.getExperts(); - DataProduct dataProduct = - repository - .copy(new DataProduct(), create, user) - .withFullyQualifiedName(create.getName()) - .withStyle(create.getStyle()) - .withExperts( - EntityUtil.populateEntityReferences(getEntityReferences(Entity.USER, experts))); - dataProduct.withAssets(new ArrayList<>()); - for (EntityReference asset : listOrEmpty(create.getAssets())) { - asset = Entity.getEntityReference(asset, Include.NON_DELETED); - dataProduct.getAssets().add(asset); - dataProduct.getAssets().sort(EntityUtil.compareEntityReference); - } - return dataProduct; - } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/domains/DomainMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/domains/DomainMapper.java new file mode 100644 index 000000000000..fb4ef61c6cd6 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/domains/DomainMapper.java @@ -0,0 +1,28 @@ +package org.openmetadata.service.resources.domains; + +import static org.openmetadata.service.util.EntityUtil.getEntityReference; +import static org.openmetadata.service.util.EntityUtil.getEntityReferences; + +import java.util.List; +import org.openmetadata.schema.api.domains.CreateDomain; +import org.openmetadata.schema.entity.domains.Domain; +import org.openmetadata.schema.type.Include; +import org.openmetadata.service.Entity; +import org.openmetadata.service.mapper.EntityMapper; +import org.openmetadata.service.util.EntityUtil; + +public class DomainMapper implements EntityMapper { + @Override + public Domain createToEntity(CreateDomain create, String user) { + List experts = create.getExperts(); + return copy(new Domain(), create, user) + .withStyle(create.getStyle()) + .withDomainType(create.getDomainType()) + .withFullyQualifiedName(create.getName()) + .withParent( + Entity.getEntityReference( + getEntityReference(Entity.DOMAIN, create.getParent()), Include.NON_DELETED)) + .withExperts( + EntityUtil.populateEntityReferences(getEntityReferences(Entity.USER, experts))); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/domains/DomainResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/domains/DomainResource.java index b416b2e35f69..f3b446ce4c98 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/domains/DomainResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/domains/DomainResource.java @@ -22,7 +22,6 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; -import java.util.List; import java.util.UUID; import javax.json.JsonPatch; import javax.validation.Valid; @@ -49,7 +48,6 @@ import org.openmetadata.schema.entity.domains.Domain; import org.openmetadata.schema.type.ChangeEvent; import org.openmetadata.schema.type.EntityHistory; -import org.openmetadata.schema.type.Include; import org.openmetadata.schema.type.api.BulkAssets; import org.openmetadata.schema.type.api.BulkOperationResult; import org.openmetadata.service.Entity; @@ -59,7 +57,6 @@ import org.openmetadata.service.resources.Collection; import org.openmetadata.service.resources.EntityResource; import org.openmetadata.service.security.Authorizer; -import org.openmetadata.service.util.EntityUtil; import org.openmetadata.service.util.ResultList; @Slf4j @@ -73,6 +70,7 @@ @Collection(name = "domains", order = 4) // initialize after user resource public class DomainResource extends EntityResource { public static final String COLLECTION_PATH = "/v1/domains/"; + private final DomainMapper mapper = new DomainMapper(); static final String FIELDS = "children,owners,experts"; public DomainResource(Authorizer authorizer, Limits limits) { @@ -262,7 +260,7 @@ public Response create( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateDomain create) { - Domain domain = getDomain(create, securityContext.getUserPrincipal().getName()); + Domain domain = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return create(uriInfo, securityContext, domain); } @@ -286,7 +284,7 @@ public Response createOrUpdate( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateDomain create) { - Domain domain = getDomain(create, securityContext.getUserPrincipal().getName()); + Domain domain = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return createOrUpdate(uriInfo, securityContext, domain); } @@ -435,18 +433,4 @@ public Response delete( String name) { return deleteByName(uriInfo, securityContext, name, true, true); } - - private Domain getDomain(CreateDomain create, String user) { - List experts = create.getExperts(); - return repository - .copy(new Domain(), create, user) - .withStyle(create.getStyle()) - .withDomainType(create.getDomainType()) - .withFullyQualifiedName(create.getName()) - .withParent( - Entity.getEntityReference( - getEntityReference(Entity.DOMAIN, create.getParent()), Include.NON_DELETED)) - .withExperts( - EntityUtil.populateEntityReferences(getEntityReferences(Entity.USER, experts))); - } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseMapper.java new file mode 100644 index 000000000000..a23a13486305 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseMapper.java @@ -0,0 +1,27 @@ +package org.openmetadata.service.resources.dqtests; + +import static org.openmetadata.service.util.EntityUtil.getEntityReference; + +import org.openmetadata.schema.api.tests.CreateTestCase; +import org.openmetadata.schema.tests.TestCase; +import org.openmetadata.service.Entity; +import org.openmetadata.service.mapper.EntityMapper; +import org.openmetadata.service.resources.feeds.MessageParser; + +public class TestCaseMapper implements EntityMapper { + @Override + public TestCase createToEntity(CreateTestCase create, String user) { + MessageParser.EntityLink entityLink = MessageParser.EntityLink.parse(create.getEntityLink()); + return copy(new TestCase(), create, user) + .withDescription(create.getDescription()) + .withName(create.getName()) + .withDisplayName(create.getDisplayName()) + .withParameterValues(create.getParameterValues()) + .withEntityLink(create.getEntityLink()) + .withComputePassedFailedRowCount(create.getComputePassedFailedRowCount()) + .withUseDynamicAssertion(create.getUseDynamicAssertion()) + .withEntityFQN(entityLink.getFullyQualifiedFieldValue()) + .withTestSuite(getEntityReference(Entity.TEST_SUITE, create.getTestSuite())) + .withTestDefinition(getEntityReference(Entity.TEST_DEFINITION, create.getTestDefinition())); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseResolutionStatusMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseResolutionStatusMapper.java new file mode 100644 index 000000000000..d62017b1d194 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseResolutionStatusMapper.java @@ -0,0 +1,28 @@ +package org.openmetadata.service.resources.dqtests; + +import org.openmetadata.schema.api.tests.CreateTestCaseResolutionStatus; +import org.openmetadata.schema.entity.teams.User; +import org.openmetadata.schema.tests.TestCase; +import org.openmetadata.schema.tests.type.TestCaseResolutionStatus; +import org.openmetadata.schema.type.Include; +import org.openmetadata.service.Entity; +import org.openmetadata.service.mapper.EntityTimeSeriesMapper; + +public class TestCaseResolutionStatusMapper + implements EntityTimeSeriesMapper { + @Override + public TestCaseResolutionStatus createToEntity( + CreateTestCaseResolutionStatus create, String user) { + TestCase testCaseEntity = + Entity.getEntityByName(Entity.TEST_CASE, create.getTestCaseReference(), null, Include.ALL); + User userEntity = Entity.getEntityByName(Entity.USER, user, null, Include.ALL); + + return new TestCaseResolutionStatus() + .withTimestamp(System.currentTimeMillis()) + .withTestCaseResolutionStatusType(create.getTestCaseResolutionStatusType()) + .withTestCaseResolutionStatusDetails(create.getTestCaseResolutionStatusDetails()) + .withUpdatedBy(userEntity.getEntityReference()) + .withUpdatedAt(System.currentTimeMillis()) + .withTestCaseReference(testCaseEntity.getEntityReference()); + } +} 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 4a12f5eff632..1fa08546cd17 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 @@ -33,11 +33,8 @@ import lombok.NonNull; import lombok.extern.slf4j.Slf4j; import org.openmetadata.schema.api.tests.CreateTestCaseResolutionStatus; -import org.openmetadata.schema.entity.teams.User; -import org.openmetadata.schema.tests.TestCase; import org.openmetadata.schema.tests.type.TestCaseResolutionStatus; import org.openmetadata.schema.tests.type.TestCaseResolutionStatusTypes; -import org.openmetadata.schema.type.Include; import org.openmetadata.schema.type.MetadataOperation; import org.openmetadata.service.Entity; import org.openmetadata.service.jdbi3.ListFilter; @@ -63,6 +60,7 @@ public class TestCaseResolutionStatusResource extends EntityTimeSeriesResource { public static final String COLLECTION_PATH = "/v1/dataQuality/testCases/testCaseIncidentStatus"; + private TestCaseResolutionStatusMapper mapper = new TestCaseResolutionStatusMapper(); public TestCaseResolutionStatusResource(Authorizer authorizer) { super(Entity.TEST_CASE_RESOLUTION_STATUS, authorizer); @@ -232,20 +230,12 @@ public Response create( new OperationContext(Entity.TEST_CASE, MetadataOperation.EDIT_TESTS); ResourceContextInterface resourceContext = ReportDataContext.builder().build(); authorizer.authorize(securityContext, operationContext, resourceContext); - - TestCase testCaseEntity = - Entity.getEntityByName( - Entity.TEST_CASE, - createTestCaseResolutionStatus.getTestCaseReference(), - null, - Include.ALL); TestCaseResolutionStatus testCaseResolutionStatus = - getTestCaseResolutionStatus( - testCaseEntity, - createTestCaseResolutionStatus, - securityContext.getUserPrincipal().getName()); - - return create(testCaseResolutionStatus, testCaseEntity.getFullyQualifiedName()); + mapper.createToEntity( + createTestCaseResolutionStatus, securityContext.getUserPrincipal().getName()); + return create( + testCaseResolutionStatus, + testCaseResolutionStatus.getTestCaseReference().getFullyQualifiedName()); } @PATCH @@ -284,21 +274,4 @@ public Response patch( repository.patch(id, patch, securityContext.getUserPrincipal().getName()); return response.toResponse(); } - - private TestCaseResolutionStatus getTestCaseResolutionStatus( - TestCase testCaseEntity, - CreateTestCaseResolutionStatus createTestCaseResolutionStatus, - String userName) { - User userEntity = Entity.getEntityByName(Entity.USER, userName, null, Include.ALL); - - return new TestCaseResolutionStatus() - .withTimestamp(System.currentTimeMillis()) - .withTestCaseResolutionStatusType( - createTestCaseResolutionStatus.getTestCaseResolutionStatusType()) - .withTestCaseResolutionStatusDetails( - createTestCaseResolutionStatus.getTestCaseResolutionStatusDetails()) - .withUpdatedBy(userEntity.getEntityReference()) - .withUpdatedAt(System.currentTimeMillis()) - .withTestCaseReference(testCaseEntity.getEntityReference()); - } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseResultMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseResultMapper.java new file mode 100644 index 000000000000..b38fd6964d8f --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseResultMapper.java @@ -0,0 +1,36 @@ +package org.openmetadata.service.resources.dqtests; + +import static org.openmetadata.service.Entity.TEST_CASE; + +import java.util.UUID; +import org.openmetadata.schema.api.tests.CreateTestCaseResult; +import org.openmetadata.schema.tests.TestCase; +import org.openmetadata.schema.tests.type.TestCaseResult; +import org.openmetadata.schema.type.Include; +import org.openmetadata.service.Entity; +import org.openmetadata.service.mapper.EntityTimeSeriesMapper; +import org.openmetadata.service.util.RestUtil; + +public class TestCaseResultMapper + implements EntityTimeSeriesMapper { + @Override + public TestCaseResult createToEntity(CreateTestCaseResult create, String user) { + TestCase testCase = Entity.getEntityByName(TEST_CASE, create.getFqn(), "", Include.ALL); + RestUtil.validateTimestampMilliseconds(create.getTimestamp()); + return new TestCaseResult() + .withId(UUID.randomUUID()) + .withTestCaseFQN(testCase.getFullyQualifiedName()) + .withTimestamp(create.getTimestamp()) + .withTestCaseStatus(create.getTestCaseStatus()) + .withResult(create.getResult()) + .withSampleData(create.getSampleData()) + .withTestResultValue(create.getTestResultValue()) + .withPassedRows(create.getPassedRows()) + .withFailedRows(create.getFailedRows()) + .withPassedRowsPercentage(create.getPassedRowsPercentage()) + .withFailedRowsPercentage(create.getFailedRowsPercentage()) + .withIncidentId(create.getIncidentId()) + .withMaxBound(create.getMaxBound()) + .withMinBound(create.getMinBound()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseResultResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseResultResource.java index 8561d314b3f9..080b7ddf1d1f 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseResultResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseResultResource.java @@ -67,6 +67,7 @@ @Collection(name = "TestCaseResults") public class TestCaseResultResource extends EntityTimeSeriesResource { + private final TestCaseResultMapper mapper = new TestCaseResultMapper(); static final String FIELDS = "testCase,testDefinition"; public TestCaseResultResource(Authorizer authorizer) { @@ -101,12 +102,17 @@ public Response addTestCaseResult( @PathParam("fqn") String fqn, @Valid CreateTestCaseResult createTestCaseResults) { + // Needed in further validation to check if the testCase exists + createTestCaseResults.withFqn(fqn); ResourceContextInterface resourceContext = TestCaseResourceContext.builder().name(fqn).build(); OperationContext operationContext = new OperationContext(Entity.TABLE, MetadataOperation.EDIT_TESTS); authorizer.authorize(securityContext, operationContext, resourceContext); return repository.addTestCaseResult( - securityContext.getUserPrincipal().getName(), uriInfo, fqn, createTestCaseResults); + securityContext.getUserPrincipal().getName(), + uriInfo, + fqn, + mapper.createToEntity(createTestCaseResults, securityContext.getUserPrincipal().getName())); } @GET diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestDefinitionMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestDefinitionMapper.java new file mode 100644 index 000000000000..1b587fd77474 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestDefinitionMapper.java @@ -0,0 +1,19 @@ +package org.openmetadata.service.resources.dqtests; + +import org.openmetadata.schema.api.tests.CreateTestDefinition; +import org.openmetadata.schema.tests.TestDefinition; +import org.openmetadata.service.mapper.EntityMapper; + +public class TestDefinitionMapper implements EntityMapper { + @Override + public TestDefinition createToEntity(CreateTestDefinition create, String user) { + return copy(new TestDefinition(), create, user) + .withDescription(create.getDescription()) + .withEntityType(create.getEntityType()) + .withTestPlatforms(create.getTestPlatforms()) + .withSupportedDataTypes(create.getSupportedDataTypes()) + .withDisplayName(create.getDisplayName()) + .withParameterDefinition(create.getParameterDefinition()) + .withName(create.getName()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestDefinitionResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestDefinitionResource.java index 9cd9126e1910..d59de3ccc2a1 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestDefinitionResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestDefinitionResource.java @@ -63,6 +63,7 @@ @Collection(name = "TestDefinitions") public class TestDefinitionResource extends EntityResource { + private final TestDefinitionMapper mapper = new TestDefinitionMapper(); public static final String COLLECTION_PATH = "/v1/dataQuality/testDefinitions"; static final String FIELDS = "owners"; @@ -314,7 +315,7 @@ public Response create( @Context SecurityContext securityContext, @Valid CreateTestDefinition create) { TestDefinition testDefinition = - getTestDefinition(create, securityContext.getUserPrincipal().getName()); + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return create(uriInfo, securityContext, testDefinition); } @@ -367,7 +368,7 @@ public Response createOrUpdate( @Context SecurityContext securityContext, @Valid CreateTestDefinition create) { TestDefinition testDefinition = - getTestDefinition(create, securityContext.getUserPrincipal().getName()); + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return createOrUpdate(uriInfo, securityContext, testDefinition); } @@ -452,16 +453,4 @@ public Response restoreTestDefinition( @Valid RestoreEntity restore) { return restoreEntity(uriInfo, securityContext, restore.getId()); } - - private TestDefinition getTestDefinition(CreateTestDefinition create, String user) { - return repository - .copy(new TestDefinition(), create, user) - .withDescription(create.getDescription()) - .withEntityType(create.getEntityType()) - .withTestPlatforms(create.getTestPlatforms()) - .withSupportedDataTypes(create.getSupportedDataTypes()) - .withDisplayName(create.getDisplayName()) - .withParameterDefinition(create.getParameterDefinition()) - .withName(create.getName()); - } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestSuiteMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestSuiteMapper.java new file mode 100644 index 000000000000..f5bb30c2f6e2 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestSuiteMapper.java @@ -0,0 +1,31 @@ +package org.openmetadata.service.resources.dqtests; + +import org.openmetadata.schema.api.tests.CreateTestSuite; +import org.openmetadata.schema.entity.data.Table; +import org.openmetadata.schema.tests.TestSuite; +import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.service.Entity; +import org.openmetadata.service.mapper.EntityMapper; + +public class TestSuiteMapper implements EntityMapper { + @Override + public TestSuite createToEntity(CreateTestSuite create, String user) { + TestSuite testSuite = + copy(new TestSuite(), create, user) + .withDescription(create.getDescription()) + .withDisplayName(create.getDisplayName()) + .withName(create.getName()); + if (create.getBasicEntityReference() != null) { + Table table = + Entity.getEntityByName(Entity.TABLE, create.getBasicEntityReference(), null, null); + EntityReference entityReference = + new EntityReference() + .withId(table.getId()) + .withFullyQualifiedName(table.getFullyQualifiedName()) + .withName(table.getName()) + .withType(Entity.TABLE); + testSuite.setBasicEntityReference(entityReference); + } + return testSuite; + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/events/subscription/EventSubscriptionMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/events/subscription/EventSubscriptionMapper.java new file mode 100644 index 000000000000..2892c2488030 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/events/subscription/EventSubscriptionMapper.java @@ -0,0 +1,46 @@ +package org.openmetadata.service.resources.events.subscription; + +import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; +import static org.openmetadata.service.events.subscription.AlertUtil.validateAndBuildFilteringConditions; +import static org.openmetadata.service.fernet.Fernet.encryptWebhookSecretKey; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.openmetadata.schema.api.events.CreateEventSubscription; +import org.openmetadata.schema.entity.events.EventSubscription; +import org.openmetadata.schema.entity.events.SubscriptionDestination; +import org.openmetadata.service.mapper.EntityMapper; + +public class EventSubscriptionMapper + implements EntityMapper { + @Override + public EventSubscription createToEntity(CreateEventSubscription create, String user) { + return copy(new EventSubscription(), create, user) + .withAlertType(create.getAlertType()) + .withTrigger(create.getTrigger()) + .withEnabled(create.getEnabled()) + .withBatchSize(create.getBatchSize()) + .withFilteringRules( + validateAndBuildFilteringConditions( + create.getResources(), create.getAlertType(), create.getInput())) + .withDestinations(encryptWebhookSecretKey(getSubscriptions(create.getDestinations()))) + .withProvider(create.getProvider()) + .withRetries(create.getRetries()) + .withPollInterval(create.getPollInterval()) + .withInput(create.getInput()); + } + + private List getSubscriptions( + List subscriptions) { + List result = new ArrayList<>(); + subscriptions.forEach( + subscription -> { + if (nullOrEmpty(subscription.getId())) { + subscription.withId(UUID.randomUUID()); + } + result.add(subscription); + }); + return result; + } +} 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 51d943faa3b4..d81538868c14 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 @@ -16,8 +16,6 @@ import static org.openmetadata.common.utils.CommonUtil.listOrEmpty; import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; import static org.openmetadata.schema.api.events.CreateEventSubscription.AlertType.NOTIFICATION; -import static org.openmetadata.service.events.subscription.AlertUtil.validateAndBuildFilteringConditions; -import static org.openmetadata.service.fernet.Fernet.encryptWebhookSecretKey; import io.swagger.v3.oas.annotations.ExternalDocumentation; import io.swagger.v3.oas.annotations.Operation; @@ -108,6 +106,7 @@ public class EventSubscriptionResource extends EntityResource { public static final String COLLECTION_PATH = "/v1/events/subscriptions"; public static final String FIELDS = "owners,filteringRules"; + private final EventSubscriptionMapper mapper = new EventSubscriptionMapper(); public EventSubscriptionResource(Authorizer authorizer, Limits limits) { super(Entity.EVENT_SUBSCRIPTION, authorizer, limits); @@ -296,7 +295,7 @@ public Response createEventSubscription( @Valid CreateEventSubscription request) throws SchedulerException { EventSubscription eventSub = - getEventSubscription(request, securityContext.getUserPrincipal().getName()); + mapper.createToEntity(request, securityContext.getUserPrincipal().getName()); // Only one Creation is allowed Response response = create(uriInfo, securityContext, eventSub); EventSubscriptionScheduler.getInstance().addSubscriptionPublisher(eventSub); @@ -323,7 +322,7 @@ public Response createOrUpdateEventSubscription( @Context SecurityContext securityContext, @Valid CreateEventSubscription create) { EventSubscription eventSub = - getEventSubscription(create, securityContext.getUserPrincipal().getName()); + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); Response response = createOrUpdate(uriInfo, securityContext, eventSub); EventSubscriptionScheduler.getInstance() .updateEventSubscription((EventSubscription) response.getEntity()); @@ -1354,36 +1353,6 @@ public Response sendTestMessageAlert( return Response.ok(resultDestinations).build(); } - private EventSubscription getEventSubscription(CreateEventSubscription create, String user) { - return repository - .copy(new EventSubscription(), create, user) - .withAlertType(create.getAlertType()) - .withTrigger(create.getTrigger()) - .withEnabled(create.getEnabled()) - .withBatchSize(create.getBatchSize()) - .withFilteringRules( - validateAndBuildFilteringConditions( - create.getResources(), create.getAlertType(), create.getInput())) - .withDestinations(encryptWebhookSecretKey(getSubscriptions(create.getDestinations()))) - .withProvider(create.getProvider()) - .withRetries(create.getRetries()) - .withPollInterval(create.getPollInterval()) - .withInput(create.getInput()); - } - - private List getSubscriptions( - List subscriptions) { - List result = new ArrayList<>(); - subscriptions.forEach( - subscription -> { - if (nullOrEmpty(subscription.getId())) { - subscription.withId(UUID.randomUUID()); - } - result.add(subscription); - }); - return result; - } - public static List getNotificationsFilterDescriptors() throws IOException { List entityNotificationDescriptors = diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/feeds/FeedMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/feeds/FeedMapper.java new file mode 100644 index 000000000000..541096507d11 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/feeds/FeedMapper.java @@ -0,0 +1,56 @@ +package org.openmetadata.service.resources.feeds; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import org.openmetadata.schema.api.CreateTaskDetails; +import org.openmetadata.schema.api.feed.CreateThread; +import org.openmetadata.schema.entity.feed.Thread; +import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.schema.type.TaskDetails; +import org.openmetadata.schema.type.TaskStatus; +import org.openmetadata.service.Entity; + +public class FeedMapper { + public Thread createToEntity(CreateThread create, String user) { + UUID randomUUID = UUID.randomUUID(); + return new Thread() + .withId(randomUUID) + .withThreadTs(System.currentTimeMillis()) + .withMessage(create.getMessage()) + .withCreatedBy(create.getFrom()) + .withAbout(create.getAbout()) + .withAddressedTo(create.getAddressedTo()) + .withReactions(Collections.emptyList()) + .withType(create.getType()) + .withTask(getTaskDetails(create.getTaskDetails())) + .withAnnouncement(create.getAnnouncementDetails()) + .withChatbot(create.getChatbotDetails()) + .withUpdatedBy(user) + .withUpdatedAt(System.currentTimeMillis()) + .withEntityRef(new EntityReference().withId(randomUUID).withType(Entity.THREAD)) + .withGeneratedBy(Thread.GeneratedBy.USER); + } + + private TaskDetails getTaskDetails(CreateTaskDetails create) { + if (create != null) { + return new TaskDetails() + .withAssignees(formatAssignees(create.getAssignees())) + .withType(create.getType()) + .withStatus(TaskStatus.Open) + .withOldValue(create.getOldValue()) + .withSuggestion(create.getSuggestion()); + } + return null; + } + + public static List formatAssignees(List assignees) { + List result = new ArrayList<>(); + assignees.forEach( + assignee -> + result.add( + new EntityReference().withId(assignee.getId()).withType(assignee.getType()))); + return result; + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/feeds/FeedResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/feeds/FeedResource.java index a1f6565bfdaa..b998185addcd 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/feeds/FeedResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/feeds/FeedResource.java @@ -28,8 +28,6 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; -import java.util.ArrayList; -import java.util.Collections; import java.util.List; import java.util.UUID; import javax.json.JsonPatch; @@ -52,7 +50,6 @@ import javax.ws.rs.core.Response; import javax.ws.rs.core.SecurityContext; import javax.ws.rs.core.UriInfo; -import org.openmetadata.schema.api.CreateTaskDetails; import org.openmetadata.schema.api.feed.CloseTask; import org.openmetadata.schema.api.feed.CreatePost; import org.openmetadata.schema.api.feed.CreateThread; @@ -62,7 +59,6 @@ import org.openmetadata.schema.type.EntityReference; import org.openmetadata.schema.type.MetadataOperation; import org.openmetadata.schema.type.Post; -import org.openmetadata.schema.type.TaskDetails; import org.openmetadata.schema.type.TaskStatus; import org.openmetadata.schema.type.ThreadType; import org.openmetadata.service.Entity; @@ -90,6 +86,8 @@ @Collection(name = "feeds") public class FeedResource { public static final String COLLECTION_PATH = "/v1/feed/"; + private final FeedMapper mapper = new FeedMapper(); + private final PostMapper postMapper = new PostMapper(); private final FeedRepository dao; private final Authorizer authorizer; @@ -421,7 +419,7 @@ public Response createThread( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateThread create) { - Thread thread = getThread(securityContext, create); + Thread thread = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); addHref(uriInfo, dao.create(thread)); return Response.created(thread.getHref()) .entity(thread) @@ -452,7 +450,7 @@ public Response addPost( @PathParam("id") UUID id, @Valid CreatePost createPost) { - Post post = getPost(createPost); + Post post = postMapper.createToEntity(createPost, securityContext.getUserPrincipal().getName()); Thread thread = addHref( uriInfo, dao.addPostToThread(id, post, securityContext.getUserPrincipal().getName())); @@ -588,54 +586,4 @@ public ResultList getPosts( UUID id) { return new ResultList<>(dao.listPosts(id)); } - - private Thread getThread(SecurityContext securityContext, CreateThread create) { - UUID randomUUID = UUID.randomUUID(); - return new Thread() - .withId(randomUUID) - .withThreadTs(System.currentTimeMillis()) - .withMessage(create.getMessage()) - .withCreatedBy(create.getFrom()) - .withAbout(create.getAbout()) - .withAddressedTo(create.getAddressedTo()) - .withReactions(Collections.emptyList()) - .withType(create.getType()) - .withTask(getTaskDetails(create.getTaskDetails())) - .withAnnouncement(create.getAnnouncementDetails()) - .withChatbot(create.getChatbotDetails()) - .withUpdatedBy(securityContext.getUserPrincipal().getName()) - .withUpdatedAt(System.currentTimeMillis()) - .withEntityRef(new EntityReference().withId(randomUUID).withType(Entity.THREAD)) - .withGeneratedBy(Thread.GeneratedBy.USER); - } - - private Post getPost(CreatePost create) { - return new Post() - .withId(UUID.randomUUID()) - .withMessage(create.getMessage()) - .withFrom(create.getFrom()) - .withReactions(Collections.emptyList()) - .withPostTs(System.currentTimeMillis()); - } - - private TaskDetails getTaskDetails(CreateTaskDetails create) { - if (create != null) { - return new TaskDetails() - .withAssignees(formatAssignees(create.getAssignees())) - .withType(create.getType()) - .withStatus(TaskStatus.Open) - .withOldValue(create.getOldValue()) - .withSuggestion(create.getSuggestion()); - } - return null; - } - - public static List formatAssignees(List assignees) { - List result = new ArrayList<>(); - assignees.forEach( - assignee -> - result.add( - new EntityReference().withId(assignee.getId()).withType(assignee.getType()))); - return result; - } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/feeds/PostMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/feeds/PostMapper.java new file mode 100644 index 000000000000..2e281168f9d8 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/feeds/PostMapper.java @@ -0,0 +1,17 @@ +package org.openmetadata.service.resources.feeds; + +import java.util.Collections; +import java.util.UUID; +import org.openmetadata.schema.api.feed.CreatePost; +import org.openmetadata.schema.type.Post; + +public class PostMapper { + public Post createToEntity(CreatePost create, String user) { + return new Post() + .withId(UUID.randomUUID()) + .withMessage(create.getMessage()) + .withFrom(create.getFrom()) + .withReactions(Collections.emptyList()) + .withPostTs(System.currentTimeMillis()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/feeds/SuggestionMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/feeds/SuggestionMapper.java new file mode 100644 index 000000000000..c032cfa578a3 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/feeds/SuggestionMapper.java @@ -0,0 +1,72 @@ +package org.openmetadata.service.resources.feeds; + +import static org.openmetadata.common.utils.CommonUtil.listOrEmpty; + +import java.util.UUID; +import javax.ws.rs.core.Response; +import org.openmetadata.schema.api.feed.CreateSuggestion; +import org.openmetadata.schema.entity.feed.Suggestion; +import org.openmetadata.schema.type.Include; +import org.openmetadata.schema.type.SuggestionStatus; +import org.openmetadata.schema.type.SuggestionType; +import org.openmetadata.schema.type.TagLabel; +import org.openmetadata.sdk.exception.SuggestionException; +import org.openmetadata.service.Entity; +import org.openmetadata.service.resources.tags.TagLabelUtil; +import org.openmetadata.service.util.UserUtil; + +public class SuggestionMapper { + private final String INVALID_SUGGESTION_REQUEST = "INVALID_SUGGESTION_REQUEST"; + + public Suggestion createToEntity(CreateSuggestion create, String user) { + validate(create); + return new Suggestion() + .withId(UUID.randomUUID()) + .withDescription(create.getDescription()) + .withEntityLink(create.getEntityLink()) + .withType(create.getType()) + .withDescription(create.getDescription()) + .withTagLabels(create.getTagLabels()) + .withStatus(SuggestionStatus.Open) + .withCreatedBy(UserUtil.getUserOrBot(user)) + .withCreatedAt(System.currentTimeMillis()) + .withUpdatedBy(user) + .withUpdatedAt(System.currentTimeMillis()); + } + + private void validate(CreateSuggestion suggestion) { + if (suggestion.getEntityLink() == null) { + throw new SuggestionException( + Response.Status.BAD_REQUEST, + INVALID_SUGGESTION_REQUEST, + "Suggestion's entityLink cannot be null."); + } + MessageParser.EntityLink entityLink = + MessageParser.EntityLink.parse(suggestion.getEntityLink()); + Entity.getEntityReferenceByName( + entityLink.getEntityType(), entityLink.getEntityFQN(), Include.NON_DELETED); + + if (suggestion.getType() == SuggestionType.SuggestDescription) { + if (suggestion.getDescription() == null || suggestion.getDescription().isEmpty()) { + throw new SuggestionException( + Response.Status.BAD_REQUEST, + INVALID_SUGGESTION_REQUEST, + "Suggestion's description cannot be empty."); + } + } else if (suggestion.getType() == SuggestionType.SuggestTagLabel) { + if (suggestion.getTagLabels().isEmpty()) { + throw new SuggestionException( + Response.Status.BAD_REQUEST, + INVALID_SUGGESTION_REQUEST, + "Suggestion's tag label's cannot be empty."); + } else { + for (TagLabel label : listOrEmpty(suggestion.getTagLabels())) { + TagLabelUtil.applyTagCommonFields(label); + } + } + } else { + throw new SuggestionException( + Response.Status.BAD_REQUEST, INVALID_SUGGESTION_REQUEST, "Invalid Suggestion Type."); + } + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/feeds/SuggestionsResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/feeds/SuggestionsResource.java index 8a411075783e..131493dd36b5 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/feeds/SuggestionsResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/feeds/SuggestionsResource.java @@ -13,7 +13,6 @@ package org.openmetadata.service.resources.feeds; -import static org.openmetadata.common.utils.CommonUtil.listOrEmpty; import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; import static org.openmetadata.schema.type.EventType.SUGGESTION_CREATED; import static org.openmetadata.schema.type.EventType.SUGGESTION_REJECTED; @@ -54,20 +53,16 @@ import org.openmetadata.schema.type.MetadataOperation; import org.openmetadata.schema.type.SuggestionStatus; import org.openmetadata.schema.type.SuggestionType; -import org.openmetadata.schema.type.TagLabel; -import org.openmetadata.sdk.exception.SuggestionException; import org.openmetadata.service.Entity; import org.openmetadata.service.jdbi3.SuggestionFilter; import org.openmetadata.service.jdbi3.SuggestionRepository; import org.openmetadata.service.resources.Collection; -import org.openmetadata.service.resources.tags.TagLabelUtil; import org.openmetadata.service.security.Authorizer; import org.openmetadata.service.security.policyevaluator.OperationContext; import org.openmetadata.service.security.policyevaluator.PostResourceContext; import org.openmetadata.service.security.policyevaluator.ResourceContextInterface; import org.openmetadata.service.util.RestUtil; import org.openmetadata.service.util.ResultList; -import org.openmetadata.service.util.UserUtil; @Path("/v1/suggestions") @Tag( @@ -79,9 +74,9 @@ @Collection(name = "suggestions") public class SuggestionsResource { public static final String COLLECTION_PATH = "/v1/suggestions/"; + private final SuggestionMapper mapper = new SuggestionMapper(); private final SuggestionRepository dao; private final Authorizer authorizer; - private final String INVALID_SUGGESTION_REQUEST = "INVALID_SUGGESTION_REQUEST"; public static void addHref(UriInfo uriInfo, List suggestions) { if (uriInfo != null) { @@ -410,7 +405,8 @@ public Response createSuggestion( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateSuggestion create) { - Suggestion suggestion = getSuggestion(securityContext, create); + Suggestion suggestion = + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); addHref(uriInfo, dao.create(suggestion)); return Response.created(suggestion.getHref()) .entity(suggestion) @@ -479,56 +475,4 @@ public Response deleteSuggestions( return dao.deleteSuggestionsForAnEntity(entity, securityContext.getUserPrincipal().getName()) .toResponse(); } - - private Suggestion getSuggestion(SecurityContext securityContext, CreateSuggestion create) { - validate(create); - return new Suggestion() - .withId(UUID.randomUUID()) - .withDescription(create.getDescription()) - .withEntityLink(create.getEntityLink()) - .withType(create.getType()) - .withDescription(create.getDescription()) - .withTagLabels(create.getTagLabels()) - .withStatus(SuggestionStatus.Open) - .withCreatedBy(UserUtil.getUserOrBot(securityContext.getUserPrincipal().getName())) - .withCreatedAt(System.currentTimeMillis()) - .withUpdatedBy(securityContext.getUserPrincipal().getName()) - .withUpdatedAt(System.currentTimeMillis()); - } - - private void validate(CreateSuggestion suggestion) { - if (suggestion.getEntityLink() == null) { - throw new SuggestionException( - Response.Status.BAD_REQUEST, - INVALID_SUGGESTION_REQUEST, - "Suggestion's entityLink cannot be null."); - } - MessageParser.EntityLink entityLink = - MessageParser.EntityLink.parse(suggestion.getEntityLink()); - Entity.getEntityReferenceByName( - entityLink.getEntityType(), entityLink.getEntityFQN(), Include.NON_DELETED); - - if (suggestion.getType() == SuggestionType.SuggestDescription) { - if (suggestion.getDescription() == null || suggestion.getDescription().isEmpty()) { - throw new SuggestionException( - Response.Status.BAD_REQUEST, - INVALID_SUGGESTION_REQUEST, - "Suggestion's description cannot be empty."); - } - } else if (suggestion.getType() == SuggestionType.SuggestTagLabel) { - if (suggestion.getTagLabels().isEmpty()) { - throw new SuggestionException( - Response.Status.BAD_REQUEST, - INVALID_SUGGESTION_REQUEST, - "Suggestion's tag label's cannot be empty."); - } else { - for (TagLabel label : listOrEmpty(suggestion.getTagLabels())) { - TagLabelUtil.applyTagCommonFields(label); - } - } - } else { - throw new SuggestionException( - Response.Status.BAD_REQUEST, INVALID_SUGGESTION_REQUEST, "Invalid Suggestion Type."); - } - } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/glossary/GlossaryMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/glossary/GlossaryMapper.java new file mode 100644 index 000000000000..0a5a6f950d97 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/glossary/GlossaryMapper.java @@ -0,0 +1,14 @@ +package org.openmetadata.service.resources.glossary; + +import org.openmetadata.schema.api.data.CreateGlossary; +import org.openmetadata.schema.entity.data.Glossary; +import org.openmetadata.service.mapper.EntityMapper; + +public class GlossaryMapper implements EntityMapper { + @Override + public Glossary createToEntity(CreateGlossary create, String user) { + return copy(new Glossary(), create, user) + .withProvider(create.getProvider()) + .withMutuallyExclusive(create.getMutuallyExclusive()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/glossary/GlossaryResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/glossary/GlossaryResource.java index 2947dd330924..169a30a34c7a 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/glossary/GlossaryResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/glossary/GlossaryResource.java @@ -79,6 +79,7 @@ public class GlossaryResource extends EntityResource { public static final String COLLECTION_PATH = "v1/glossaries/"; static final String FIELDS = "owners,tags,reviewers,usageCount,termCount,domain,extension"; + private final GlossaryMapper mapper = new GlossaryMapper(); public GlossaryResource(Authorizer authorizer, Limits limits) { super(Entity.GLOSSARY, authorizer, limits); @@ -296,7 +297,7 @@ public Response create( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateGlossary create) { - Glossary glossary = getGlossary(create, securityContext.getUserPrincipal().getName()); + Glossary glossary = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return create(uriInfo, securityContext, glossary); } @@ -377,7 +378,7 @@ public Response createOrUpdate( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateGlossary create) { - Glossary glossary = getGlossary(create, securityContext.getUserPrincipal().getName()); + Glossary glossary = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return createOrUpdate(uriInfo, securityContext, glossary); } @@ -607,16 +608,4 @@ public Response importCsvAsync( boolean dryRun) { return importCsvInternalAsync(securityContext, name, csv, dryRun); } - - private Glossary getGlossary(CreateGlossary create, String user) { - return getGlossary(repository, create, user); - } - - public static Glossary getGlossary( - GlossaryRepository repository, CreateGlossary create, String updatedBy) { - return repository - .copy(new Glossary(), create, updatedBy) - .withProvider(create.getProvider()) - .withMutuallyExclusive(create.getMutuallyExclusive()); - } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/glossary/GlossaryTermMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/glossary/GlossaryTermMapper.java new file mode 100644 index 000000000000..47c6e494725d --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/glossary/GlossaryTermMapper.java @@ -0,0 +1,24 @@ +package org.openmetadata.service.resources.glossary; + +import static org.openmetadata.service.util.EntityUtil.getEntityReference; +import static org.openmetadata.service.util.EntityUtil.getEntityReferences; + +import org.openmetadata.schema.api.data.CreateGlossaryTerm; +import org.openmetadata.schema.entity.data.GlossaryTerm; +import org.openmetadata.service.Entity; +import org.openmetadata.service.mapper.EntityMapper; + +public class GlossaryTermMapper implements EntityMapper { + @Override + public GlossaryTerm createToEntity(CreateGlossaryTerm create, String user) { + return copy(new GlossaryTerm(), create, user) + .withSynonyms(create.getSynonyms()) + .withStyle(create.getStyle()) + .withGlossary(getEntityReference(Entity.GLOSSARY, create.getGlossary())) + .withParent(getEntityReference(Entity.GLOSSARY_TERM, create.getParent())) + .withRelatedTerms(getEntityReferences(Entity.GLOSSARY_TERM, create.getRelatedTerms())) + .withReferences(create.getReferences()) + .withProvider(create.getProvider()) + .withMutuallyExclusive(create.getMutuallyExclusive()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/glossary/GlossaryTermResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/glossary/GlossaryTermResource.java index e7673154bc39..11ffd396f8b9 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/glossary/GlossaryTermResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/glossary/GlossaryTermResource.java @@ -88,6 +88,8 @@ name = "glossaryTerms", order = 7) // Initialized after Glossary, Classification, and Tags public class GlossaryTermResource extends EntityResource { + private final GlossaryTermMapper mapper = new GlossaryTermMapper(); + private final GlossaryMapper glossaryMapper = new GlossaryMapper(); public static final String COLLECTION_PATH = "v1/glossaryTerms/"; static final String FIELDS = "children,relatedTerms,reviewers,owners,tags,usageCount,domain,extension,childrenCount"; @@ -126,8 +128,7 @@ public void initialize(OpenMetadataApplicationConfig config) throws IOException GLOSSARY, ".*json/data/glossary/.*Glossary\\.json$", LoadGlossary.class); for (LoadGlossary loadGlossary : loadGlossaries) { Glossary glossary = - GlossaryResource.getGlossary( - glossaryRepository, loadGlossary.getCreateGlossary(), ADMIN_USER_NAME); + glossaryMapper.createToEntity(loadGlossary.getCreateGlossary(), ADMIN_USER_NAME); glossary.setFullyQualifiedName(glossary.getName()); glossaryRepository.initializeEntity(glossary); @@ -135,7 +136,7 @@ public void initialize(OpenMetadataApplicationConfig config) throws IOException for (CreateGlossaryTerm createTerm : loadGlossary.getCreateTerms()) { createTerm.withGlossary(glossary.getName()); createTerm.withProvider(glossary.getProvider()); - GlossaryTerm term = getGlossaryTerm(createTerm, ADMIN_USER_NAME); + GlossaryTerm term = mapper.createToEntity(createTerm, ADMIN_USER_NAME); repository.setFullyQualifiedName(term); // FQN required for ordering tags based on hierarchy termsToCreate.add(term); } @@ -411,7 +412,7 @@ public Response create( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateGlossaryTerm create) { - GlossaryTerm term = getGlossaryTerm(create, securityContext.getUserPrincipal().getName()); + GlossaryTerm term = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return create(uriInfo, securityContext, term); } @@ -493,7 +494,7 @@ public Response createOrUpdate( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateGlossaryTerm create) { - GlossaryTerm term = getGlossaryTerm(create, securityContext.getUserPrincipal().getName()); + GlossaryTerm term = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return createOrUpdate(uriInfo, securityContext, term); } @@ -681,17 +682,4 @@ public Response restoreTable( @Valid RestoreEntity restore) { return restoreEntity(uriInfo, securityContext, restore.getId()); } - - private GlossaryTerm getGlossaryTerm(CreateGlossaryTerm create, String user) { - return repository - .copy(new GlossaryTerm(), create, user) - .withSynonyms(create.getSynonyms()) - .withStyle(create.getStyle()) - .withGlossary(getEntityReference(Entity.GLOSSARY, create.getGlossary())) - .withParent(getEntityReference(Entity.GLOSSARY_TERM, create.getParent())) - .withRelatedTerms(getEntityReferences(Entity.GLOSSARY_TERM, create.getRelatedTerms())) - .withReferences(create.getReferences()) - .withProvider(create.getProvider()) - .withMutuallyExclusive(create.getMutuallyExclusive()); - } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/governance/WorkflowDefinitionMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/governance/WorkflowDefinitionMapper.java new file mode 100644 index 000000000000..452ca49309f4 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/governance/WorkflowDefinitionMapper.java @@ -0,0 +1,18 @@ +package org.openmetadata.service.resources.governance; + +import org.openmetadata.schema.api.governance.CreateWorkflowDefinition; +import org.openmetadata.schema.governance.workflows.WorkflowDefinition; +import org.openmetadata.service.mapper.EntityMapper; + +public class WorkflowDefinitionMapper + implements EntityMapper { + @Override + public WorkflowDefinition createToEntity(CreateWorkflowDefinition create, String user) { + return copy(new WorkflowDefinition(), create, user) + .withFullyQualifiedName(create.getName()) + .withType(WorkflowDefinition.Type.fromValue(create.getType().toString())) + .withTrigger(create.getTrigger()) + .withNodes(create.getNodes()) + .withEdges(create.getEdges()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/governance/WorkflowDefinitionResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/governance/WorkflowDefinitionResource.java index f76306509325..73832c1313e9 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/governance/WorkflowDefinitionResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/governance/WorkflowDefinitionResource.java @@ -59,6 +59,7 @@ public class WorkflowDefinitionResource extends EntityResource { public static final String COLLECTION_PATH = "v1/governance/workflowDefinitions/"; static final String FIELDS = "owners"; + private final WorkflowDefinitionMapper mapper = new WorkflowDefinitionMapper(); public WorkflowDefinitionResource(Authorizer authorizer, Limits limits) { super(Entity.WORKFLOW_DEFINITION, authorizer, limits); @@ -277,7 +278,7 @@ public Response create( @Context SecurityContext securityContext, @Valid CreateWorkflowDefinition create) { WorkflowDefinition workflowDefinition = - getWorkflowDefinition(create, securityContext.getUserPrincipal().getName()); + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return create(uriInfo, securityContext, workflowDefinition); } @@ -359,7 +360,7 @@ public Response createOrUpdate( @Context SecurityContext securityContext, @Valid CreateWorkflowDefinition create) { WorkflowDefinition workflowDefinition = - getWorkflowDefinition(create, securityContext.getUserPrincipal().getName()); + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return createOrUpdate(uriInfo, securityContext, workflowDefinition); } @@ -475,15 +476,4 @@ public Response trigger( return Response.status(Response.Status.NOT_FOUND).entity(fqn).build(); } } - - private WorkflowDefinition getWorkflowDefinition(CreateWorkflowDefinition create, String user) { - // TODO: Validate the NodeType and NodeSubType. - return repository - .copy(new WorkflowDefinition(), create, user) - .withFullyQualifiedName(create.getName()) - .withType(WorkflowDefinition.Type.fromValue(create.getType().toString())) - .withTrigger(create.getTrigger()) - .withNodes(create.getNodes()) - .withEdges(create.getEdges()); - } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/kpi/KpiMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/kpi/KpiMapper.java new file mode 100644 index 000000000000..6687a2ff765f --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/kpi/KpiMapper.java @@ -0,0 +1,22 @@ +package org.openmetadata.service.resources.kpi; + +import static org.openmetadata.service.util.EntityUtil.getEntityReference; + +import org.openmetadata.schema.api.dataInsight.kpi.CreateKpiRequest; +import org.openmetadata.schema.dataInsight.kpi.Kpi; +import org.openmetadata.service.Entity; +import org.openmetadata.service.mapper.EntityMapper; + +public class KpiMapper implements EntityMapper { + @Override + public Kpi createToEntity(CreateKpiRequest create, String user) { + return copy(new Kpi(), create, user) + .withStartDate(create.getStartDate()) + .withEndDate(create.getEndDate()) + .withTargetValue(create.getTargetValue()) + .withDataInsightChart( + getEntityReference( + Entity.DATA_INSIGHT_CUSTOM_CHART, create.getDataInsightChart().value())) + .withMetricType(create.getMetricType()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/kpi/KpiResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/kpi/KpiResource.java index d47001dc6402..0f2dcdd69788 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/kpi/KpiResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/kpi/KpiResource.java @@ -62,6 +62,7 @@ @Collection(name = "kpi") public class KpiResource extends EntityResource { public static final String COLLECTION_PATH = "/v1/kpi"; + private final KpiMapper mapper = new KpiMapper(); static final String FIELDS = "owners,dataInsightChart,kpiResult"; @Override @@ -284,7 +285,7 @@ public Response create( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateKpiRequest create) { - Kpi kpi = getKpi(create, securityContext.getUserPrincipal().getName()); + Kpi kpi = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); // TODO fix this // dao.validateDataInsightChartOneToOneMapping(kpi.getDataInsightChart().getId()); return create(uriInfo, securityContext, kpi); @@ -365,7 +366,7 @@ public Response createOrUpdate( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateKpiRequest create) { - Kpi kpi = getKpi(create, securityContext.getUserPrincipal().getName()); + Kpi kpi = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return createOrUpdate(uriInfo, securityContext, kpi); } @@ -507,16 +508,4 @@ public KpiResult listKpiResults( String name) { return repository.getKpiResult(name); } - - private Kpi getKpi(CreateKpiRequest create, String user) { - return repository - .copy(new Kpi(), create, user) - .withStartDate(create.getStartDate()) - .withEndDate(create.getEndDate()) - .withTargetValue(create.getTargetValue()) - .withDataInsightChart( - getEntityReference( - Entity.DATA_INSIGHT_CUSTOM_CHART, create.getDataInsightChart().value())) - .withMetricType(create.getMetricType()); - } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/metrics/MetricMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/metrics/MetricMapper.java new file mode 100644 index 000000000000..473783f50634 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/metrics/MetricMapper.java @@ -0,0 +1,20 @@ +package org.openmetadata.service.resources.metrics; + +import static org.openmetadata.service.util.EntityUtil.getEntityReferences; + +import org.openmetadata.schema.api.data.CreateMetric; +import org.openmetadata.schema.entity.data.Metric; +import org.openmetadata.service.Entity; +import org.openmetadata.service.mapper.EntityMapper; + +public class MetricMapper implements EntityMapper { + @Override + public Metric createToEntity(CreateMetric create, String user) { + return copy(new Metric(), create, user) + .withMetricExpression(create.getMetricExpression()) + .withGranularity(create.getGranularity()) + .withRelatedMetrics(getEntityReferences(Entity.METRIC, create.getRelatedMetrics())) + .withMetricType(create.getMetricType()) + .withUnitOfMeasurement(create.getUnitOfMeasurement()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/metrics/MetricResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/metrics/MetricResource.java index 4be491b81a58..b2e644498d7e 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/metrics/MetricResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/metrics/MetricResource.java @@ -73,6 +73,7 @@ @Collection(name = "metrics") public class MetricResource extends EntityResource { public static final String COLLECTION_PATH = "v1/metrics/"; + private final MetricMapper mapper = new MetricMapper(); static final String FIELDS = "owners,relatedMetrics,followers,tags,extension,domain,dataProducts"; public MetricResource(Authorizer authorizer, Limits limits) { @@ -279,7 +280,7 @@ public Response create( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateMetric create) { - Metric metric = getMetric(create, securityContext.getUserPrincipal().getName()); + Metric metric = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return create(uriInfo, securityContext, metric); } @@ -302,7 +303,7 @@ public Response createOrUpdate( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateMetric create) { - Metric metric = getMetric(create, securityContext.getUserPrincipal().getName()); + Metric metric = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return createOrUpdate(uriInfo, securityContext, metric); } @@ -519,14 +520,4 @@ public Response restore( @Valid RestoreEntity restore) { return restoreEntity(uriInfo, securityContext, restore.getId()); } - - private Metric getMetric(CreateMetric create, String user) { - return repository - .copy(new Metric(), create, user) - .withMetricExpression(create.getMetricExpression()) - .withGranularity(create.getGranularity()) - .withRelatedMetrics(getEntityReferences(Entity.METRIC, create.getRelatedMetrics())) - .withMetricType(create.getMetricType()) - .withUnitOfMeasurement(create.getUnitOfMeasurement()); - } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/mlmodels/MlModelMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/mlmodels/MlModelMapper.java new file mode 100644 index 000000000000..29f3358bf827 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/mlmodels/MlModelMapper.java @@ -0,0 +1,25 @@ +package org.openmetadata.service.resources.mlmodels; + +import static org.openmetadata.service.util.EntityUtil.getEntityReference; + +import org.openmetadata.schema.api.data.CreateMlModel; +import org.openmetadata.schema.entity.data.MlModel; +import org.openmetadata.service.Entity; +import org.openmetadata.service.mapper.EntityMapper; + +public class MlModelMapper implements EntityMapper { + @Override + public MlModel createToEntity(CreateMlModel create, String user) { + return copy(new MlModel(), create, user) + .withService(getEntityReference(Entity.MLMODEL_SERVICE, create.getService())) + .withDashboard(getEntityReference(Entity.DASHBOARD, create.getDashboard())) + .withAlgorithm(create.getAlgorithm()) + .withMlFeatures(create.getMlFeatures()) + .withMlHyperParameters(create.getMlHyperParameters()) + .withMlStore(create.getMlStore()) + .withServer(create.getServer()) + .withTarget(create.getTarget()) + .withSourceUrl(create.getSourceUrl()) + .withSourceHash(create.getSourceHash()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/mlmodels/MlModelResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/mlmodels/MlModelResource.java index d0ed303ac86c..092e6c7489a9 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/mlmodels/MlModelResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/mlmodels/MlModelResource.java @@ -73,6 +73,7 @@ @Collection(name = "mlmodels") public class MlModelResource extends EntityResource { public static final String COLLECTION_PATH = "v1/mlmodels/"; + private final MlModelMapper mapper = new MlModelMapper(); static final String FIELDS = "owners,dashboard,followers,tags,usageSummary,extension,domain,sourceHash"; @@ -250,7 +251,7 @@ public Response create( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateMlModel create) { - MlModel mlModel = getMlModel(create, securityContext.getUserPrincipal().getName()); + MlModel mlModel = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return create(uriInfo, securityContext, mlModel); } @@ -331,7 +332,7 @@ public Response createOrUpdate( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateMlModel create) { - MlModel mlModel = getMlModel(create, securityContext.getUserPrincipal().getName()); + MlModel mlModel = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return createOrUpdate(uriInfo, securityContext, mlModel); } @@ -552,19 +553,4 @@ public Response restoreMlModel( @Valid RestoreEntity restore) { return restoreEntity(uriInfo, securityContext, restore.getId()); } - - private MlModel getMlModel(CreateMlModel create, String user) { - return repository - .copy(new MlModel(), create, user) - .withService(getEntityReference(Entity.MLMODEL_SERVICE, create.getService())) - .withDashboard(getEntityReference(Entity.DASHBOARD, create.getDashboard())) - .withAlgorithm(create.getAlgorithm()) - .withMlFeatures(create.getMlFeatures()) - .withMlHyperParameters(create.getMlHyperParameters()) - .withMlStore(create.getMlStore()) - .withServer(create.getServer()) - .withTarget(create.getTarget()) - .withSourceUrl(create.getSourceUrl()) - .withSourceHash(create.getSourceHash()); - } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/pipelines/PipelineMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/pipelines/PipelineMapper.java new file mode 100644 index 000000000000..78143bafdb0a --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/pipelines/PipelineMapper.java @@ -0,0 +1,23 @@ +package org.openmetadata.service.resources.pipelines; + +import static org.openmetadata.service.util.EntityUtil.getEntityReference; + +import org.openmetadata.schema.api.data.CreatePipeline; +import org.openmetadata.schema.entity.data.Pipeline; +import org.openmetadata.service.Entity; +import org.openmetadata.service.mapper.EntityMapper; + +public class PipelineMapper implements EntityMapper { + @Override + public Pipeline createToEntity(CreatePipeline create, String user) { + return copy(new Pipeline(), create, user) + .withService(getEntityReference(Entity.PIPELINE_SERVICE, create.getService())) + .withTasks(create.getTasks()) + .withSourceUrl(create.getSourceUrl()) + .withConcurrency(create.getConcurrency()) + .withStartDate(create.getStartDate()) + .withPipelineLocation(create.getPipelineLocation()) + .withScheduleInterval(create.getScheduleInterval()) + .withSourceHash(create.getSourceHash()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/pipelines/PipelineResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/pipelines/PipelineResource.java index ee9228690c63..210a270975e6 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/pipelines/PipelineResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/pipelines/PipelineResource.java @@ -76,6 +76,7 @@ @Collection(name = "pipelines") public class PipelineResource extends EntityResource { public static final String COLLECTION_PATH = "v1/pipelines/"; + private final PipelineMapper mapper = new PipelineMapper(); static final String FIELDS = "owners,tasks,pipelineStatus,followers,tags,extension,scheduleInterval,domain,sourceHash"; @@ -311,7 +312,7 @@ public Response create( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreatePipeline create) { - Pipeline pipeline = getPipeline(create, securityContext.getUserPrincipal().getName()); + Pipeline pipeline = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return create(uriInfo, securityContext, pipeline); } @@ -392,7 +393,7 @@ public Response createOrUpdate( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreatePipeline create) { - Pipeline pipeline = getPipeline(create, securityContext.getUserPrincipal().getName()); + Pipeline pipeline = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return createOrUpdate(uriInfo, securityContext, pipeline); } @@ -669,17 +670,4 @@ public Response restorePipeline( @Valid RestoreEntity restore) { return restoreEntity(uriInfo, securityContext, restore.getId()); } - - private Pipeline getPipeline(CreatePipeline create, String user) { - return repository - .copy(new Pipeline(), create, user) - .withService(getEntityReference(Entity.PIPELINE_SERVICE, create.getService())) - .withTasks(create.getTasks()) - .withSourceUrl(create.getSourceUrl()) - .withConcurrency(create.getConcurrency()) - .withStartDate(create.getStartDate()) - .withPipelineLocation(create.getPipelineLocation()) - .withScheduleInterval(create.getScheduleInterval()) - .withSourceHash(create.getSourceHash()); - } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/policies/PolicyMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/policies/PolicyMapper.java new file mode 100644 index 000000000000..60b5aa6322d5 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/policies/PolicyMapper.java @@ -0,0 +1,20 @@ +package org.openmetadata.service.resources.policies; + +import org.openmetadata.schema.api.policies.CreatePolicy; +import org.openmetadata.schema.entity.policies.Policy; +import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.service.mapper.EntityMapper; + +public class PolicyMapper implements EntityMapper { + @Override + public Policy createToEntity(CreatePolicy create, String user) { + Policy policy = + copy(new Policy(), create, user) + .withRules(create.getRules()) + .withEnabled(create.getEnabled()); + if (create.getLocation() != null) { + policy = policy.withLocation(new EntityReference().withId(create.getLocation())); + } + return policy; + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/policies/PolicyResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/policies/PolicyResource.java index 663ff9c556f7..411a489899b2 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/policies/PolicyResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/policies/PolicyResource.java @@ -51,7 +51,6 @@ import org.openmetadata.schema.entity.policies.Policy; import org.openmetadata.schema.entity.policies.accessControl.Rule; import org.openmetadata.schema.type.EntityHistory; -import org.openmetadata.schema.type.EntityReference; import org.openmetadata.schema.type.Function; import org.openmetadata.schema.type.Include; import org.openmetadata.schema.type.MetadataOperation; @@ -82,6 +81,7 @@ @Consumes(MediaType.APPLICATION_JSON) @Collection(name = "policies", order = 0, requiredForOps = true) public class PolicyResource extends EntityResource { + private final PolicyMapper mapper = new PolicyMapper(); public static final String COLLECTION_PATH = "v1/policies/"; public static final String FIELDS = "owners,location,teams,roles"; @@ -359,7 +359,7 @@ public Response create( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreatePolicy create) { - Policy policy = getPolicy(create, securityContext.getUserPrincipal().getName()); + Policy policy = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return create(uriInfo, securityContext, policy); } @@ -439,7 +439,7 @@ public Response createOrUpdate( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreatePolicy create) { - Policy policy = getPolicy(create, securityContext.getUserPrincipal().getName()); + Policy policy = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return createOrUpdate(uriInfo, securityContext, policy); } @@ -531,16 +531,4 @@ public void validateCondition( authorizer.authorizeAdmin(securityContext); CompiledRule.validateExpression(expression, Boolean.class); } - - private Policy getPolicy(CreatePolicy create, String user) { - Policy policy = - repository - .copy(new Policy(), create, user) - .withRules(create.getRules()) - .withEnabled(create.getEnabled()); - if (create.getLocation() != null) { - policy = policy.withLocation(new EntityReference().withId(create.getLocation())); - } - return policy; - } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/query/QueryMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/query/QueryMapper.java new file mode 100644 index 000000000000..581e81679780 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/query/QueryMapper.java @@ -0,0 +1,29 @@ +package org.openmetadata.service.resources.query; + +import static org.openmetadata.service.Entity.USER; +import static org.openmetadata.service.util.EntityUtil.getEntityReference; +import static org.openmetadata.service.util.EntityUtil.getEntityReferences; + +import org.openmetadata.schema.api.data.CreateQuery; +import org.openmetadata.schema.entity.data.Query; +import org.openmetadata.schema.type.Votes; +import org.openmetadata.service.Entity; +import org.openmetadata.service.mapper.EntityMapper; +import org.openmetadata.service.util.EntityUtil; + +public class QueryMapper implements EntityMapper { + @Override + public Query createToEntity(CreateQuery create, String user) { + return copy(new Query(), create, user) + .withQuery(create.getQuery()) + .withChecksum(EntityUtil.hash(create.getQuery())) + .withService(getEntityReference(Entity.DATABASE_SERVICE, create.getService())) + .withDuration(create.getDuration()) + .withVotes(new Votes().withUpVotes(0).withDownVotes(0)) + .withUsers(getEntityReferences(USER, create.getUsers())) + .withQueryUsedIn(EntityUtil.populateEntityReferences(create.getQueryUsedIn())) + .withQueryDate(create.getQueryDate()) + .withTriggeredBy(create.getTriggeredBy()) + .withProcessedLineage(create.getProcessedLineage()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/query/QueryResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/query/QueryResource.java index 1839fa8d4928..c82743da06e0 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/query/QueryResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/query/QueryResource.java @@ -1,7 +1,5 @@ package org.openmetadata.service.resources.query; -import static org.openmetadata.service.Entity.USER; - import io.swagger.v3.oas.annotations.ExternalDocumentation; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -42,7 +40,6 @@ import org.openmetadata.schema.type.EntityHistory; import org.openmetadata.schema.type.EntityReference; import org.openmetadata.schema.type.MetadataOperation; -import org.openmetadata.schema.type.Votes; import org.openmetadata.service.Entity; import org.openmetadata.service.jdbi3.ListFilter; import org.openmetadata.service.jdbi3.QueryRepository; @@ -52,7 +49,6 @@ import org.openmetadata.service.security.Authorizer; import org.openmetadata.service.security.mask.PIIMasker; import org.openmetadata.service.security.policyevaluator.OperationContext; -import org.openmetadata.service.util.EntityUtil; import org.openmetadata.service.util.ResultList; @Path("/v1/queries") @@ -64,6 +60,7 @@ @Consumes(MediaType.APPLICATION_JSON) @Collection(name = "queries") public class QueryResource extends EntityResource { + private final QueryMapper mapper = new QueryMapper(); public static final String COLLECTION_PATH = "v1/queries/"; static final String FIELDS = "owners,followers,users,votes,tags,queryUsedIn"; @@ -285,7 +282,7 @@ public Response create( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateQuery create) { - Query query = getQuery(create, securityContext.getUserPrincipal().getName()); + Query query = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return create(uriInfo, securityContext, query); } @@ -309,7 +306,7 @@ public Response createOrUpdate( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateQuery create) { - Query query = getQuery(create, securityContext.getUserPrincipal().getName()); + Query query = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return createOrUpdate(uriInfo, securityContext, query); } @@ -633,19 +630,4 @@ public Response delete( String fqn) { return deleteByName(uriInfo, securityContext, fqn, false, true); } - - private Query getQuery(CreateQuery create, String user) { - return repository - .copy(new Query(), create, user) - .withQuery(create.getQuery()) - .withChecksum(EntityUtil.hash(create.getQuery())) - .withService(getEntityReference(Entity.DATABASE_SERVICE, create.getService())) - .withDuration(create.getDuration()) - .withVotes(new Votes().withUpVotes(0).withDownVotes(0)) - .withUsers(getEntityReferences(USER, create.getUsers())) - .withQueryUsedIn(EntityUtil.populateEntityReferences(create.getQueryUsedIn())) - .withQueryDate(create.getQueryDate()) - .withTriggeredBy(create.getTriggeredBy()) - .withProcessedLineage(create.getProcessedLineage()); - } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/searchindex/SearchIndexMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/searchindex/SearchIndexMapper.java new file mode 100644 index 000000000000..b7df4b11e337 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/searchindex/SearchIndexMapper.java @@ -0,0 +1,20 @@ +package org.openmetadata.service.resources.searchindex; + +import static org.openmetadata.service.util.EntityUtil.getEntityReference; + +import org.openmetadata.schema.api.data.CreateSearchIndex; +import org.openmetadata.schema.entity.data.SearchIndex; +import org.openmetadata.service.Entity; +import org.openmetadata.service.mapper.EntityMapper; + +public class SearchIndexMapper implements EntityMapper { + @Override + public SearchIndex createToEntity(CreateSearchIndex create, String user) { + return copy(new SearchIndex(), create, user) + .withService(getEntityReference(Entity.SEARCH_SERVICE, create.getService())) + .withFields(create.getFields()) + .withSearchIndexSettings(create.getSearchIndexSettings()) + .withSourceHash(create.getSourceHash()) + .withIndexType(create.getIndexType()); + } +} 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 24831f21253e..e65dea4cf4ad 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 @@ -75,6 +75,7 @@ @Consumes(MediaType.APPLICATION_JSON) @Collection(name = "searchIndexes") public class SearchIndexResource extends EntityResource { + private final SearchIndexMapper mapper = new SearchIndexMapper(); public static final String COLLECTION_PATH = "v1/searchIndexes/"; static final String FIELDS = "owners,followers,tags,extension,domain,dataProducts,sourceHash"; @@ -309,7 +310,8 @@ public Response create( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateSearchIndex create) { - SearchIndex searchIndex = getSearchIndex(create, securityContext.getUserPrincipal().getName()); + SearchIndex searchIndex = + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return create(uriInfo, securityContext, searchIndex); } @@ -389,7 +391,8 @@ public Response createOrUpdate( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateSearchIndex create) { - SearchIndex searchIndex = getSearchIndex(create, securityContext.getUserPrincipal().getName()); + SearchIndex searchIndex = + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return createOrUpdate(uriInfo, securityContext, searchIndex); } @@ -626,16 +629,4 @@ public Response restoreSearchIndex( @Valid RestoreEntity restore) { return restoreEntity(uriInfo, securityContext, restore.getId()); } - - private SearchIndex getSearchIndex(CreateSearchIndex create, String user) { - 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/resources/services/apiservices/APIServiceMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/apiservices/APIServiceMapper.java new file mode 100644 index 000000000000..9e5187875f61 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/apiservices/APIServiceMapper.java @@ -0,0 +1,14 @@ +package org.openmetadata.service.resources.services.apiservices; + +import org.openmetadata.schema.api.services.CreateApiService; +import org.openmetadata.schema.entity.services.ApiService; +import org.openmetadata.service.mapper.EntityMapper; + +public class APIServiceMapper implements EntityMapper { + @Override + public ApiService createToEntity(CreateApiService create, String user) { + return copy(new ApiService(), create, user) + .withServiceType(create.getServiceType()) + .withConnection(create.getConnection()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/apiservices/APIServiceResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/apiservices/APIServiceResource.java index 1a23c9b1c9a9..47f2345b06b0 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/apiservices/APIServiceResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/apiservices/APIServiceResource.java @@ -76,6 +76,7 @@ @Collection(name = "apiServices") public class APIServiceResource extends ServiceEntityResource { + private final APIServiceMapper mapper = new APIServiceMapper(); public static final String COLLECTION_PATH = "v1/services/apiServices/"; static final String FIELDS = "pipelines,owners,tags,domain"; @@ -345,7 +346,8 @@ public Response create( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateApiService create) { - ApiService service = getService(create, securityContext.getUserPrincipal().getName()); + ApiService service = + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); Response response = create(uriInfo, securityContext, service); decryptOrNullify(securityContext, (ApiService) response.getEntity()); return response; @@ -370,7 +372,8 @@ public Response createOrUpdate( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateApiService update) { - ApiService service = getService(update, securityContext.getUserPrincipal().getName()); + ApiService service = + mapper.createToEntity(update, securityContext.getUserPrincipal().getName()); Response response = createOrUpdate(uriInfo, securityContext, unmask(service)); decryptOrNullify(securityContext, (ApiService) response.getEntity()); return response; @@ -513,13 +516,6 @@ public Response restoreAPIService( return restoreEntity(uriInfo, securityContext, restore.getId()); } - private ApiService getService(CreateApiService create, String user) { - return repository - .copy(new ApiService(), create, user) - .withServiceType(create.getServiceType()) - .withConnection(create.getConnection()); - } - @Override protected ApiService nullifyConnection(ApiService service) { return service.withConnection(null); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/dashboard/DashboardServiceMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/dashboard/DashboardServiceMapper.java new file mode 100644 index 000000000000..3ec9cab15750 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/dashboard/DashboardServiceMapper.java @@ -0,0 +1,15 @@ +package org.openmetadata.service.resources.services.dashboard; + +import org.openmetadata.schema.api.services.CreateDashboardService; +import org.openmetadata.schema.entity.services.DashboardService; +import org.openmetadata.service.mapper.EntityMapper; + +public class DashboardServiceMapper + implements EntityMapper { + @Override + public DashboardService createToEntity(CreateDashboardService create, String user) { + return copy(new DashboardService(), create, user) + .withServiceType(create.getServiceType()) + .withConnection(create.getConnection()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/dashboard/DashboardServiceResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/dashboard/DashboardServiceResource.java index fa72220f3ede..d000ac35775d 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/dashboard/DashboardServiceResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/dashboard/DashboardServiceResource.java @@ -72,6 +72,7 @@ public class DashboardServiceResource extends ServiceEntityResource< DashboardService, DashboardServiceRepository, DashboardConnection> { + private final DashboardServiceMapper mapper = new DashboardServiceMapper(); public static final String COLLECTION_PATH = "v1/services/dashboardServices"; static final String FIELDS = "owners,domain"; @@ -333,7 +334,8 @@ public Response create( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateDashboardService create) { - DashboardService service = getService(create, securityContext.getUserPrincipal().getName()); + DashboardService service = + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); Response response = create(uriInfo, securityContext, service); decryptOrNullify(securityContext, (DashboardService) response.getEntity()); return response; @@ -358,7 +360,8 @@ public Response createOrUpdate( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateDashboardService update) { - DashboardService service = getService(update, securityContext.getUserPrincipal().getName()); + DashboardService service = + mapper.createToEntity(update, securityContext.getUserPrincipal().getName()); Response response = createOrUpdate(uriInfo, securityContext, unmask(service)); decryptOrNullify(securityContext, (DashboardService) response.getEntity()); return response; @@ -507,13 +510,6 @@ public Response restoreDashboardService( return restoreEntity(uriInfo, securityContext, restore.getId()); } - private DashboardService getService(CreateDashboardService create, String user) { - return repository - .copy(new DashboardService(), create, user) - .withServiceType(create.getServiceType()) - .withConnection(create.getConnection()); - } - @Override protected DashboardService nullifyConnection(DashboardService service) { return service.withConnection(null); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/database/DatabaseServiceMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/database/DatabaseServiceMapper.java new file mode 100644 index 000000000000..db8ac8818afd --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/database/DatabaseServiceMapper.java @@ -0,0 +1,14 @@ +package org.openmetadata.service.resources.services.database; + +import org.openmetadata.schema.api.services.CreateDatabaseService; +import org.openmetadata.schema.entity.services.DatabaseService; +import org.openmetadata.service.mapper.EntityMapper; + +public class DatabaseServiceMapper implements EntityMapper { + @Override + public DatabaseService createToEntity(CreateDatabaseService create, String user) { + return copy(new DatabaseService(), create, user) + .withServiceType(create.getServiceType()) + .withConnection(create.getConnection()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/database/DatabaseServiceResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/database/DatabaseServiceResource.java index adf3fd7b8d59..0faf6fd73139 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/database/DatabaseServiceResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/database/DatabaseServiceResource.java @@ -80,6 +80,7 @@ @Collection(name = "databaseServices") public class DatabaseServiceResource extends ServiceEntityResource { + private final DatabaseServiceMapper mapper = new DatabaseServiceMapper(); public static final String COLLECTION_PATH = "v1/services/databaseServices/"; static final String FIELDS = "pipelines,owners,tags,domain"; @@ -352,7 +353,8 @@ public Response create( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateDatabaseService create) { - DatabaseService service = getService(create, securityContext.getUserPrincipal().getName()); + DatabaseService service = + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); Response response = create(uriInfo, securityContext, service); decryptOrNullify(securityContext, (DatabaseService) response.getEntity()); return response; @@ -377,7 +379,8 @@ public Response createOrUpdate( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateDatabaseService update) { - DatabaseService service = getService(update, securityContext.getUserPrincipal().getName()); + DatabaseService service = + mapper.createToEntity(update, securityContext.getUserPrincipal().getName()); Response response = createOrUpdate(uriInfo, securityContext, unmask(service)); decryptOrNullify(securityContext, (DatabaseService) response.getEntity()); return response; @@ -642,13 +645,6 @@ public Response restoreDatabaseService( return restoreEntity(uriInfo, securityContext, restore.getId()); } - private DatabaseService getService(CreateDatabaseService create, String user) { - return repository - .copy(new DatabaseService(), create, user) - .withServiceType(create.getServiceType()) - .withConnection(create.getConnection()); - } - @Override protected DatabaseService nullifyConnection(DatabaseService service) { return service.withConnection(null); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/ingestionpipelines/IngestionPipelineMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/ingestionpipelines/IngestionPipelineMapper.java new file mode 100644 index 000000000000..e2134d9b11f7 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/ingestionpipelines/IngestionPipelineMapper.java @@ -0,0 +1,31 @@ +package org.openmetadata.service.resources.services.ingestionpipelines; + +import org.openmetadata.schema.api.services.ingestionPipelines.CreateIngestionPipeline; +import org.openmetadata.schema.entity.services.ingestionPipelines.IngestionPipeline; +import org.openmetadata.schema.services.connections.metadata.OpenMetadataConnection; +import org.openmetadata.service.OpenMetadataApplicationConfig; +import org.openmetadata.service.mapper.EntityMapper; +import org.openmetadata.service.util.OpenMetadataConnectionBuilder; + +public class IngestionPipelineMapper + implements EntityMapper { + private final OpenMetadataApplicationConfig openMetadataApplicationConfig; + + public IngestionPipelineMapper(OpenMetadataApplicationConfig openMetadataApplicationConfig) { + this.openMetadataApplicationConfig = openMetadataApplicationConfig; + } + + @Override + public IngestionPipeline createToEntity(CreateIngestionPipeline create, String user) { + OpenMetadataConnection openMetadataServerConnection = + new OpenMetadataConnectionBuilder(openMetadataApplicationConfig).build(); + + return copy(new IngestionPipeline(), create, user) + .withPipelineType(create.getPipelineType()) + .withAirflowConfig(create.getAirflowConfig()) + .withOpenMetadataServerConnection(openMetadataServerConnection) + .withSourceConfig(create.getSourceConfig()) + .withLoggerLevel(create.getLoggerLevel()) + .withService(create.getService()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/ingestionpipelines/IngestionPipelineResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/ingestionpipelines/IngestionPipelineResource.java index 51757d6c9f6d..3a64d212caee 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/ingestionpipelines/IngestionPipelineResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/ingestionpipelines/IngestionPipelineResource.java @@ -97,6 +97,7 @@ @Collection(name = "IngestionPipelines") public class IngestionPipelineResource extends EntityResource { + private IngestionPipelineMapper mapper; public static final String COLLECTION_PATH = "v1/services/ingestionPipelines/"; private PipelineServiceClientInterface pipelineServiceClient; private OpenMetadataApplicationConfig openMetadataApplicationConfig; @@ -116,7 +117,7 @@ public IngestionPipelineResource(Authorizer authorizer, Limits limits) { @Override public void initialize(OpenMetadataApplicationConfig config) { this.openMetadataApplicationConfig = config; - + this.mapper = new IngestionPipelineMapper(config); this.pipelineServiceClient = PipelineServiceClientFactory.createPipelineServiceClient( config.getPipelineServiceClientConfiguration()); @@ -426,7 +427,7 @@ public Response create( @Context SecurityContext securityContext, @Valid CreateIngestionPipeline create) { IngestionPipeline ingestionPipeline = - getIngestionPipeline(create, securityContext.getUserPrincipal().getName()); + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); Response response = create(uriInfo, securityContext, ingestionPipeline); validateProfileSample(ingestionPipeline); decryptOrNullify(securityContext, (IngestionPipeline) response.getEntity(), false); @@ -516,7 +517,7 @@ public Response createOrUpdate( @Context SecurityContext securityContext, @Valid CreateIngestionPipeline update) { IngestionPipeline ingestionPipeline = - getIngestionPipeline(update, securityContext.getUserPrincipal().getName()); + mapper.createToEntity(update, securityContext.getUserPrincipal().getName()); unmask(ingestionPipeline); Response response = createOrUpdate(uriInfo, securityContext, ingestionPipeline); validateProfileSample(ingestionPipeline); @@ -936,20 +937,6 @@ public IngestionPipeline deletePipelineStatus( return addHref(uriInfo, ingestionPipeline); } - private IngestionPipeline getIngestionPipeline(CreateIngestionPipeline create, String user) { - OpenMetadataConnection openMetadataServerConnection = - new OpenMetadataConnectionBuilder(openMetadataApplicationConfig).build(); - - return repository - .copy(new IngestionPipeline(), create, user) - .withPipelineType(create.getPipelineType()) - .withAirflowConfig(create.getAirflowConfig()) - .withOpenMetadataServerConnection(openMetadataServerConnection) - .withSourceConfig(create.getSourceConfig()) - .withLoggerLevel(create.getLoggerLevel()) - .withService(create.getService()); - } - private void unmask(IngestionPipeline ingestionPipeline) { repository.setFullyQualifiedName(ingestionPipeline); IngestionPipeline originalIngestionPipeline = diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/messaging/MessagingServiceMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/messaging/MessagingServiceMapper.java new file mode 100644 index 000000000000..666ba51cccb5 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/messaging/MessagingServiceMapper.java @@ -0,0 +1,15 @@ +package org.openmetadata.service.resources.services.messaging; + +import org.openmetadata.schema.api.services.CreateMessagingService; +import org.openmetadata.schema.entity.services.MessagingService; +import org.openmetadata.service.mapper.EntityMapper; + +public class MessagingServiceMapper + implements EntityMapper { + @Override + public MessagingService createToEntity(CreateMessagingService create, String user) { + return copy(new MessagingService(), create, user) + .withConnection(create.getConnection()) + .withServiceType(create.getServiceType()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/messaging/MessagingServiceResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/messaging/MessagingServiceResource.java index 139861ecf301..beb48d97dda8 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/messaging/MessagingServiceResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/messaging/MessagingServiceResource.java @@ -72,6 +72,7 @@ public class MessagingServiceResource extends ServiceEntityResource< MessagingService, MessagingServiceRepository, MessagingConnection> { + private final MessagingServiceMapper mapper = new MessagingServiceMapper(); public static final String COLLECTION_PATH = "v1/services/messagingServices/"; public static final String FIELDS = "owners,domain"; @@ -339,7 +340,8 @@ public Response create( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateMessagingService create) { - MessagingService service = getService(create, securityContext.getUserPrincipal().getName()); + MessagingService service = + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); Response response = create(uriInfo, securityContext, service); decryptOrNullify(securityContext, (MessagingService) response.getEntity()); return response; @@ -365,7 +367,8 @@ public Response createOrUpdate( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateMessagingService update) { - MessagingService service = getService(update, securityContext.getUserPrincipal().getName()); + MessagingService service = + mapper.createToEntity(update, securityContext.getUserPrincipal().getName()); Response response = createOrUpdate(uriInfo, securityContext, unmask(service)); decryptOrNullify(securityContext, (MessagingService) response.getEntity()); return response; @@ -513,13 +516,6 @@ public Response restoreMessagingService( return restoreEntity(uriInfo, securityContext, restore.getId()); } - private MessagingService getService(CreateMessagingService create, String user) { - return repository - .copy(new MessagingService(), create, user) - .withConnection(create.getConnection()) - .withServiceType(create.getServiceType()); - } - @Override protected MessagingService nullifyConnection(MessagingService service) { return service.withConnection(null); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/metadata/MetadataServiceMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/metadata/MetadataServiceMapper.java new file mode 100644 index 000000000000..a546eb60140e --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/metadata/MetadataServiceMapper.java @@ -0,0 +1,14 @@ +package org.openmetadata.service.resources.services.metadata; + +import org.openmetadata.schema.api.services.CreateMetadataService; +import org.openmetadata.schema.entity.services.MetadataService; +import org.openmetadata.service.mapper.EntityMapper; + +public class MetadataServiceMapper implements EntityMapper { + @Override + public MetadataService createToEntity(CreateMetadataService create, String user) { + return copy(new MetadataService(), create, user) + .withServiceType(create.getServiceType()) + .withConnection(create.getConnection()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/metadata/MetadataServiceResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/metadata/MetadataServiceResource.java index dc0ca583ad43..58b77643b4c0 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/metadata/MetadataServiceResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/metadata/MetadataServiceResource.java @@ -16,7 +16,6 @@ import java.util.List; import java.util.Objects; import java.util.UUID; -import java.util.stream.Collectors; import javax.json.JsonPatch; import javax.validation.Valid; import javax.validation.constraints.Max; @@ -75,6 +74,7 @@ @Collection(name = "metadataServices", order = 8) // init before IngestionPipelineService public class MetadataServiceResource extends ServiceEntityResource { + private final MetadataServiceMapper mapper = new MetadataServiceMapper(); public static final String OPENMETADATA_SERVICE = "OpenMetadata"; public static final String COLLECTION_PATH = "v1/services/metadataServices/"; public static final String FIELDS = "pipelines,owners,tags"; @@ -320,7 +320,7 @@ public EntityHistory listVersions( return json; } }) - .collect(Collectors.toList()); + .toList(); entityHistory.setVersions(versions); return entityHistory; } @@ -378,7 +378,7 @@ public Response create( @Context SecurityContext securityContext, @Valid CreateMetadataService create) { MetadataService service = - getMetadataService(create, securityContext.getUserPrincipal().getName()); + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); Response response = create(uriInfo, securityContext, service); decryptOrNullify(securityContext, (MetadataService) response.getEntity()); return response; @@ -404,7 +404,7 @@ public Response createOrUpdate( @Context SecurityContext securityContext, @Valid CreateMetadataService update) { MetadataService service = - getMetadataService(update, securityContext.getUserPrincipal().getName()); + mapper.createToEntity(update, securityContext.getUserPrincipal().getName()); Response response = createOrUpdate(uriInfo, securityContext, unmask(service)); decryptOrNullify(securityContext, (MetadataService) response.getEntity()); return response; @@ -552,13 +552,6 @@ public Response restoreMetadataService( return restoreEntity(uriInfo, securityContext, restore.getId()); } - private MetadataService getMetadataService(CreateMetadataService create, String user) { - return repository - .copy(new MetadataService(), create, user) - .withServiceType(create.getServiceType()) - .withConnection(create.getConnection()); - } - @Override protected MetadataService nullifyConnection(MetadataService service) { return service.withConnection(null); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/mlmodel/MlModelServiceMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/mlmodel/MlModelServiceMapper.java new file mode 100644 index 000000000000..fac0635e55fa --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/mlmodel/MlModelServiceMapper.java @@ -0,0 +1,14 @@ +package org.openmetadata.service.resources.services.mlmodel; + +import org.openmetadata.schema.api.services.CreateMlModelService; +import org.openmetadata.schema.entity.services.MlModelService; +import org.openmetadata.service.mapper.EntityMapper; + +public class MlModelServiceMapper implements EntityMapper { + @Override + public MlModelService createToEntity(CreateMlModelService create, String user) { + return copy(new MlModelService(), create, user) + .withServiceType(create.getServiceType()) + .withConnection(create.getConnection()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/mlmodel/MlModelServiceResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/mlmodel/MlModelServiceResource.java index 5b70603806ed..bf1c389d5d28 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/mlmodel/MlModelServiceResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/mlmodel/MlModelServiceResource.java @@ -73,6 +73,7 @@ @Collection(name = "mlmodelServices") public class MlModelServiceResource extends ServiceEntityResource { + private final MlModelServiceMapper mapper = new MlModelServiceMapper(); public static final String COLLECTION_PATH = "v1/services/mlmodelServices/"; public static final String FIELDS = "pipelines,owners,tags,domain"; @@ -351,7 +352,8 @@ public Response create( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateMlModelService create) { - MlModelService service = getService(create, securityContext.getUserPrincipal().getName()); + MlModelService service = + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); Response response = create(uriInfo, securityContext, service); decryptOrNullify(securityContext, (MlModelService) response.getEntity()); return response; @@ -377,7 +379,8 @@ public Response createOrUpdate( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateMlModelService update) { - MlModelService service = getService(update, securityContext.getUserPrincipal().getName()); + MlModelService service = + mapper.createToEntity(update, securityContext.getUserPrincipal().getName()); Response response = createOrUpdate(uriInfo, securityContext, unmask(service)); decryptOrNullify(securityContext, (MlModelService) response.getEntity()); return response; @@ -526,13 +529,6 @@ public Response restoreTable( return restoreEntity(uriInfo, securityContext, restore.getId()); } - private MlModelService getService(CreateMlModelService create, String user) { - return repository - .copy(new MlModelService(), create, user) - .withServiceType(create.getServiceType()) - .withConnection(create.getConnection()); - } - @Override protected MlModelService nullifyConnection(MlModelService service) { return service.withConnection(null); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/pipeline/PipelineServiceMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/pipeline/PipelineServiceMapper.java new file mode 100644 index 000000000000..a6eec765aa4a --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/pipeline/PipelineServiceMapper.java @@ -0,0 +1,14 @@ +package org.openmetadata.service.resources.services.pipeline; + +import org.openmetadata.schema.api.services.CreatePipelineService; +import org.openmetadata.schema.entity.services.PipelineService; +import org.openmetadata.service.mapper.EntityMapper; + +public class PipelineServiceMapper implements EntityMapper { + @Override + public PipelineService createToEntity(CreatePipelineService create, String user) { + return copy(new PipelineService(), create, user) + .withServiceType(create.getServiceType()) + .withConnection(create.getConnection()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/pipeline/PipelineServiceResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/pipeline/PipelineServiceResource.java index b6b5406ce008..1727413224f0 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/pipeline/PipelineServiceResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/pipeline/PipelineServiceResource.java @@ -71,6 +71,7 @@ @Collection(name = "pipelineServices") public class PipelineServiceResource extends ServiceEntityResource { + private final PipelineServiceMapper mapper = new PipelineServiceMapper(); public static final String COLLECTION_PATH = "v1/services/pipelineServices/"; static final String FIELDS = "pipelines,owners,domain"; @@ -352,7 +353,8 @@ public Response create( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreatePipelineService create) { - PipelineService service = getService(create, securityContext.getUserPrincipal().getName()); + PipelineService service = + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); Response response = create(uriInfo, securityContext, service); decryptOrNullify(securityContext, (PipelineService) response.getEntity()); return response; @@ -378,7 +380,8 @@ public Response createOrUpdate( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreatePipelineService update) { - PipelineService service = getService(update, securityContext.getUserPrincipal().getName()); + PipelineService service = + mapper.createToEntity(update, securityContext.getUserPrincipal().getName()); Response response = createOrUpdate(uriInfo, securityContext, unmask(service)); decryptOrNullify(securityContext, (PipelineService) response.getEntity()); return response; @@ -529,13 +532,6 @@ public Response restorePipelineService( return restoreEntity(uriInfo, securityContext, restore.getId()); } - private PipelineService getService(CreatePipelineService create, String user) { - return repository - .copy(new PipelineService(), create, user) - .withServiceType(create.getServiceType()) - .withConnection(create.getConnection()); - } - @Override protected PipelineService nullifyConnection(PipelineService service) { return service.withConnection(null); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/searchIndexes/SearchServiceMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/searchIndexes/SearchServiceMapper.java new file mode 100644 index 000000000000..d4a220908eb1 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/searchIndexes/SearchServiceMapper.java @@ -0,0 +1,14 @@ +package org.openmetadata.service.resources.services.searchIndexes; + +import org.openmetadata.schema.api.services.CreateSearchService; +import org.openmetadata.schema.entity.services.SearchService; +import org.openmetadata.service.mapper.EntityMapper; + +public class SearchServiceMapper implements EntityMapper { + @Override + public SearchService createToEntity(CreateSearchService create, String user) { + return copy(new SearchService(), create, user) + .withServiceType(create.getServiceType()) + .withConnection(create.getConnection()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/searchIndexes/SearchServiceResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/searchIndexes/SearchServiceResource.java index 59584f45fbf1..a1f7c592f4aa 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/searchIndexes/SearchServiceResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/searchIndexes/SearchServiceResource.java @@ -63,6 +63,7 @@ @Collection(name = "searchServices") public class SearchServiceResource extends ServiceEntityResource { + private final SearchServiceMapper mapper = new SearchServiceMapper(); public static final String COLLECTION_PATH = "v1/services/searchServices/"; static final String FIELDS = "pipelines,owners,tags,domain"; @@ -333,7 +334,8 @@ public Response create( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateSearchService create) { - SearchService service = getService(create, securityContext.getUserPrincipal().getName()); + SearchService service = + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); Response response = create(uriInfo, securityContext, service); decryptOrNullify(securityContext, (SearchService) response.getEntity()); return response; @@ -358,7 +360,8 @@ public Response createOrUpdate( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateSearchService update) { - SearchService service = getService(update, securityContext.getUserPrincipal().getName()); + SearchService service = + mapper.createToEntity(update, securityContext.getUserPrincipal().getName()); Response response = createOrUpdate(uriInfo, securityContext, unmask(service)); decryptOrNullify(securityContext, (SearchService) response.getEntity()); return response; @@ -502,13 +505,6 @@ public Response restoreSearchService( return restoreEntity(uriInfo, securityContext, restore.getId()); } - private SearchService getService(CreateSearchService create, String user) { - return repository - .copy(new SearchService(), create, user) - .withServiceType(create.getServiceType()) - .withConnection(create.getConnection()); - } - @Override protected SearchService nullifyConnection(SearchService service) { return service.withConnection(null); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/storage/StorageServiceMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/storage/StorageServiceMapper.java new file mode 100644 index 000000000000..4fb0c4aa02c3 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/storage/StorageServiceMapper.java @@ -0,0 +1,14 @@ +package org.openmetadata.service.resources.services.storage; + +import org.openmetadata.schema.api.services.CreateStorageService; +import org.openmetadata.schema.entity.services.StorageService; +import org.openmetadata.service.mapper.EntityMapper; + +public class StorageServiceMapper implements EntityMapper { + @Override + public StorageService createToEntity(CreateStorageService create, String user) { + return copy(new StorageService(), create, user) + .withServiceType(create.getServiceType()) + .withConnection(create.getConnection()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/storage/StorageServiceResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/storage/StorageServiceResource.java index cfbe55a5ca62..ed5e489099a2 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/storage/StorageServiceResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/services/storage/StorageServiceResource.java @@ -63,6 +63,7 @@ @Collection(name = "storageServices") public class StorageServiceResource extends ServiceEntityResource { + private final StorageServiceMapper mapper = new StorageServiceMapper(); public static final String COLLECTION_PATH = "v1/services/storageServices/"; static final String FIELDS = "pipelines,owners,tags,domain"; @@ -332,7 +333,8 @@ public Response create( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateStorageService create) { - StorageService service = getService(create, securityContext.getUserPrincipal().getName()); + StorageService service = + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); Response response = create(uriInfo, securityContext, service); decryptOrNullify(securityContext, (StorageService) response.getEntity()); return response; @@ -357,7 +359,8 @@ public Response createOrUpdate( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateStorageService update) { - StorageService service = getService(update, securityContext.getUserPrincipal().getName()); + StorageService service = + mapper.createToEntity(update, securityContext.getUserPrincipal().getName()); Response response = createOrUpdate(uriInfo, securityContext, unmask(service)); decryptOrNullify(securityContext, (StorageService) response.getEntity()); return response; @@ -473,13 +476,6 @@ public Response restoreStorageService( return restoreEntity(uriInfo, securityContext, restore.getId()); } - private StorageService getService(CreateStorageService create, String user) { - return repository - .copy(new StorageService(), create, user) - .withServiceType(create.getServiceType()) - .withConnection(create.getConnection()); - } - @Override protected StorageService nullifyConnection(StorageService service) { return service.withConnection(null); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/storages/ContainerMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/storages/ContainerMapper.java new file mode 100644 index 000000000000..e16ea5865565 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/storages/ContainerMapper.java @@ -0,0 +1,25 @@ +package org.openmetadata.service.resources.storages; + +import static org.openmetadata.service.util.EntityUtil.getEntityReference; + +import org.openmetadata.schema.api.data.CreateContainer; +import org.openmetadata.schema.entity.data.Container; +import org.openmetadata.service.Entity; +import org.openmetadata.service.mapper.EntityMapper; + +public class ContainerMapper implements EntityMapper { + @Override + public Container createToEntity(CreateContainer create, String user) { + return copy(new Container(), create, user) + .withService(getEntityReference(Entity.STORAGE_SERVICE, create.getService())) + .withParent(create.getParent()) + .withDataModel(create.getDataModel()) + .withPrefix(create.getPrefix()) + .withNumberOfObjects(create.getNumberOfObjects()) + .withSize(create.getSize()) + .withFullPath(create.getFullPath()) + .withFileFormats(create.getFileFormats()) + .withSourceUrl(create.getSourceUrl()) + .withSourceHash(create.getSourceHash()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/storages/ContainerResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/storages/ContainerResource.java index ae0f3a4edeac..806b18a893aa 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/storages/ContainerResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/storages/ContainerResource.java @@ -59,6 +59,7 @@ @Consumes(MediaType.APPLICATION_JSON) @Collection(name = "containers") public class ContainerResource extends EntityResource { + private final ContainerMapper mapper = new ContainerMapper(); public static final String COLLECTION_PATH = "v1/containers/"; static final String FIELDS = "parent,children,dataModel,owners,tags,followers,extension,domain,sourceHash"; @@ -239,7 +240,8 @@ public Response create( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateContainer create) { - Container container = getContainer(create, securityContext.getUserPrincipal().getName()); + Container container = + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return create(uriInfo, securityContext, container); } @@ -320,7 +322,8 @@ public Response createOrUpdate( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateContainer create) { - Container container = getContainer(create, securityContext.getUserPrincipal().getName()); + Container container = + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return createOrUpdate(uriInfo, securityContext, container); } @@ -543,19 +546,4 @@ public Response restoreContainer( @Valid RestoreEntity restore) { return restoreEntity(uriInfo, securityContext, restore.getId()); } - - private Container getContainer(CreateContainer create, String user) { - return repository - .copy(new Container(), create, user) - .withService(getEntityReference(Entity.STORAGE_SERVICE, create.getService())) - .withParent(create.getParent()) - .withDataModel(create.getDataModel()) - .withPrefix(create.getPrefix()) - .withNumberOfObjects(create.getNumberOfObjects()) - .withSize(create.getSize()) - .withFullPath(create.getFullPath()) - .withFileFormats(create.getFileFormats()) - .withSourceUrl(create.getSourceUrl()) - .withSourceHash(create.getSourceHash()); - } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/tags/ClassificationMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/tags/ClassificationMapper.java new file mode 100644 index 000000000000..b75fe17280d1 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/tags/ClassificationMapper.java @@ -0,0 +1,15 @@ +package org.openmetadata.service.resources.tags; + +import org.openmetadata.schema.api.classification.CreateClassification; +import org.openmetadata.schema.entity.classification.Classification; +import org.openmetadata.service.mapper.EntityMapper; + +public class ClassificationMapper implements EntityMapper { + @Override + public Classification createToEntity(CreateClassification create, String user) { + return copy(new Classification(), create, user) + .withFullyQualifiedName(create.getName()) + .withProvider(create.getProvider()) + .withMutuallyExclusive(create.getMutuallyExclusive()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/tags/ClassificationResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/tags/ClassificationResource.java index 18a60fa1a986..e25160ca878e 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/tags/ClassificationResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/tags/ClassificationResource.java @@ -77,6 +77,7 @@ order = 4) // Initialize before TagResource, Glossary, and GlossaryTerms public class ClassificationResource extends EntityResource { + private final ClassificationMapper mapper = new ClassificationMapper(); public static final String TAG_COLLECTION_PATH = "/v1/classifications/"; static final String FIELDS = "usageCount,termCount"; @@ -302,7 +303,8 @@ public Response create( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateClassification create) { - Classification category = getClassification(create, securityContext); + Classification category = + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return create(uriInfo, securityContext, category); } @@ -315,7 +317,8 @@ public Response createOrUpdate( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateClassification create) { - Classification category = getClassification(create, securityContext); + Classification category = + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return createOrUpdate(uriInfo, securityContext, category); } @@ -447,18 +450,4 @@ public Response restore( @Valid RestoreEntity restore) { return restoreEntity(uriInfo, securityContext, restore.getId()); } - - private Classification getClassification( - CreateClassification create, SecurityContext securityContext) { - return getClassification(repository, create, securityContext.getUserPrincipal().getName()); - } - - public static Classification getClassification( - ClassificationRepository repository, CreateClassification create, String updatedBy) { - return repository - .copy(new Classification(), create, updatedBy) - .withFullyQualifiedName(create.getName()) - .withProvider(create.getProvider()) - .withMutuallyExclusive(create.getMutuallyExclusive()); - } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/tags/TagMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/tags/TagMapper.java new file mode 100644 index 000000000000..e45961dc8b17 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/tags/TagMapper.java @@ -0,0 +1,21 @@ +package org.openmetadata.service.resources.tags; + +import static org.openmetadata.service.Entity.CLASSIFICATION; +import static org.openmetadata.service.Entity.TAG; +import static org.openmetadata.service.util.EntityUtil.getEntityReference; + +import org.openmetadata.schema.api.classification.CreateTag; +import org.openmetadata.schema.entity.classification.Tag; +import org.openmetadata.service.mapper.EntityMapper; + +public class TagMapper implements EntityMapper { + @Override + public Tag createToEntity(CreateTag create, String user) { + return copy(new Tag(), create, user) + .withStyle(create.getStyle()) + .withParent(getEntityReference(TAG, create.getParent())) + .withClassification(getEntityReference(CLASSIFICATION, create.getClassification())) + .withProvider(create.getProvider()) + .withMutuallyExclusive(create.getMutuallyExclusive()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/tags/TagResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/tags/TagResource.java index 1ce89ee5054b..0a0fe3d62c43 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/tags/TagResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/tags/TagResource.java @@ -15,7 +15,6 @@ import static org.openmetadata.service.Entity.ADMIN_USER_NAME; import static org.openmetadata.service.Entity.CLASSIFICATION; -import static org.openmetadata.service.Entity.TAG; import io.swagger.v3.oas.annotations.ExternalDocumentation; import io.swagger.v3.oas.annotations.Operation; @@ -91,6 +90,8 @@ name = "tags", order = 5) // initialize after Classification, and before Glossary and GlossaryTerm public class TagResource extends EntityResource { + private final ClassificationMapper classificationMapper = new ClassificationMapper(); + private final TagMapper mapper = new TagMapper(); public static final String TAG_COLLECTION_PATH = "/v1/tags/"; static final String FIELDS = "children,usageCount"; @@ -119,15 +120,14 @@ public void initialize(OpenMetadataApplicationConfig config) throws IOException CLASSIFICATION, ".*json/data/tags/.*\\.json$", LoadTags.class); for (LoadTags loadTags : loadTagsList) { Classification classification = - ClassificationResource.getClassification( - classificationRepository, loadTags.getCreateClassification(), ADMIN_USER_NAME); + classificationMapper.createToEntity(loadTags.getCreateClassification(), ADMIN_USER_NAME); classificationRepository.initializeEntity(classification); List tagsToCreate = new ArrayList<>(); for (CreateTag createTag : loadTags.getCreateTags()) { createTag.withClassification(classification.getName()); createTag.withProvider(classification.getProvider()); - Tag tag = getTag(createTag, ADMIN_USER_NAME); + Tag tag = mapper.createToEntity(createTag, ADMIN_USER_NAME); repository.setFullyQualifiedName(tag); // FQN required for ordering tags based on hierarchy tagsToCreate.add(tag); } @@ -352,7 +352,7 @@ public Tag getVersion( }) public Response create( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateTag create) { - Tag tag = getTag(securityContext, create); + Tag tag = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return create(uriInfo, securityContext, tag); } @@ -430,7 +430,7 @@ public Response patch( }) public Response createOrUpdate( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateTag create) { - Tag tag = getTag(create, securityContext.getUserPrincipal().getName()); + Tag tag = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return createOrUpdate(uriInfo, securityContext, tag); } @@ -563,18 +563,4 @@ public Tag addHref(UriInfo uriInfo, Tag tag) { Entity.withHref(uriInfo, tag.getParent()); return tag; } - - private Tag getTag(SecurityContext securityContext, CreateTag create) { - return getTag(create, securityContext.getUserPrincipal().getName()); - } - - private Tag getTag(CreateTag create, String updateBy) { - return repository - .copy(new Tag(), create, updateBy) - .withStyle(create.getStyle()) - .withParent(getEntityReference(TAG, create.getParent())) - .withClassification(getEntityReference(CLASSIFICATION, create.getClassification())) - .withProvider(create.getProvider()) - .withMutuallyExclusive(create.getMutuallyExclusive()); - } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/PersonaMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/PersonaMapper.java new file mode 100644 index 000000000000..e33d6f7b441e --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/PersonaMapper.java @@ -0,0 +1,15 @@ +package org.openmetadata.service.resources.teams; + +import org.openmetadata.schema.api.teams.CreatePersona; +import org.openmetadata.schema.entity.teams.Persona; +import org.openmetadata.service.Entity; +import org.openmetadata.service.mapper.EntityMapper; +import org.openmetadata.service.util.EntityUtil; + +public class PersonaMapper implements EntityMapper { + @Override + public Persona createToEntity(CreatePersona create, String user) { + return copy(new Persona(), create, user) + .withUsers(EntityUtil.toEntityReferences(create.getUsers(), Entity.USER)); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/PersonaResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/PersonaResource.java index 7f9db2ee7beb..e084f9131ea5 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/PersonaResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/PersonaResource.java @@ -46,7 +46,6 @@ import org.openmetadata.service.resources.Collection; import org.openmetadata.service.resources.EntityResource; import org.openmetadata.service.security.Authorizer; -import org.openmetadata.service.util.EntityUtil; import org.openmetadata.service.util.ResultList; @Slf4j @@ -60,6 +59,7 @@ @Consumes(MediaType.APPLICATION_JSON) @Collection(name = "personas", order = 2) public class PersonaResource extends EntityResource { + private final PersonaMapper mapper = new PersonaMapper(); public static final String COLLECTION_PATH = "/v1/personas"; static final String FIELDS = "users"; @@ -278,7 +278,7 @@ public Persona getVersion( }) public Response create( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreatePersona cp) { - Persona persona = getPersona(cp, securityContext.getUserPrincipal().getName()); + Persona persona = mapper.createToEntity(cp, securityContext.getUserPrincipal().getName()); return create(uriInfo, securityContext, persona); } @@ -299,7 +299,7 @@ public Response create( }) public Response createOrUpdate( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreatePersona cp) { - Persona persona = getPersona(cp, securityContext.getUserPrincipal().getName()); + Persona persona = mapper.createToEntity(cp, securityContext.getUserPrincipal().getName()); return createOrUpdate(uriInfo, securityContext, persona); } @@ -398,10 +398,4 @@ public Response delete( String name) { return deleteByName(uriInfo, securityContext, name, false, true); } - - private Persona getPersona(CreatePersona cp, String user) { - return repository - .copy(new Persona(), cp, user) - .withUsers(EntityUtil.toEntityReferences(cp.getUsers(), Entity.USER)); - } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/RoleMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/RoleMapper.java new file mode 100644 index 000000000000..3042da9e5226 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/RoleMapper.java @@ -0,0 +1,20 @@ +package org.openmetadata.service.resources.teams; + +import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; +import static org.openmetadata.service.util.EntityUtil.getEntityReferences; + +import org.openmetadata.schema.api.teams.CreateRole; +import org.openmetadata.schema.entity.teams.Role; +import org.openmetadata.service.Entity; +import org.openmetadata.service.mapper.EntityMapper; + +public class RoleMapper implements EntityMapper { + @Override + public Role createToEntity(CreateRole create, String user) { + if (nullOrEmpty(create.getPolicies())) { + throw new IllegalArgumentException("At least one policy is required to create a role"); + } + return copy(new Role(), create, user) + .withPolicies(getEntityReferences(Entity.POLICY, create.getPolicies())); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/RoleResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/RoleResource.java index 9b5631c01707..aa3f2f519af6 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/RoleResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/RoleResource.java @@ -13,8 +13,6 @@ package org.openmetadata.service.resources.teams; -import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; - import io.dropwizard.jersey.PATCH; import io.swagger.v3.oas.annotations.ExternalDocumentation; import io.swagger.v3.oas.annotations.Operation; @@ -81,6 +79,7 @@ requiredForOps = true) // Load roles after PolicyResource are loaded at Order 0 @Slf4j public class RoleResource extends EntityResource { + private final RoleMapper mapper = new RoleMapper(); public static final String COLLECTION_PATH = "/v1/roles/"; public static final String FIELDS = "policies,teams,users"; @@ -335,7 +334,7 @@ public Response create( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateRole createRole) { - Role role = getRole(createRole, securityContext.getUserPrincipal().getName()); + Role role = mapper.createToEntity(createRole, securityContext.getUserPrincipal().getName()); return create(uriInfo, securityContext, role); } @@ -358,7 +357,7 @@ public Response createOrUpdateRole( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateRole createRole) { - Role role = getRole(createRole, securityContext.getUserPrincipal().getName()); + Role role = mapper.createToEntity(createRole, securityContext.getUserPrincipal().getName()); return createOrUpdate(uriInfo, securityContext, role); } @@ -487,18 +486,4 @@ public Response restoreRole( @Valid RestoreEntity restore) { return restoreEntity(uriInfo, securityContext, restore.getId()); } - - private Role getRole(CreateRole create, String user) { - if (nullOrEmpty(create.getPolicies())) { - throw new IllegalArgumentException("At least one policy is required to create a role"); - } - return repository - .copy(new Role(), create, user) - .withPolicies(getEntityReferences(Entity.POLICY, create.getPolicies())); - } - - public static EntityReference getRole(String roleName) { - RoleRepository roleRepository = (RoleRepository) Entity.getEntityRepository(Entity.ROLE); - return roleRepository.getReferenceByName(roleName, Include.NON_DELETED); - } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/TeamMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/TeamMapper.java new file mode 100644 index 000000000000..6118c5ade5d7 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/TeamMapper.java @@ -0,0 +1,33 @@ +package org.openmetadata.service.resources.teams; + +import static org.openmetadata.service.exception.CatalogExceptionMessage.CREATE_GROUP; +import static org.openmetadata.service.exception.CatalogExceptionMessage.CREATE_ORGANIZATION; + +import org.openmetadata.schema.api.teams.CreateTeam; +import org.openmetadata.schema.entity.teams.Team; +import org.openmetadata.service.Entity; +import org.openmetadata.service.mapper.EntityMapper; +import org.openmetadata.service.util.EntityUtil; + +public class TeamMapper implements EntityMapper { + @Override + public Team createToEntity(CreateTeam create, String user) { + if (create.getTeamType().equals(CreateTeam.TeamType.ORGANIZATION)) { + throw new IllegalArgumentException(CREATE_ORGANIZATION); + } + if (create.getTeamType().equals(CreateTeam.TeamType.GROUP) && create.getChildren() != null) { + throw new IllegalArgumentException(CREATE_GROUP); + } + return copy(new Team(), create, user) + .withProfile(create.getProfile()) + .withIsJoinable(create.getIsJoinable()) + .withUsers(EntityUtil.toEntityReferences(create.getUsers(), Entity.USER)) + .withDefaultRoles(EntityUtil.toEntityReferences(create.getDefaultRoles(), Entity.ROLE)) + .withTeamType(create.getTeamType()) + .withParents(EntityUtil.toEntityReferences(create.getParents(), Entity.TEAM)) + .withChildren(EntityUtil.toEntityReferences(create.getChildren(), Entity.TEAM)) + .withPolicies(EntityUtil.toEntityReferences(create.getPolicies(), Entity.POLICY)) + .withEmail(create.getEmail()) + .withDomains(EntityUtil.getEntityReferences(Entity.DOMAIN, create.getDomains())); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/TeamResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/TeamResource.java index 6f0070dff28a..e3374cbca785 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/TeamResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/TeamResource.java @@ -14,8 +14,6 @@ package org.openmetadata.service.resources.teams; import static org.openmetadata.common.utils.CommonUtil.listOf; -import static org.openmetadata.service.exception.CatalogExceptionMessage.CREATE_GROUP; -import static org.openmetadata.service.exception.CatalogExceptionMessage.CREATE_ORGANIZATION; import io.dropwizard.jersey.PATCH; import io.swagger.v3.oas.annotations.ExternalDocumentation; @@ -52,7 +50,6 @@ import lombok.extern.slf4j.Slf4j; import org.openmetadata.schema.api.data.RestoreEntity; import org.openmetadata.schema.api.teams.CreateTeam; -import org.openmetadata.schema.api.teams.CreateTeam.TeamType; import org.openmetadata.schema.entity.teams.Team; import org.openmetadata.schema.entity.teams.TeamHierarchy; import org.openmetadata.schema.type.ChangeEvent; @@ -73,7 +70,6 @@ import org.openmetadata.service.resources.EntityResource; import org.openmetadata.service.security.Authorizer; import org.openmetadata.service.util.CSVExportResponse; -import org.openmetadata.service.util.EntityUtil; import org.openmetadata.service.util.JsonUtils; import org.openmetadata.service.util.ResultList; @@ -92,6 +88,7 @@ requiredForOps = true) // Load after roles, and policy resources public class TeamResource extends EntityResource { public static final String COLLECTION_PATH = "/v1/teams/"; + private final TeamMapper mapper = new TeamMapper(); static final String FIELDS = "owners,profile,users,owns,defaultRoles,parents,children,policies,userCount,childrenCount,domains"; @@ -378,7 +375,7 @@ public Team getVersion( }) public Response create( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateTeam ct) { - Team team = getTeam(ct, securityContext.getUserPrincipal().getName()); + Team team = mapper.createToEntity(ct, securityContext.getUserPrincipal().getName()); return create(uriInfo, securityContext, team); } @@ -399,7 +396,7 @@ public Response create( }) public Response createOrUpdate( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateTeam ct) { - Team team = getTeam(ct, securityContext.getUserPrincipal().getName()); + Team team = mapper.createToEntity(ct, securityContext.getUserPrincipal().getName()); return createOrUpdate(uriInfo, securityContext, team); } @@ -752,25 +749,4 @@ public Response importCsvAsync( String csv) { return importCsvInternalAsync(securityContext, name, csv, dryRun); } - - private Team getTeam(CreateTeam ct, String user) { - if (ct.getTeamType().equals(TeamType.ORGANIZATION)) { - throw new IllegalArgumentException(CREATE_ORGANIZATION); - } - if (ct.getTeamType().equals(TeamType.GROUP) && ct.getChildren() != null) { - throw new IllegalArgumentException(CREATE_GROUP); - } - return repository - .copy(new Team(), ct, user) - .withProfile(ct.getProfile()) - .withIsJoinable(ct.getIsJoinable()) - .withUsers(EntityUtil.toEntityReferences(ct.getUsers(), Entity.USER)) - .withDefaultRoles(EntityUtil.toEntityReferences(ct.getDefaultRoles(), Entity.ROLE)) - .withTeamType(ct.getTeamType()) - .withParents(EntityUtil.toEntityReferences(ct.getParents(), Entity.TEAM)) - .withChildren(EntityUtil.toEntityReferences(ct.getChildren(), Entity.TEAM)) - .withPolicies(EntityUtil.toEntityReferences(ct.getPolicies(), Entity.POLICY)) - .withEmail(ct.getEmail()) - .withDomains(EntityUtil.getEntityReferences(Entity.DOMAIN, ct.getDomains())); - } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/UserMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/UserMapper.java new file mode 100644 index 000000000000..10ce880b7dc9 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/UserMapper.java @@ -0,0 +1,33 @@ +package org.openmetadata.service.resources.teams; + +import java.util.UUID; +import org.openmetadata.schema.api.teams.CreateUser; +import org.openmetadata.schema.entity.teams.User; +import org.openmetadata.schema.utils.EntityInterfaceUtil; +import org.openmetadata.service.Entity; +import org.openmetadata.service.mapper.EntityMapper; +import org.openmetadata.service.util.EntityUtil; + +public class UserMapper implements EntityMapper { + @Override + public User createToEntity(CreateUser create, String user) { + return new User() + .withId(UUID.randomUUID()) + .withName(create.getName().toLowerCase()) + .withFullyQualifiedName(EntityInterfaceUtil.quoteName(create.getName().toLowerCase())) + .withEmail(create.getEmail().toLowerCase()) + .withDescription(create.getDescription()) + .withDisplayName(create.getDisplayName()) + .withIsBot(create.getIsBot()) + .withIsAdmin(create.getIsAdmin()) + .withProfile(create.getProfile()) + .withPersonas(create.getPersonas()) + .withDefaultPersona(create.getDefaultPersona()) + .withTimezone(create.getTimezone()) + .withUpdatedBy(user.toLowerCase()) + .withUpdatedAt(System.currentTimeMillis()) + .withTeams(EntityUtil.toEntityReferences(create.getTeams(), Entity.TEAM)) + .withRoles(EntityUtil.toEntityReferences(create.getRoles(), Entity.ROLE)) + .withDomains(EntityUtil.getEntityReferences(Entity.DOMAIN, create.getDomains())); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/topics/TopicMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/topics/TopicMapper.java new file mode 100644 index 000000000000..470bfdb82840 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/topics/TopicMapper.java @@ -0,0 +1,27 @@ +package org.openmetadata.service.resources.topics; + +import static org.openmetadata.service.util.EntityUtil.getEntityReference; + +import org.openmetadata.schema.api.data.CreateTopic; +import org.openmetadata.schema.entity.data.Topic; +import org.openmetadata.service.Entity; +import org.openmetadata.service.mapper.EntityMapper; + +public class TopicMapper implements EntityMapper { + @Override + public Topic createToEntity(CreateTopic create, String user) { + return copy(new Topic(), create, user) + .withService(getEntityReference(Entity.MESSAGING_SERVICE, create.getService())) + .withPartitions(create.getPartitions()) + .withMessageSchema(create.getMessageSchema()) + .withCleanupPolicies(create.getCleanupPolicies()) + .withMaximumMessageSize(create.getMaximumMessageSize()) + .withMinimumInSyncReplicas(create.getMinimumInSyncReplicas()) + .withRetentionSize(create.getRetentionSize()) + .withRetentionTime(create.getRetentionTime()) + .withReplicationFactor(create.getReplicationFactor()) + .withTopicConfig(create.getTopicConfig()) + .withSourceUrl(create.getSourceUrl()) + .withSourceHash(create.getSourceHash()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/topics/TopicResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/topics/TopicResource.java index a5a2916a34bf..8a7f5b46ae35 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/topics/TopicResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/topics/TopicResource.java @@ -77,6 +77,7 @@ @Collection(name = "topics") public class TopicResource extends EntityResource { public static final String COLLECTION_PATH = "v1/topics/"; + private final TopicMapper mapper = new TopicMapper(); static final String FIELDS = "owners,followers,tags,extension,domain,dataProducts,sourceHash"; @Override @@ -302,7 +303,7 @@ public Response create( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateTopic create) { - Topic topic = getTopic(create, securityContext.getUserPrincipal().getName()); + Topic topic = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return create(uriInfo, securityContext, topic); } @@ -381,7 +382,7 @@ public Response createOrUpdate( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateTopic create) { - Topic topic = getTopic(create, securityContext.getUserPrincipal().getName()); + Topic topic = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return createOrUpdate(uriInfo, securityContext, topic); } @@ -597,21 +598,4 @@ public Response restoreTopic( @Valid RestoreEntity restore) { return restoreEntity(uriInfo, securityContext, restore.getId()); } - - private Topic getTopic(CreateTopic create, String user) { - return repository - .copy(new Topic(), create, user) - .withService(getEntityReference(Entity.MESSAGING_SERVICE, create.getService())) - .withPartitions(create.getPartitions()) - .withMessageSchema(create.getMessageSchema()) - .withCleanupPolicies(create.getCleanupPolicies()) - .withMaximumMessageSize(create.getMaximumMessageSize()) - .withMinimumInSyncReplicas(create.getMinimumInSyncReplicas()) - .withRetentionSize(create.getRetentionSize()) - .withRetentionTime(create.getRetentionTime()) - .withReplicationFactor(create.getReplicationFactor()) - .withTopicConfig(create.getTopicConfig()) - .withSourceUrl(create.getSourceUrl()) - .withSourceHash(create.getSourceHash()); - } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/types/TypeMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/types/TypeMapper.java new file mode 100644 index 000000000000..e8b21b942f5c --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/types/TypeMapper.java @@ -0,0 +1,15 @@ +package org.openmetadata.service.resources.types; + +import org.openmetadata.schema.api.CreateType; +import org.openmetadata.schema.entity.Type; +import org.openmetadata.service.mapper.EntityMapper; + +public class TypeMapper implements EntityMapper { + @Override + public Type createToEntity(CreateType create, String user) { + return copy(new Type(), create, user) + .withFullyQualifiedName(create.getName()) + .withCategory(create.getCategory()) + .withSchema(create.getSchema()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/types/TypeResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/types/TypeResource.java index 65c9b2a34364..dc90db9aea29 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/types/TypeResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/types/TypeResource.java @@ -84,6 +84,7 @@ @Slf4j public class TypeResource extends EntityResource { public static final String COLLECTION_PATH = "v1/metadata/types/"; + private final TypeMapper mapper = new TypeMapper(); public SchemaFieldExtractor extractor; @Override @@ -324,7 +325,7 @@ public Response create( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateType create) { - Type type = getType(create, securityContext.getUserPrincipal().getName()); + Type type = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return create(uriInfo, securityContext, type); } @@ -403,7 +404,7 @@ public Response createOrUpdate( @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateType create) { - Type type = getType(create, securityContext.getUserPrincipal().getName()); + Type type = mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); return createOrUpdate(uriInfo, securityContext, type); } @@ -520,12 +521,4 @@ public Response getAllCustomPropertiesByEntityType( .build(); } } - - private Type getType(CreateType create, String user) { - return repository - .copy(new Type(), create, user) - .withFullyQualifiedName(create.getName()) - .withCategory(create.getCategory()) - .withSchema(create.getSchema()); - } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/Authorizer.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/Authorizer.java index 26dbe0babfde..f94193c0d817 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/Authorizer.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/Authorizer.java @@ -43,6 +43,8 @@ void authorize( void authorizeAdmin(SecurityContext securityContext); + void authorizeAdmin(String adminName); + void authorizeAdminOrBot(SecurityContext securityContext); boolean shouldMaskPasswords(SecurityContext securityContext); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/DefaultAuthorizer.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/DefaultAuthorizer.java index 51b0e32ca80e..a8636b53ad44 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/DefaultAuthorizer.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/DefaultAuthorizer.java @@ -101,6 +101,15 @@ public void authorizeAdmin(SecurityContext securityContext) { throw new AuthorizationException(notAdmin(securityContext.getUserPrincipal().getName())); } + @Override + public void authorizeAdmin(String adminName) { + SubjectContext subjectContext = SubjectContext.getSubjectContext(adminName); + if (subjectContext.isAdmin()) { + return; + } + throw new AuthorizationException(notAdmin(adminName)); + } + @Override public void authorizeAdminOrBot(SecurityContext securityContext) { SubjectContext subjectContext = getSubjectContext(securityContext); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/NoopAuthorizer.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/NoopAuthorizer.java index 7b9ed7ee6bd7..355bfe809905 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/NoopAuthorizer.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/NoopAuthorizer.java @@ -96,6 +96,11 @@ public void authorizeAdmin(SecurityContext securityContext) { /* Always authorize */ } + @Override + public void authorizeAdmin(String adminName) { + /* Always authorize */ + } + @Override public void authorizeAdminOrBot(SecurityContext securityContext) { /* Always authorize */ diff --git a/openmetadata-spec/src/main/resources/json/schema/api/tests/createTestCaseResult.json b/openmetadata-spec/src/main/resources/json/schema/api/tests/createTestCaseResult.json index 5e008a5e091d..d064bbbe6fde 100644 --- a/openmetadata-spec/src/main/resources/json/schema/api/tests/createTestCaseResult.json +++ b/openmetadata-spec/src/main/resources/json/schema/api/tests/createTestCaseResult.json @@ -6,6 +6,10 @@ "javaType": "org.openmetadata.schema.api.tests.CreateTestCaseResult", "type": "object", "properties": { + "fqn": { + "description": "Fqn of the test case against which this test case result is added.", + "type": "string" + }, "timestamp": { "description": "Data one which test case result is taken.", "$ref": "../../type/basic.json#/definitions/timestamp" diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/tests/createTestCaseResult.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/tests/createTestCaseResult.ts index 80dae91b90fc..9dc61fd339a7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/api/tests/createTestCaseResult.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/tests/createTestCaseResult.ts @@ -1,5 +1,5 @@ /* - * Copyright 2024 Collate. + * Copyright 2025 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 @@ -10,9 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - - - /** +/** * Schema to create a new test case result . */ export interface CreateTestCaseResult { @@ -24,6 +22,10 @@ export interface CreateTestCaseResult { * Percentage of rows that failed. */ failedRowsPercentage?: number; + /** + * Fqn of the test case against which this test case result is added. + */ + fqn?: string; /** * Incident State ID associated with this result. This association happens when the result * is created, and will stay there even when the incident is resolved. From 3e88f1ca300a6f2f29e622fda489b9b428bddb99 Mon Sep 17 00:00:00 2001 From: Mohit Yadav <105265192+mohityadav766@users.noreply.github.com> Date: Fri, 3 Jan 2025 13:49:34 +0530 Subject: [PATCH 081/294] Onboarding Application Changes for OSS (#19203) (cherry picked from commit d60327c448a0ac0e14c11ad4976bb405437fe4c9) --- .../service/jdbi3/EntityRepository.java | 15 +++++++++++++++ .../service/jdbi3/MlModelRepository.java | 6 +++--- .../service/jdbi3/PipelineRepository.java | 4 +--- .../resources/pipelines/PipelineResource.java | 2 +- .../openmetadata/service/util/JsonUtils.java | 19 +++++++++++++++++++ 5 files changed, 39 insertions(+), 7 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java index b86eca1e1fe3..d35e979b706b 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java @@ -1155,6 +1155,21 @@ public final DeleteResponse delete( return response; } + @SuppressWarnings("unused") + @Transaction + public final DeleteResponse deleteByNameIfExists( + String updatedBy, String name, boolean recursive, boolean hardDelete) { + name = quoteFqn ? quoteName(name) : name; + T entity = findByNameOrNull(name, ALL); + if (entity != null) { + DeleteResponse response = deleteInternalByName(updatedBy, name, recursive, hardDelete); + postDelete(response.entity()); + return response; + } else { + return new DeleteResponse<>(null, ENTITY_DELETED); + } + } + @Transaction public final DeleteResponse deleteByName( String updatedBy, String name, boolean recursive, boolean hardDelete) { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/MlModelRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/MlModelRepository.java index e2b582ecb7c8..2937030e0e63 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/MlModelRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/MlModelRepository.java @@ -17,6 +17,7 @@ import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; import static org.openmetadata.service.Entity.DASHBOARD; import static org.openmetadata.service.Entity.MLMODEL; +import static org.openmetadata.service.Entity.getEntityReference; import static org.openmetadata.service.resources.tags.TagLabelUtil.checkMutuallyExclusive; import static org.openmetadata.service.util.EntityUtil.entityReferenceMatch; import static org.openmetadata.service.util.EntityUtil.mlFeatureMatch; @@ -149,8 +150,7 @@ private void validateReferences(List mlFeatures) { private void validateMlDataSource(MlFeatureSource source) { if (source.getDataSource() != null) { - Entity.getEntityReferenceById( - source.getDataSource().getType(), source.getDataSource().getId(), Include.NON_DELETED); + Entity.getEntityReference(source.getDataSource(), Include.NON_DELETED); } } @@ -211,7 +211,7 @@ private void setMlFeatureSourcesLineage(MlModel mlModel) { .getFeatureSources() .forEach( mlFeatureSource -> { - EntityReference targetEntity = mlFeatureSource.getDataSource(); + EntityReference targetEntity = getEntityReference(mlFeatureSource.getDataSource(), Include.ALL); if (targetEntity != null) { addRelationship( targetEntity.getId(), diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/PipelineRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/PipelineRepository.java index bab495ad27d0..2b81433ac352 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/PipelineRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/PipelineRepository.java @@ -29,7 +29,6 @@ import java.util.ArrayList; import java.util.List; import javax.ws.rs.core.Response; -import javax.ws.rs.core.UriInfo; import org.apache.commons.lang3.tuple.Triple; import org.jdbi.v3.sqlobject.transaction.Transaction; import org.openmetadata.schema.EntityInterface; @@ -173,8 +172,7 @@ private PipelineStatus getPipelineStatus(Pipeline pipeline) { PipelineStatus.class); } - public RestUtil.PutResponse addPipelineStatus( - UriInfo uriInfo, String fqn, PipelineStatus pipelineStatus) { + public RestUtil.PutResponse addPipelineStatus(String fqn, PipelineStatus pipelineStatus) { // Validate the request content Pipeline pipeline = daoCollection.pipelineDAO().findEntityByName(fqn); pipeline.setService(getContainer(pipeline.getId())); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/pipelines/PipelineResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/pipelines/PipelineResource.java index 210a270975e6..d0e97492496d 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/pipelines/PipelineResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/pipelines/PipelineResource.java @@ -425,7 +425,7 @@ public Response addPipelineStatus( OperationContext operationContext = new OperationContext(entityType, MetadataOperation.EDIT_STATUS); authorizer.authorize(securityContext, operationContext, getResourceContextByName(fqn)); - return repository.addPipelineStatus(uriInfo, fqn, pipelineStatus).toResponse(); + return repository.addPipelineStatus(fqn, pipelineStatus).toResponse(); } @GET diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/util/JsonUtils.java b/openmetadata-service/src/main/java/org/openmetadata/service/util/JsonUtils.java index f4b1ab79d842..e0524586d1f1 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/util/JsonUtils.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/util/JsonUtils.java @@ -39,6 +39,7 @@ import java.nio.file.Paths; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; @@ -640,6 +641,24 @@ public static JsonNode pojoToJsonNode(Object obj) { } } + @SuppressWarnings("unused") + public static Map getMapFromJson(String json) { + return (Map) (JsonUtils.readValue(json, Map.class)); + } + + @SuppressWarnings("unused") + public static T convertObjectWithFilteredFields( + Object input, Set fields, Class clazz) { + Map inputMap = JsonUtils.getMap(input); + Map result = new HashMap<>(); + for (String field : fields) { + if (inputMap.containsKey(field)) { + result.put(field, inputMap.get(field)); + } + } + return JsonUtils.convertValue(result, clazz); + } + public static JsonPatch convertFgeToJavax(com.github.fge.jsonpatch.JsonPatch fgeJsonPatch) { String jsonString = fgeJsonPatch.toString(); From dccd151ffef861ab442de019590d402d682a705d Mon Sep 17 00:00:00 2001 From: Sweta Agarwalla <105535990+sweta1308@users.noreply.github.com> Date: Tue, 7 Jan 2025 20:51:33 +0530 Subject: [PATCH 082/294] MINOR: Add condition for welcome page (#19266) * add condition for welcome page * create mock for useWelcomeStore --------- Co-authored-by: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> (cherry picked from commit 29b48bec51a83ac88ec067090697afe62a03ddd8) --- .../src/main/resources/ui/src/App.tsx | 3 +++ .../resources/ui/src/hooks/useWelcomeStore.ts | 25 +++++++++++++++++++ .../pages/MyDataPage/MyDataPage.component.tsx | 4 ++- .../src/pages/MyDataPage/MyDataPage.test.tsx | 6 +++++ 4 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/hooks/useWelcomeStore.ts diff --git a/openmetadata-ui/src/main/resources/ui/src/App.tsx b/openmetadata-ui/src/main/resources/ui/src/App.tsx index cbcc1eaef496..a511d1c40041 100644 --- a/openmetadata-ui/src/main/resources/ui/src/App.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/App.tsx @@ -30,6 +30,7 @@ import PermissionProvider from './context/PermissionProvider/PermissionProvider' import TourProvider from './context/TourProvider/TourProvider'; import WebSocketProvider from './context/WebSocketProvider/WebSocketProvider'; import { useApplicationStore } from './hooks/useApplicationStore'; +import { useWelcomeStore } from './hooks/useWelcomeStore'; import { getCustomUiThemePreference } from './rest/settingConfigAPI'; import { history } from './utils/HistoryUtils'; import i18n from './utils/i18next/LocalUtil'; @@ -37,6 +38,7 @@ import { getThemeConfig } from './utils/ThemeUtils'; const App: FC = () => { const { applicationConfig, setApplicationConfig } = useApplicationStore(); + const { setIsWelcomeVisible } = useWelcomeStore(); const fetchApplicationConfig = async () => { try { @@ -54,6 +56,7 @@ const App: FC = () => { useEffect(() => { fetchApplicationConfig(); + setIsWelcomeVisible(true); }, []); useEffect(() => { diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useWelcomeStore.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useWelcomeStore.ts new file mode 100644 index 000000000000..e2d0ffca19fb --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useWelcomeStore.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2025 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 { create } from 'zustand'; + +interface WelcomeStore { + isWelcomeVisible: boolean; + setIsWelcomeVisible: (isVisible: boolean) => void; +} + +export const useWelcomeStore = create()((set) => ({ + isWelcomeVisible: false, + setIsWelcomeVisible: (isVisible: boolean) => { + set({ isWelcomeVisible: isVisible }); + }, +})); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx index 96e541a15516..a5ea133dac6b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx @@ -38,6 +38,7 @@ import { EntityReference } from '../../generated/type/entityReference'; import LimitWrapper from '../../hoc/LimitWrapper'; import { useApplicationStore } from '../../hooks/useApplicationStore'; import { useGridLayoutDirection } from '../../hooks/useGridLayoutDirection'; +import { useWelcomeStore } from '../../hooks/useWelcomeStore'; import { getDocumentByFQN } from '../../rest/DocStoreAPI'; import { getActiveAnnouncement } from '../../rest/feedsAPI'; import { searchQuery } from '../../rest/searchAPI'; @@ -52,6 +53,7 @@ const ReactGridLayout = WidthProvider(RGL); const MyDataPage = () => { const { t } = useTranslation(); const { currentUser, selectedPersona } = useApplicationStore(); + const { isWelcomeVisible } = useWelcomeStore(); const [followedData, setFollowedData] = useState>([]); const [followedDataCount, setFollowedDataCount] = useState(0); const [isLoadingOwnedData, setIsLoadingOwnedData] = useState(false); @@ -108,7 +110,7 @@ const MyDataPage = () => { useEffect(() => { isMounted.current = true; - updateWelcomeScreen(!usernameExistsInCookie); + updateWelcomeScreen(!usernameExistsInCookie && isWelcomeVisible); return () => updateWelcomeScreen(false); }, []); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.test.tsx index 7317f5603869..654524e93cd2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.test.tsx @@ -145,6 +145,12 @@ jest.mock('../DataInsightPage/DataInsightProvider', async () => { return jest.fn().mockImplementation(({ children }) => <>{children}); }); +jest.mock('../../hooks/useWelcomeStore', () => ({ + useWelcomeStore: jest.fn().mockReturnValue({ + isWelcomeVisible: true, + }), +})); + jest.mock('../DataInsightPage/DataInsightProvider', () => { return { __esModule: true, From 4eb6d7a848576ab803dd19d97fb4d4d2a36328fb Mon Sep 17 00:00:00 2001 From: Teddy Date: Wed, 8 Jan 2025 08:20:11 +0100 Subject: [PATCH 083/294] ISSUE-19131: Fix patch removing testSuites field (#19260) * fix: patch removing testSuites field * fix: revert comment update (cherry picked from commit e432af9fb5fd74a6c00fbfd7055feaf0324155d8) --- .../java/org/openmetadata/service/jdbi3/TestCaseRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseRepository.java index e986c2381651..c1015ddf9409 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseRepository.java @@ -75,7 +75,7 @@ public class TestCaseRepository extends EntityRepository { private static final String UPDATE_FIELDS = "owners,entityLink,testSuite,testSuites,testDefinition"; private static final String PATCH_FIELDS = - "owners,entityLink,testSuite,testDefinition,computePassedFailedRowCount,useDynamicAssertion"; + "owners,entityLink,testSuite,testSuites,testDefinition,computePassedFailedRowCount,useDynamicAssertion"; public static final String FAILED_ROWS_SAMPLE_EXTENSION = "testCase.failedRowsSample"; public TestCaseRepository() { From 8ba02f50376e133a98889092cd529f97943a89b5 Mon Sep 17 00:00:00 2001 From: Mayur Singal <39544459+ulixius9@users.noreply.github.com> Date: Wed, 8 Jan 2025 14:42:24 +0530 Subject: [PATCH 084/294] MINOR: Kafka dependency conflict resolution (#19278) (cherry picked from commit 8fdaea805f1148950b7315d80d6294783f7ff96d) --- ingestion/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ingestion/setup.py b/ingestion/setup.py index 7e8b015d6574..51a48a55666f 100644 --- a/ingestion/setup.py +++ b/ingestion/setup.py @@ -76,7 +76,7 @@ }, "kafka": { VERSIONS["avro"], - "confluent_kafka>=2.1.1", + "confluent_kafka>=2.1.1,<=2.6.1", "fastavro>=1.2.0", # Due to https://github.com/grpc/grpc/issues/30843#issuecomment-1303816925 # use >= v1.47.2 https://github.com/grpc/grpc/blob/v1.47.2/tools/distrib/python/grpcio_tools/grpc_version.py#L17 From 2ef7192b6781b1a4a9ff4943a23d297c75457787 Mon Sep 17 00:00:00 2001 From: harshsoni2024 <64592571+harshsoni2024@users.noreply.github.com> Date: Wed, 8 Jan 2025 15:10:59 +0530 Subject: [PATCH 085/294] Redshift stored procedure query parse fix (#19276) --- .../src/metadata/ingestion/source/database/redshift/queries.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ingestion/src/metadata/ingestion/source/database/redshift/queries.py b/ingestion/src/metadata/ingestion/source/database/redshift/queries.py index 50ef72085cf3..bc840ff1b00d 100644 --- a/ingestion/src/metadata/ingestion/source/database/redshift/queries.py +++ b/ingestion/src/metadata/ingestion/source/database/redshift/queries.py @@ -328,7 +328,7 @@ pid as query_session_id, starttime as query_start_time, endtime as query_end_time, - b.usename as query_user_name + cast(b.usename as varchar) as query_user_name from STL_QUERY q join pg_catalog.pg_user b on b.usesysid = q.userid From 6c1a8dad8219b6f097dcdea8e4041f549f1119e4 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Wed, 8 Jan 2025 01:38:08 -0800 Subject: [PATCH 086/294] Issue-19197: Deleting Data Product should delete the data asset relationships (#19208) * Issue-19197: Deleting Data Product should delete the data asset relationships * Issue-19197: Deleting Data Product should delete the data asset relationships * Minor: Fix the deleteByName * add tests for data product delete case * fix flaky tests * update data products fqn keyword mapping * Update table_index_mapping.json * fix flaky test * fix tests * fix flaky tests * fix flakiness --------- Co-authored-by: karanh37 Co-authored-by: Karan Hotchandani <33024356+karanh37@users.noreply.github.com> (cherry picked from commit 490ebdebc3dc8684ea419c88fa2b3b14e69662bd) --- .../service/jdbi3/EntityRepository.java | 10 +- .../service/resources/EntityResource.java | 2 - .../resources/dqtests/TestSuiteResource.java | 2 +- .../service/search/SearchClient.java | 3 + .../service/search/SearchRepository.java | 8 + .../elasticsearch/ElasticSearchClient.java | 2 +- .../en/api_collection_index_mapping.json | 2 +- .../en/api_endpoint_index_mapping.json | 2 +- .../en/api_service_index_mapping.json | 2 +- .../elasticsearch/en/chart_index_mapping.json | 2 +- .../en/container_index_mapping.json | 2 +- .../dashboard_data_model_index_mapping.json | 2 +- .../en/dashboard_index_mapping.json | 2 +- .../en/dashboard_service_index_mapping.json | 2 +- .../en/database_index_mapping.json | 2 +- .../en/database_schema_index_mapping.json | 2 +- .../en/database_service_index_mapping.json | 2 +- .../en/messaging_service_index_mapping.json | 2 +- .../en/mlmodel_index_mapping.json | 2 +- .../en/mlmodel_service_index_mapping.json | 2 +- .../en/pipeline_index_mapping.json | 2 +- .../en/pipeline_service_index_mapping.json | 2 +- .../en/search_entity_index_mapping.json | 2 +- .../en/search_service_index_mapping.json | 2 +- .../en/storage_service_index_mapping.json | 2 +- .../en/stored_procedure_index_mapping.json | 2 +- .../elasticsearch/en/table_index_mapping.json | 2 +- .../elasticsearch/en/topic_index_mapping.json | 2 +- .../jp/api_collection_index_mapping.json | 2 +- .../jp/api_endpoint_index_mapping.json | 2 +- .../jp/api_service_index_mapping.json | 2 +- .../elasticsearch/jp/chart_index_mapping.json | 2 +- .../jp/container_index_mapping.json | 2 +- .../dashboard_data_model_index_mapping.json | 2 +- .../jp/dashboard_index_mapping.json | 2 +- .../jp/dashboard_service_index_mapping.json | 2 +- .../jp/database_index_mapping.json | 2 +- .../jp/database_schema_index_mapping.json | 2 +- .../jp/database_service_index_mapping.json | 2 +- .../jp/messaging_service_index_mapping.json | 2 +- .../jp/mlmodel_index_mapping.json | 2 +- .../jp/mlmodel_service_index_mapping.json | 2 +- .../jp/pipeline_index_mapping.json | 2 +- .../jp/pipeline_service_index_mapping.json | 2 +- .../jp/search_entity_index_mapping.json | 2 +- .../jp/search_service_index_mapping.json | 2 +- .../jp/storage_service_index_mapping.json | 2 +- .../jp/stored_procedure_index_mapping.json | 2 +- .../elasticsearch/jp/table_index_mapping.json | 2 +- .../elasticsearch/jp/topic_index_mapping.json | 2 +- .../zh/api_collection_index_mapping.json | 2 +- .../zh/api_endpoint_index_mapping.json | 2 +- .../zh/api_service_index_mapping.json | 2 +- .../elasticsearch/zh/chart_index_mapping.json | 2 +- .../zh/container_index_mapping.json | 2 +- .../dashboard_data_model_index_mapping.json | 2 +- .../zh/dashboard_index_mapping.json | 2 +- .../zh/dashboard_service_index_mapping.json | 2 +- .../zh/database_index_mapping.json | 2 +- .../zh/database_schema_index_mapping.json | 2 +- .../zh/database_service_index_mapping.json | 2 +- .../zh/messaging_service_index_mapping.json | 2 +- .../zh/mlmodel_index_mapping.json | 2 +- .../zh/mlmodel_service_index_mapping.json | 2 +- .../zh/pipeline_index_mapping.json | 2 +- .../zh/pipeline_service_index_mapping.json | 2 +- .../zh/search_entity_index_mapping.json | 2 +- .../zh/search_service_index_mapping.json | 2 +- .../zh/storage_service_index_mapping.json | 2 +- .../zh/stored_procedure_index_mapping.json | 2 +- .../elasticsearch/zh/table_index_mapping.json | 2 +- .../elasticsearch/zh/topic_index_mapping.json | 2 +- .../ui/playwright/e2e/Pages/Domains.spec.ts | 82 ++++++++- .../playwright/support/domain/DataProduct.ts | 6 +- .../resources/ui/playwright/utils/domain.ts | 164 +++++++++++++++--- 75 files changed, 307 insertions(+), 104 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java index d35e979b706b..6545a7faa33d 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java @@ -1152,6 +1152,7 @@ public final DeleteResponse delete( String updatedBy, UUID id, boolean recursive, boolean hardDelete) { DeleteResponse response = deleteInternal(updatedBy, id, recursive, hardDelete); postDelete(response.entity()); + deleteFromSearch(response.entity(), hardDelete); return response; } @@ -1176,6 +1177,7 @@ public final DeleteResponse deleteByName( name = quoteFqn ? quoteName(name) : name; DeleteResponse response = deleteInternalByName(updatedBy, name, recursive, hardDelete); postDelete(response.entity()); + deleteFromSearch(response.entity(), hardDelete); return response; } @@ -1186,12 +1188,12 @@ protected void preDelete(T entity, String deletedBy) { protected void postDelete(T entity) {} - public final void deleteFromSearch(T entity, EventType changeType) { + public final void deleteFromSearch(T entity, boolean hardDelete) { if (supportsSearch) { - if (changeType.equals(ENTITY_SOFT_DELETED)) { - searchRepository.softDeleteOrRestoreEntity(entity, true); - } else { + if (hardDelete) { searchRepository.deleteEntity(entity); + } else { + searchRepository.softDeleteOrRestoreEntity(entity, true); } } } 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 d2044d444205..740daa839b77 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 @@ -361,7 +361,6 @@ public Response delete( authorizer.authorize(securityContext, operationContext, getResourceContextById(id)); DeleteResponse response = repository.delete(securityContext.getUserPrincipal().getName(), id, recursive, hardDelete); - repository.deleteFromSearch(response.entity(), response.changeType()); if (hardDelete) { limits.invalidateCache(entityType); } @@ -380,7 +379,6 @@ public Response deleteByName( DeleteResponse response = repository.deleteByName( securityContext.getUserPrincipal().getName(), name, recursive, hardDelete); - repository.deleteFromSearch(response.entity(), response.changeType()); addHref(uriInfo, response.entity()); return response.toResponse(); } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestSuiteResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestSuiteResource.java index 5797b1a31771..9b21b2b5d014 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestSuiteResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestSuiteResource.java @@ -757,7 +757,7 @@ public Response delete( } RestUtil.DeleteResponse response = repository.deleteLogicalTestSuite(securityContext, testSuite, hardDelete); - repository.deleteFromSearch(response.entity(), response.changeType()); + repository.deleteFromSearch(response.entity(), hardDelete); addHref(uriInfo, response.entity()); return response.toResponse(); } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchClient.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchClient.java index ade12dae4ded..d764b11a8b00 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchClient.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchClient.java @@ -42,6 +42,7 @@ public interface SearchClient { String DELETE = "delete"; String GLOBAL_SEARCH_ALIAS = "all"; + String DATA_ASSET_SEARCH_ALIAS = "dataAsset"; String GLOSSARY_TERM_SEARCH_INDEX = "glossary_term_search_index"; String TAG_SEARCH_INDEX = "tag_search_index"; String DEFAULT_UPDATE_SCRIPT = "for (k in params.keySet()) { ctx._source.put(k, params.get(k)) }"; @@ -74,6 +75,8 @@ public interface SearchClient { String REMOVE_TAGS_CHILDREN_SCRIPT = "for (int i = 0; i < ctx._source.tags.length; i++) { if (ctx._source.tags[i].tagFQN == params.fqn) { ctx._source.tags.remove(i) }}"; + String REMOVE_DATA_PRODUCTS_CHILDREN_SCRIPT = + "for (int i = 0; i < ctx._source.dataProducts.length; i++) { if (ctx._source.dataProducts[i].fullyQualifiedName == params.fqn) { ctx._source.dataProducts.remove(i) }}"; String UPDATE_CERTIFICATION_SCRIPT = "if (ctx._source.certification != null && ctx._source.certification.tagLabel != null) {ctx._source.certification.tagLabel.style = params.style; ctx._source.certification.tagLabel.description = params.description; ctx._source.certification.tagLabel.tagFQN = params.tagFQN; ctx._source.certification.tagLabel.name = params.name; }"; diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchRepository.java index 3cfe3b04dfb5..015aa7639fa5 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchRepository.java @@ -16,6 +16,7 @@ import static org.openmetadata.service.search.SearchClient.PROPAGATE_ENTITY_REFERENCE_FIELD_SCRIPT; import static org.openmetadata.service.search.SearchClient.PROPAGATE_FIELD_SCRIPT; import static org.openmetadata.service.search.SearchClient.PROPAGATE_TEST_SUITES_SCRIPT; +import static org.openmetadata.service.search.SearchClient.REMOVE_DATA_PRODUCTS_CHILDREN_SCRIPT; import static org.openmetadata.service.search.SearchClient.REMOVE_DOMAINS_CHILDREN_SCRIPT; import static org.openmetadata.service.search.SearchClient.REMOVE_OWNERS_SCRIPT; import static org.openmetadata.service.search.SearchClient.REMOVE_PROPAGATED_ENTITY_REFERENCE_FIELD_SCRIPT; @@ -727,6 +728,13 @@ public void deleteOrUpdateChildren(EntityInterface entity, IndexMapping indexMap indexMapping.getChildAliases(clusterAlias), List.of(new ImmutablePair<>(entityType + ".id", docId))); } + case Entity.DATA_PRODUCT -> searchClient.updateChildren( + GLOBAL_SEARCH_ALIAS, + new ImmutablePair<>("dataProducts.id", docId), + new ImmutablePair<>( + REMOVE_DATA_PRODUCTS_CHILDREN_SCRIPT, + Collections.singletonMap("fqn", entity.getFullyQualifiedName()))); + case Entity.TAG, Entity.GLOSSARY_TERM -> searchClient.updateChildren( GLOBAL_SEARCH_ALIAS, new ImmutablePair<>("tags.tagFQN", entity.getFullyQualifiedName()), diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java index fec119ce0a46..62ddfea9f02a 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java @@ -2212,7 +2212,7 @@ public void updateElasticSearch(UpdateRequest updateRequest) { private void updateElasticSearchByQuery(UpdateByQueryRequest updateByQueryRequest) { if (updateByQueryRequest != null && isClientAvailable) { updateByQueryRequest.setRefresh(true); - LOG.debug(SENDING_REQUEST_TO_ELASTIC_SEARCH, updateByQueryRequest); + LOG.info(SENDING_REQUEST_TO_ELASTIC_SEARCH, updateByQueryRequest); client.updateByQuery(updateByQueryRequest, RequestOptions.DEFAULT); } } diff --git a/openmetadata-service/src/main/resources/elasticsearch/en/api_collection_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/en/api_collection_index_mapping.json index 4412d0b9d4d0..7d2375914a46 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/en/api_collection_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/en/api_collection_index_mapping.json @@ -152,7 +152,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/en/api_endpoint_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/en/api_endpoint_index_mapping.json index 4bad64a563d6..9c3780bde690 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/en/api_endpoint_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/en/api_endpoint_index_mapping.json @@ -236,7 +236,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/en/api_service_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/en/api_service_index_mapping.json index e5b166b75d25..09899efe95af 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/en/api_service_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/en/api_service_index_mapping.json @@ -244,7 +244,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/en/chart_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/en/chart_index_mapping.json index 4c07d3a82d4c..55563789deab 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/en/chart_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/en/chart_index_mapping.json @@ -323,7 +323,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/en/container_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/en/container_index_mapping.json index 9377b8829522..485b08cbe143 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/en/container_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/en/container_index_mapping.json @@ -245,7 +245,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/en/dashboard_data_model_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/en/dashboard_data_model_index_mapping.json index cf14f2f82545..02407bd997cc 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/en/dashboard_data_model_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/en/dashboard_data_model_index_mapping.json @@ -129,7 +129,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/en/dashboard_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/en/dashboard_index_mapping.json index 531775d00ba4..b37992167d33 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/en/dashboard_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/en/dashboard_index_mapping.json @@ -301,7 +301,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/en/dashboard_service_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/en/dashboard_service_index_mapping.json index 8a95a19bfd8b..5650e456a594 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/en/dashboard_service_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/en/dashboard_service_index_mapping.json @@ -243,7 +243,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/en/database_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/en/database_index_mapping.json index 6410fcce44bc..dea0e25dae19 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/en/database_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/en/database_index_mapping.json @@ -152,7 +152,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/en/database_schema_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/en/database_schema_index_mapping.json index 55cd7e04de18..8975b0caaf02 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/en/database_schema_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/en/database_schema_index_mapping.json @@ -129,7 +129,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/en/database_service_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/en/database_service_index_mapping.json index a9a06a28a622..5af7c4fac354 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/en/database_service_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/en/database_service_index_mapping.json @@ -244,7 +244,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/en/messaging_service_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/en/messaging_service_index_mapping.json index ac26a4c5697c..93d8f59b8bb2 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/en/messaging_service_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/en/messaging_service_index_mapping.json @@ -243,7 +243,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/en/mlmodel_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/en/mlmodel_index_mapping.json index 884ce04be35e..1649ed831452 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/en/mlmodel_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/en/mlmodel_index_mapping.json @@ -145,7 +145,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/en/mlmodel_service_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/en/mlmodel_service_index_mapping.json index 1799a171bb40..44cbb4e2df55 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/en/mlmodel_service_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/en/mlmodel_service_index_mapping.json @@ -244,7 +244,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/en/pipeline_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/en/pipeline_index_mapping.json index 89aa68e4d2e7..c79e23422519 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/en/pipeline_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/en/pipeline_index_mapping.json @@ -128,7 +128,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/en/pipeline_service_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/en/pipeline_service_index_mapping.json index d52eed9436e2..7d26039dad1b 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/en/pipeline_service_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/en/pipeline_service_index_mapping.json @@ -242,7 +242,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" 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 f18800d7d4a7..bfb708cabf75 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 @@ -438,7 +438,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/en/search_service_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/en/search_service_index_mapping.json index e5b166b75d25..09899efe95af 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/en/search_service_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/en/search_service_index_mapping.json @@ -244,7 +244,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/en/storage_service_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/en/storage_service_index_mapping.json index e8b9c1087b8e..daa0f2c24838 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/en/storage_service_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/en/storage_service_index_mapping.json @@ -239,7 +239,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/en/stored_procedure_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/en/stored_procedure_index_mapping.json index e5381631d395..8a949cac7586 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/en/stored_procedure_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/en/stored_procedure_index_mapping.json @@ -139,7 +139,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/en/table_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/en/table_index_mapping.json index 7603a5aad1e5..6894c8b18378 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/en/table_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/en/table_index_mapping.json @@ -451,7 +451,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/en/topic_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/en/topic_index_mapping.json index fdbd9874f035..104716d4cfbf 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/en/topic_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/en/topic_index_mapping.json @@ -192,7 +192,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/jp/api_collection_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/jp/api_collection_index_mapping.json index 963232c61a5c..5f372f0cb654 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/jp/api_collection_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/jp/api_collection_index_mapping.json @@ -201,7 +201,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/jp/api_endpoint_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/jp/api_endpoint_index_mapping.json index 9615b637a06d..6cea22a83d07 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/jp/api_endpoint_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/jp/api_endpoint_index_mapping.json @@ -228,7 +228,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/jp/api_service_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/jp/api_service_index_mapping.json index b129fee387e2..78f6bfae7ed6 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/jp/api_service_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/jp/api_service_index_mapping.json @@ -244,7 +244,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/jp/chart_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/jp/chart_index_mapping.json index 63dc9bf7427a..da41c7a885bf 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/jp/chart_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/jp/chart_index_mapping.json @@ -348,7 +348,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/jp/container_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/jp/container_index_mapping.json index cbc2c0fb9c7a..0ec9e3caf092 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/jp/container_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/jp/container_index_mapping.json @@ -180,7 +180,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/jp/dashboard_data_model_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/jp/dashboard_data_model_index_mapping.json index ac97cf2da7a7..16eab0e04b1e 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/jp/dashboard_data_model_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/jp/dashboard_data_model_index_mapping.json @@ -130,7 +130,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/jp/dashboard_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/jp/dashboard_index_mapping.json index 34220063f91c..6dd71d84ccac 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/jp/dashboard_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/jp/dashboard_index_mapping.json @@ -182,7 +182,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/jp/dashboard_service_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/jp/dashboard_service_index_mapping.json index 7580ed0e585d..b37d23450dea 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/jp/dashboard_service_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/jp/dashboard_service_index_mapping.json @@ -234,7 +234,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/jp/database_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/jp/database_index_mapping.json index 2162b019c7cd..2b8625f71d0c 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/jp/database_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/jp/database_index_mapping.json @@ -201,7 +201,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/jp/database_schema_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/jp/database_schema_index_mapping.json index e3d7d75c1822..f54ea768c1f0 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/jp/database_schema_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/jp/database_schema_index_mapping.json @@ -128,7 +128,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/jp/database_service_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/jp/database_service_index_mapping.json index c5342182cba8..e52673edca0b 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/jp/database_service_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/jp/database_service_index_mapping.json @@ -244,7 +244,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/jp/messaging_service_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/jp/messaging_service_index_mapping.json index a431db25f83d..8555dddbb1f0 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/jp/messaging_service_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/jp/messaging_service_index_mapping.json @@ -239,7 +239,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/jp/mlmodel_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/jp/mlmodel_index_mapping.json index 3c43e7f576f3..0bb981a02a95 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/jp/mlmodel_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/jp/mlmodel_index_mapping.json @@ -137,7 +137,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/jp/mlmodel_service_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/jp/mlmodel_service_index_mapping.json index 8d8950f0e819..a626e291ad11 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/jp/mlmodel_service_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/jp/mlmodel_service_index_mapping.json @@ -244,7 +244,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/jp/pipeline_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/jp/pipeline_index_mapping.json index 719fbb0c696b..0a88086a9349 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/jp/pipeline_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/jp/pipeline_index_mapping.json @@ -116,7 +116,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/jp/pipeline_service_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/jp/pipeline_service_index_mapping.json index 465c23b92fd5..62f40905cf1b 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/jp/pipeline_service_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/jp/pipeline_service_index_mapping.json @@ -234,7 +234,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "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 3d05a850134c..585f67fce420 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 @@ -435,7 +435,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/jp/search_service_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/jp/search_service_index_mapping.json index b129fee387e2..78f6bfae7ed6 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/jp/search_service_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/jp/search_service_index_mapping.json @@ -244,7 +244,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/jp/storage_service_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/jp/storage_service_index_mapping.json index c108d4be5700..a8e480a1a7bd 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/jp/storage_service_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/jp/storage_service_index_mapping.json @@ -231,7 +231,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/jp/stored_procedure_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/jp/stored_procedure_index_mapping.json index f7a2c585e4a5..74dc0afdec82 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/jp/stored_procedure_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/jp/stored_procedure_index_mapping.json @@ -131,7 +131,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/jp/table_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/jp/table_index_mapping.json index 0e40e44ba165..91053db24430 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/jp/table_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/jp/table_index_mapping.json @@ -446,7 +446,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/jp/topic_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/jp/topic_index_mapping.json index cc5d423d3333..75e3a1114068 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/jp/topic_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/jp/topic_index_mapping.json @@ -362,7 +362,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/zh/api_collection_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/zh/api_collection_index_mapping.json index 315e65ca7832..401aac8101e9 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/zh/api_collection_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/zh/api_collection_index_mapping.json @@ -140,7 +140,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/zh/api_endpoint_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/zh/api_endpoint_index_mapping.json index 5562831c0941..08c71c439f26 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/zh/api_endpoint_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/zh/api_endpoint_index_mapping.json @@ -226,7 +226,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/zh/api_service_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/zh/api_service_index_mapping.json index 92f798d34c7f..9bf5d4443d15 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/zh/api_service_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/zh/api_service_index_mapping.json @@ -228,7 +228,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/zh/chart_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/zh/chart_index_mapping.json index dc6e1c89ef7d..abea73a8058b 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/zh/chart_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/zh/chart_index_mapping.json @@ -344,7 +344,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/zh/container_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/zh/container_index_mapping.json index 8747dffa51ca..b8456805e43e 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/zh/container_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/zh/container_index_mapping.json @@ -230,7 +230,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/zh/dashboard_data_model_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/zh/dashboard_data_model_index_mapping.json index 6fc67b807ec4..df4f72f7648f 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/zh/dashboard_data_model_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/zh/dashboard_data_model_index_mapping.json @@ -117,7 +117,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/zh/dashboard_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/zh/dashboard_index_mapping.json index f44807f639b1..1c55d305b163 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/zh/dashboard_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/zh/dashboard_index_mapping.json @@ -236,7 +236,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/zh/dashboard_service_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/zh/dashboard_service_index_mapping.json index f0e44beb8d42..7fb238b3e1a6 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/zh/dashboard_service_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/zh/dashboard_service_index_mapping.json @@ -224,7 +224,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/zh/database_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/zh/database_index_mapping.json index 5aeb73fc12a5..df775a54b6b4 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/zh/database_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/zh/database_index_mapping.json @@ -140,7 +140,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/zh/database_schema_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/zh/database_schema_index_mapping.json index 1cb5cafc1255..23f7e115e2a3 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/zh/database_schema_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/zh/database_schema_index_mapping.json @@ -117,7 +117,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/zh/database_service_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/zh/database_service_index_mapping.json index ac4cae0dada7..d7e999223144 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/zh/database_service_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/zh/database_service_index_mapping.json @@ -228,7 +228,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/zh/messaging_service_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/zh/messaging_service_index_mapping.json index 5905c5c4586f..3fc002e1ab9b 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/zh/messaging_service_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/zh/messaging_service_index_mapping.json @@ -226,7 +226,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/zh/mlmodel_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/zh/mlmodel_index_mapping.json index cafb914e9b0d..3fd2a901c7a4 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/zh/mlmodel_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/zh/mlmodel_index_mapping.json @@ -131,7 +131,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/zh/mlmodel_service_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/zh/mlmodel_service_index_mapping.json index 8034e3b1affa..315c7e72fd67 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/zh/mlmodel_service_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/zh/mlmodel_service_index_mapping.json @@ -228,7 +228,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/zh/pipeline_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/zh/pipeline_index_mapping.json index d345bdb6c572..76629230e642 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/zh/pipeline_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/zh/pipeline_index_mapping.json @@ -117,7 +117,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/zh/pipeline_service_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/zh/pipeline_service_index_mapping.json index 30e6757560ae..db4e34b91892 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/zh/pipeline_service_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/zh/pipeline_service_index_mapping.json @@ -224,7 +224,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "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 385a51afbf0a..fef942a90656 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 @@ -426,7 +426,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/zh/search_service_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/zh/search_service_index_mapping.json index 92f798d34c7f..9bf5d4443d15 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/zh/search_service_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/zh/search_service_index_mapping.json @@ -228,7 +228,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/zh/storage_service_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/zh/storage_service_index_mapping.json index f6e08d84a619..7c278c1abbf0 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/zh/storage_service_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/zh/storage_service_index_mapping.json @@ -221,7 +221,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/zh/stored_procedure_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/zh/stored_procedure_index_mapping.json index 05962f5a3a01..b658476dbfb0 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/zh/stored_procedure_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/zh/stored_procedure_index_mapping.json @@ -127,7 +127,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/zh/table_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/zh/table_index_mapping.json index a60e851a0516..2aa220c986b8 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/zh/table_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/zh/table_index_mapping.json @@ -442,7 +442,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" diff --git a/openmetadata-service/src/main/resources/elasticsearch/zh/topic_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/zh/topic_index_mapping.json index a562e51e4f88..eea4513713e0 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/zh/topic_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/zh/topic_index_mapping.json @@ -180,7 +180,7 @@ } }, "fullyQualifiedName": { - "type": "text" + "type": "keyword" }, "description": { "type": "text" 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 4a6ee2337773..528eda7b740c 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 @@ -34,6 +34,7 @@ import { selectDomain, selectSubDomain, setupAssetsForDomain, + verifyDataProductAssetsAfterDelete, verifyDomain, } from '../../utils/domain'; import { sidebarClick } from '../../utils/sidebar'; @@ -61,7 +62,7 @@ test.describe('Domains', () => { await test.step('Add assets to domain', async () => { await redirectToHomePage(page); await sidebarClick(page, SidebarItem.DOMAIN); - await addAssetsToDomain(page, domain.data, assets); + await addAssetsToDomain(page, domain, assets); }); await test.step('Delete domain using delete modal', async () => { @@ -100,7 +101,7 @@ test.describe('Domains', () => { await domain.create(apiContext); await sidebarClick(page, SidebarItem.DOMAIN); await page.reload(); - await addAssetsToDomain(page, domain.data, assets); + await addAssetsToDomain(page, domain, assets); await test.step('Create DataProducts', async () => { await selectDomain(page, domain.data); @@ -115,7 +116,11 @@ test.describe('Domains', () => { await redirectToHomePage(page); await sidebarClick(page, SidebarItem.DOMAIN); await selectDataProduct(page, domain.data, dataProduct1.data); - await addAssetsToDataProduct(page, dataProduct1.data, assets); + await addAssetsToDataProduct( + page, + dataProduct1.data.fullyQualifiedName ?? '', + assets + ); }); await test.step('Remove assets from DataProducts', async () => { @@ -220,7 +225,7 @@ test.describe('Domains', () => { await domain.create(apiContext); await page.reload(); await sidebarClick(page, SidebarItem.DOMAIN); - await addAssetsToDomain(page, domain.data, assets); + await addAssetsToDomain(page, domain, assets); await page.getByTestId('documentation').click(); const updatedDomainName = 'PW Domain Updated'; @@ -264,6 +269,71 @@ test.describe('Domains', () => { await domain.delete(apiContext); await afterAction(); }); + + test('Should clear assets from data products after deletion of data product in Domain', async ({ + page, + }) => { + const { afterAction, apiContext } = await getApiContext(page); + const { assets, assetCleanup } = await setupAssetsForDomain(page); + const domain = new Domain({ + name: 'PW_Domain_Delete_Testing', + displayName: 'PW_Domain_Delete_Testing', + description: 'playwright domain description', + domainType: 'Aggregate', + fullyQualifiedName: 'PW_Domain_Delete_Testing', + }); + const dataProduct1 = new DataProduct(domain, 'PW_DataProduct_Sales'); + const dataProduct2 = new DataProduct(domain, 'PW_DataProduct_Finance'); + + const domain1 = new Domain({ + name: 'PW_Domain_Delete_Testing', + displayName: 'PW_Domain_Delete_Testing', + description: 'playwright domain description', + domainType: 'Aggregate', + fullyQualifiedName: 'PW_Domain_Delete_Testing', + }); + const newDomainDP1 = new DataProduct(domain1, 'PW_DataProduct_Sales'); + const newDomainDP2 = new DataProduct(domain1, 'PW_DataProduct_Finance'); + + try { + await domain.create(apiContext); + await dataProduct1.create(apiContext); + await dataProduct2.create(apiContext); + await sidebarClick(page, SidebarItem.DOMAIN); + await page.reload(); + await addAssetsToDomain(page, domain, assets); + await verifyDataProductAssetsAfterDelete(page, { + domain, + dataProduct1, + dataProduct2, + assets, + }); + + await test.step( + 'Delete domain & recreate the same domain and data product', + async () => { + await domain.delete(apiContext); + await domain1.create(apiContext); + await newDomainDP1.create(apiContext); + await newDomainDP2.create(apiContext); + await page.reload(); + await redirectToHomePage(page); + await sidebarClick(page, SidebarItem.DOMAIN); + await selectDataProduct(page, domain1.data, newDomainDP1.data); + await checkAssetsCount(page, 0); + await sidebarClick(page, SidebarItem.DOMAIN); + await selectDataProduct(page, domain1.data, newDomainDP2.data); + await checkAssetsCount(page, 0); + } + ); + } finally { + await newDomainDP1.delete(apiContext); + await newDomainDP2.delete(apiContext); + await domain1.delete(apiContext); + await assetCleanup(); + await afterAction(); + } + }); }); test.describe('Domains Rbac', () => { @@ -343,13 +413,13 @@ test.describe('Domains Rbac', () => { await redirectToHomePage(page); await sidebarClick(page, SidebarItem.DOMAIN); await selectDomain(page, domain1.data); - await addAssetsToDomain(page, domain1.data, domainAssset1); + await addAssetsToDomain(page, domain1, domainAssset1); // Add assets to domain 2 await redirectToHomePage(page); await sidebarClick(page, SidebarItem.DOMAIN); await selectDomain(page, domain2.data); - await addAssetsToDomain(page, domain2.data, domainAssset2); + await addAssetsToDomain(page, domain2, domainAssset2); }); await test.step('User with access to multiple domains', async () => { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/domain/DataProduct.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/domain/DataProduct.ts index 6c60b130350e..b932c61edaae 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/domain/DataProduct.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/domain/DataProduct.ts @@ -15,6 +15,7 @@ import { uuid } from '../../utils/common'; import { EntityTypeEndpoint } from '../entity/Entity.interface'; import { EntityClass } from '../entity/EntityClass'; import { Domain } from './Domain'; +import { SubDomain } from './SubDomain'; type UserTeamRef = { name: string; @@ -45,9 +46,10 @@ export class DataProduct extends EntityClass { responseData: ResponseDataType = {} as ResponseDataType; - constructor(domain: Domain, name?: string) { + constructor(domain: Domain, name?: string, subDomain?: SubDomain) { super(EntityTypeEndpoint.DATA_PRODUCT); - this.data.domain = domain.data.name; + this.data.domain = + domain.data.name + (subDomain ? `.${subDomain?.data.name}` : ''); // fqn this.data.name = name ?? this.data.name; // eslint-disable-next-line no-useless-escape this.data.fullyQualifiedName = `\"${this.data.name}\"`; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/domain.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/domain.ts index b769ef573e70..199add87205a 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/domain.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/domain.ts @@ -10,8 +10,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { expect, Page } from '@playwright/test'; +import test, { expect, Page } from '@playwright/test'; import { get, isEmpty, isUndefined } from 'lodash'; +import { SidebarItem } from '../constant/sidebar'; import { DataProduct } from '../support/domain/DataProduct'; import { Domain } from '../support/domain/Domain'; import { SubDomain } from '../support/domain/SubDomain'; @@ -27,8 +28,10 @@ import { INVALID_NAMES, NAME_MAX_LENGTH_VALIDATION_ERROR, NAME_VALIDATION_ERROR, + redirectToHomePage, } from './common'; import { addOwner } from './entity'; +import { sidebarClick } from './sidebar'; export const assignDomain = async (page: Page, domain: Domain['data']) => { await page.getByTestId('add-domain').click(); @@ -113,13 +116,45 @@ export const selectSubDomain = async ( domain: Domain['data'], subDomain: SubDomain['data'] ) => { - await page - .getByRole('menuitem', { name: domain.displayName }) - .locator('span') - .click(); + const menuItem = page.getByRole('menuitem', { name: domain.displayName }); + const isSelected = await menuItem.evaluate((element) => { + return element.classList.contains('ant-menu-item-selected'); + }); + + if (!isSelected) { + const subDomainRes = page.waitForResponse( + '/api/v1/search/query?*&from=0&size=50&index=domain_search_index' + ); + await menuItem.click(); + await subDomainRes; + } await page.getByTestId('subdomains').getByText('Sub Domains').click(); + const res = page.waitForResponse( + '/api/v1/search/query?*&index=data_product_search_index' + ); await page.getByTestId(subDomain.name).click(); + await res; +}; + +export const selectDataProductFromTab = async ( + page: Page, + dataProduct: DataProduct['data'] +) => { + const dpRes = page.waitForResponse( + '/api/v1/search/query?*&from=0&size=50&index=data_product_search_index' + ); + await page.getByText('Data Products').click(); + + await dpRes; + + const dpDataRes = page.waitForResponse('/api/v1/dataProducts/name/*'); + + await page + .getByTestId(`explore-card-${dataProduct.name}`) + .getByTestId('entity-link') + .click(); + await dpDataRes; }; export const selectDataProduct = async ( @@ -132,11 +167,7 @@ export const selectDataProduct = async ( .locator('span') .click(); - await page.getByText('Data Products').click(); - await page - .getByTestId(`explore-card-${dataProduct.name}`) - .getByTestId('entity-link') - .click(); + await selectDataProductFromTab(page, dataProduct); }; const goToAssetsTab = async (page: Page, domain: Domain['data']) => { @@ -280,10 +311,13 @@ export const createSubDomain = async ( export const addAssetsToDomain = async ( page: Page, - domain: Domain['data'], - assets: EntityClass[] + domain: Domain, + assets: EntityClass[], + navigateToAssetsTab = true ) => { - await goToAssetsTab(page, domain); + if (navigateToAssetsTab) { + await goToAssetsTab(page, domain.data); + } await checkAssetsCount(page, 0); await expect(page.getByTestId('no-data-placeholder')).toContainText( @@ -309,20 +343,22 @@ export const addAssetsToDomain = async ( await page.locator(`[data-testid="table-data-card_${fqn}"] input`).check(); } - const assetsAddRes = page.waitForResponse( - `/api/v1/domains/${encodeURIComponent( - domain.fullyQualifiedName ?? '' - )}/assets/add` - ); + const assetsAddRes = page.waitForResponse(`/api/v1/domains/*/assets/add`); await page.getByTestId('save-btn').click(); await assetsAddRes; + const countRes = page.waitForResponse( + '/api/v1/search/query?q=*&index=all&from=0&size=15' + ); + await page.reload(); + await countRes; + await checkAssetsCount(page, assets.length); }; export const addAssetsToDataProduct = async ( page: Page, - dataProduct: DataProduct['data'], + dataProductFqn: string, assets: EntityClass[] ) => { await page.getByTestId('assets').click(); @@ -348,9 +384,7 @@ export const addAssetsToDataProduct = async ( } const assetsAddRes = page.waitForResponse( - `/api/v1/dataProducts/${encodeURIComponent( - dataProduct.fullyQualifiedName ?? '' - )}/assets/add` + `/api/v1/dataProducts/*/assets/add` ); await page.getByTestId('save-btn').click(); await assetsAddRes; @@ -423,3 +457,89 @@ export const createDataProduct = async ( await page.getByTestId('save-data-product').click(); await saveRes; }; + +export const verifyDataProductAssetsAfterDelete = async ( + page: Page, + { + domain, + dataProduct1, + dataProduct2, + assets, + subDomain, + }: { + domain: Domain; + dataProduct1: DataProduct; + dataProduct2: DataProduct; + assets: EntityClass[]; + subDomain?: SubDomain; + } +) => { + const { apiContext } = await getApiContext(page); + const newDataProduct1 = new DataProduct(domain, 'PW_DataProduct_Sales'); + + await test.step('Add assets to DataProduct Sales', async () => { + await redirectToHomePage(page); + await sidebarClick(page, SidebarItem.DOMAIN); + if (subDomain) { + await selectSubDomain(page, domain.data, subDomain.data); + await selectDataProductFromTab(page, dataProduct1.data); + } else { + await selectDataProduct(page, domain.data, dataProduct1.data); + } + await addAssetsToDataProduct( + page, + dataProduct1.responseData.fullyQualifiedName ?? '', + assets.slice(0, 2) + ); + }); + + await test.step('Add assets to DataProduct Finance', async () => { + await redirectToHomePage(page); + await sidebarClick(page, SidebarItem.DOMAIN); + if (subDomain) { + await selectSubDomain(page, domain.data, subDomain.data); + await selectDataProductFromTab(page, dataProduct2.data); + } else { + await selectDataProduct(page, domain.data, dataProduct2.data); + } + await addAssetsToDataProduct( + page, + dataProduct2.responseData.fullyQualifiedName ?? '', + [assets[2]] + ); + }); + + await test.step( + 'Remove Data Product Sales and Create the same again', + async () => { + // Remove sales data product + await dataProduct1.delete(apiContext); + + // Create sales data product again + await redirectToHomePage(page); + await sidebarClick(page, SidebarItem.DOMAIN); + if (subDomain) { + await selectSubDomain(page, domain.data, subDomain.data); + } else { + await selectDomain(page, domain.data); + } + + await createDataProduct(page, newDataProduct1.data); + } + ); + + await test.step( + 'Verify assets are not present in the newly created data product', + async () => { + await redirectToHomePage(page); + await sidebarClick(page, SidebarItem.DOMAIN); + if (subDomain) { + await selectSubDomain(page, domain.data, subDomain.data); + await selectDataProductFromTab(page, newDataProduct1.data); + } else { + await selectDataProduct(page, domain.data, newDataProduct1.data); + } + await checkAssetsCount(page, 0); + } + ); +}; From 62be9a960437295ef5130b334347d45e143cbd70 Mon Sep 17 00:00:00 2001 From: Mohit Yadav <105265192+mohityadav766@users.noreply.github.com> Date: Wed, 8 Jan 2025 15:30:54 +0530 Subject: [PATCH 087/294] [Fix-19094] Circular Rendering issue for lineage (#19270) * Fix Circulare Rendering issue for lineage * Review (cherry picked from commit 1f766791fdead5566d6e53cb0311db9ade60b02f) --- .../elasticsearch/ElasticSearchClient.java | 54 ++++++++++++++++--- .../search/opensearch/OpenSearchClient.java | 54 ++++++++++++++++--- 2 files changed, 96 insertions(+), 12 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java index 62ddfea9f02a..50a2248f9312 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java @@ -775,6 +775,7 @@ public Map searchLineageInternal( boolean deleted, String entityType) throws IOException { + Set visitedFQN = new HashSet<>(); Map responseMap = new HashMap<>(); Set> edges = new HashSet<>(); Set> nodes = new HashSet<>(); @@ -799,6 +800,7 @@ public Map searchLineageInternal( } getLineage( fqn, + visitedFQN, downstreamDepth, edges, nodes, @@ -806,7 +808,14 @@ public Map searchLineageInternal( "lineage.fromEntity.fqnHash.keyword", deleted); getLineage( - fqn, upstreamDepth, edges, nodes, queryFilter, "lineage.toEntity.fqnHash.keyword", deleted); + fqn, + visitedFQN, + upstreamDepth, + edges, + nodes, + queryFilter, + "lineage.toEntity.fqnHash.keyword", + deleted); responseMap.put("edges", edges); responseMap.put("nodes", nodes); return responseMap; @@ -1032,6 +1041,7 @@ public Response searchSchemaEntityRelationship( private void getLineage( String fqn, + Set visitedFQN, int depth, Set> edges, Set> nodes, @@ -1039,9 +1049,10 @@ private void getLineage( String direction, boolean deleted) throws IOException { - if (depth <= 0) { + if (depth <= 0 || visitedFQN.contains(fqn)) { return; } + visitedFQN.add(fqn); es.org.elasticsearch.action.search.SearchRequest searchRequest = new es.org.elasticsearch.action.search.SearchRequest( Entity.getSearchRepository().getIndexOrAliasName(GLOBAL_SEARCH_ALIAS)); @@ -1071,13 +1082,27 @@ private void getLineage( if (!edges.contains(lin) && fromEntity.get("fqn").equals(fqn)) { edges.add(lin); getLineage( - toEntity.get("fqn"), depth - 1, edges, nodes, queryFilter, direction, deleted); + toEntity.get("fqn"), + visitedFQN, + depth - 1, + edges, + nodes, + queryFilter, + direction, + deleted); } } else { if (!edges.contains(lin) && toEntity.get("fqn").equals(fqn)) { edges.add(lin); getLineage( - fromEntity.get("fqn"), depth - 1, edges, nodes, queryFilter, direction, deleted); + fromEntity.get("fqn"), + visitedFQN, + depth - 1, + edges, + nodes, + queryFilter, + direction, + deleted); } } } @@ -1229,6 +1254,7 @@ private Map searchPipelineLineage( boolean deleted, Map responseMap) throws IOException { + Set visitedFQN = new HashSet<>(); Set> edges = new HashSet<>(); Set> nodes = new HashSet<>(); es.org.elasticsearch.action.search.SearchRequest searchRequest = @@ -1263,6 +1289,7 @@ private Map searchPipelineLineage( edges.add(lin); getLineage( fromEntity.get("fqn"), + visitedFQN, upstreamDepth, edges, nodes, @@ -1271,6 +1298,7 @@ private Map searchPipelineLineage( deleted); getLineage( toEntity.get("fqn"), + visitedFQN, downstreamDepth, edges, nodes, @@ -1281,9 +1309,23 @@ private Map searchPipelineLineage( } } getLineage( - fqn, downstreamDepth, edges, nodes, queryFilter, "lineage.fromEntity.fqn.keyword", deleted); + fqn, + visitedFQN, + downstreamDepth, + edges, + nodes, + queryFilter, + "lineage.fromEntity.fqn.keyword", + deleted); getLineage( - fqn, upstreamDepth, edges, nodes, queryFilter, "lineage.toEntity.fqn.keyword", deleted); + fqn, + visitedFQN, + upstreamDepth, + edges, + nodes, + queryFilter, + "lineage.toEntity.fqn.keyword", + deleted); // TODO: Fix this , this is hack if (edges.isEmpty()) { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java index 2ba538f37cdc..cbce3b06e1ea 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java @@ -777,6 +777,7 @@ public Map searchLineageInternal( boolean deleted, String entityType) throws IOException { + Set visitedFQN = new HashSet<>(); if (entityType.equalsIgnoreCase(Entity.PIPELINE) || entityType.equalsIgnoreCase(Entity.STORED_PROCEDURE)) { return searchPipelineLineage(fqn, upstreamDepth, downstreamDepth, queryFilter, deleted); @@ -801,6 +802,7 @@ public Map searchLineageInternal( } getLineage( fqn, + visitedFQN, downstreamDepth, edges, nodes, @@ -808,7 +810,14 @@ public Map searchLineageInternal( "lineage.fromEntity.fqnHash.keyword", deleted); getLineage( - fqn, upstreamDepth, edges, nodes, queryFilter, "lineage.toEntity.fqnHash.keyword", deleted); + fqn, + visitedFQN, + upstreamDepth, + edges, + nodes, + queryFilter, + "lineage.toEntity.fqnHash.keyword", + deleted); responseMap.put("edges", edges); responseMap.put("nodes", nodes); return responseMap; @@ -1032,6 +1041,7 @@ public Response searchSchemaEntityRelationship( private void getLineage( String fqn, + Set visitedFQN, int depth, Set> edges, Set> nodes, @@ -1039,9 +1049,10 @@ private void getLineage( String direction, boolean deleted) throws IOException { - if (depth <= 0) { + if (depth <= 0 || visitedFQN.contains(fqn)) { return; } + visitedFQN.add(fqn); os.org.opensearch.action.search.SearchRequest searchRequest = new os.org.opensearch.action.search.SearchRequest( Entity.getSearchRepository().getIndexOrAliasName(GLOBAL_SEARCH_ALIAS)); @@ -1073,13 +1084,27 @@ private void getLineage( if (!edges.contains(lin) && fromEntity.get("fqn").equals(fqn)) { edges.add(lin); getLineage( - toEntity.get("fqn"), depth - 1, edges, nodes, queryFilter, direction, deleted); + toEntity.get("fqn"), + visitedFQN, + depth - 1, + edges, + nodes, + queryFilter, + direction, + deleted); } } else { if (!edges.contains(lin) && toEntity.get("fqn").equals(fqn)) { edges.add(lin); getLineage( - fromEntity.get("fqn"), depth - 1, edges, nodes, queryFilter, direction, deleted); + fromEntity.get("fqn"), + visitedFQN, + depth - 1, + edges, + nodes, + queryFilter, + direction, + deleted); } } } @@ -1226,6 +1251,7 @@ private SearchResponse performLineageSearch(String fqn, String queryFilter, bool private Map searchPipelineLineage( String fqn, int upstreamDepth, int downstreamDepth, String queryFilter, boolean deleted) throws IOException { + Set visitedFQN = new HashSet<>(); Map responseMap = new HashMap<>(); Set> edges = new HashSet<>(); Set> nodes = new HashSet<>(); @@ -1263,6 +1289,7 @@ private Map searchPipelineLineage( edges.add(lin); getLineage( fromEntity.get("fqn"), + visitedFQN, upstreamDepth, edges, nodes, @@ -1271,6 +1298,7 @@ private Map searchPipelineLineage( deleted); getLineage( toEntity.get("fqn"), + visitedFQN, downstreamDepth, edges, nodes, @@ -1281,9 +1309,23 @@ private Map searchPipelineLineage( } } getLineage( - fqn, downstreamDepth, edges, nodes, queryFilter, "lineage.fromEntity.fqn.keyword", deleted); + fqn, + visitedFQN, + downstreamDepth, + edges, + nodes, + queryFilter, + "lineage.fromEntity.fqn.keyword", + deleted); getLineage( - fqn, upstreamDepth, edges, nodes, queryFilter, "lineage.toEntity.fqn.keyword", deleted); + fqn, + visitedFQN, + upstreamDepth, + edges, + nodes, + queryFilter, + "lineage.toEntity.fqn.keyword", + deleted); if (edges.isEmpty()) { os.org.opensearch.action.search.SearchRequest searchRequestForEntity = From 60a9604d7112a9851379f869ef14820b8850c2f2 Mon Sep 17 00:00:00 2001 From: Pranita Fulsundar Date: Wed, 8 Jan 2025 15:27:20 +0530 Subject: [PATCH 088/294] fix: reference table dropdown in add and edit query forms (#19026) * fix: reference table dropdown in add and edit query forms * fix: show full value of fqn * fix: e2e test for create new query * fix: e2e test for edit query * fix: optionLabelProp value * fix: optionLabelProp value to AsyncSelect component --------- Co-authored-by: Shailesh Parmar --- .../ui/playwright/e2e/Features/QueryEntity.spec.ts | 7 ++++++- .../components/Database/TableQueries/QueryCard.tsx | 2 +- .../QueryUsedByOtherTable.component.tsx | 13 ++++++++----- .../pages/AddQueryPage/AddQueryPage.component.tsx | 2 ++ .../resources/ui/src/styles/components/size.less | 3 +++ .../src/main/resources/ui/src/utils/EntityUtils.tsx | 10 ++++++---- 6 files changed, 26 insertions(+), 11 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/QueryEntity.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/QueryEntity.spec.ts index 01e70e1e6994..f2f653128712 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/QueryEntity.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/QueryEntity.spec.ts @@ -92,6 +92,7 @@ test('Query Entity', async ({ page }) => { await page .locator('div') .filter({ hasText: new RegExp(`^${queryData.queryUsedIn.table1}$`) }) + .first() .click(); await clickOutside(page); @@ -178,7 +179,11 @@ test('Query Entity', async ({ page }) => { ); await page.keyboard.type(queryData.queryUsedIn.table2); await tableSearchResponse; - await page.click(`[title="${queryData.queryUsedIn.table2}"]`); + await page + .locator('div') + .filter({ hasText: new RegExp(`^${queryData.queryUsedIn.table2}$`) }) + .first() + .click(); await clickOutside(page); const updateQueryResponse = page.waitForResponse( (response) => diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/TableQueries/QueryCard.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/TableQueries/QueryCard.tsx index 843954e93d49..f247f98e7442 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/TableQueries/QueryCard.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/TableQueries/QueryCard.tsx @@ -130,7 +130,7 @@ const QueryCard: FC = ({ return ( existingTable ?? { id: (option.value as string) ?? '', - displayName: option.label as string, + displayName: option.labelName as string, type: EntityType.TABLE, } ); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/TableQueries/QueryUsedByOtherTable/QueryUsedByOtherTable.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/TableQueries/QueryUsedByOtherTable/QueryUsedByOtherTable.component.tsx index bc6ae182ca41..cab6ccf16eb0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/TableQueries/QueryUsedByOtherTable/QueryUsedByOtherTable.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/TableQueries/QueryUsedByOtherTable/QueryUsedByOtherTable.component.tsx @@ -25,7 +25,7 @@ import { QUERY_USED_BY_TABLE_VIEW_CAP } from '../../../../constants/Query.consta import { EntityType } from '../../../../enums/entity.enum'; import { SearchIndex } from '../../../../enums/search.enum'; import { searchData } from '../../../../rest/miscAPI'; -import { getEntityName } from '../../../../utils/EntityUtils'; +import { getEntityLabel, getEntityName } from '../../../../utils/EntityUtils'; import { AsyncSelect } from '../../../common/AsyncSelect/AsyncSelect'; import Loader from '../../../common/Loader/Loader'; import { @@ -135,8 +135,9 @@ const QueryUsedByOtherTable = ({ ); return data.hits.hits.map((value) => ({ - label: getEntityName(value._source), + label: getEntityLabel(value._source), value: value._source.id, + labelName: getEntityName(value._source), })); } catch (error) { return []; @@ -149,11 +150,12 @@ const QueryUsedByOtherTable = ({ const options = await fetchTableEntity(); const { queryUsedIn = [] } = query; const selectedValue = queryUsedIn.map((table) => ({ - label: getEntityName(table), + label: getEntityLabel(table), value: table.id, + labelName: getEntityName(table), })); - setInitialOptions(uniqBy([...selectedValue, ...options], 'value')); + setInitialOptions(uniqBy([...selectedValue, ...options], 'labelName')); } catch (error) { setInitialOptions([]); } finally { @@ -171,10 +173,11 @@ const QueryUsedByOtherTable = ({ ) : ( { const options = data.hits.hits.map((value) => ({ label: getEntityLabel(value._source), value: value._source.id, + labelName: getEntityName(value._source), })); return table @@ -267,6 +268,7 @@ const AddQueryPage = () => { api={fetchTableEntity} data-testid="query-used-in" mode="multiple" + optionLabelProp="labelName" options={initialOptions} placeholder={t('label.please-select-entity', { entity: t('label.query-used-in'), diff --git a/openmetadata-ui/src/main/resources/ui/src/styles/components/size.less b/openmetadata-ui/src/main/resources/ui/src/styles/components/size.less index e77d1c326b89..a02687c624ed 100644 --- a/openmetadata-ui/src/main/resources/ui/src/styles/components/size.less +++ b/openmetadata-ui/src/main/resources/ui/src/styles/components/size.less @@ -203,6 +203,9 @@ .w-min-20 { min-width: 20rem; } +.w-min-30 { + min-width: 30rem; +} .w-min-60 { min-width: 60rem; } 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 9b4dd9aefa88..81e9c58569a0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/EntityUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/EntityUtils.tsx @@ -171,11 +171,13 @@ export const getEntityLabel = (entity: { name?: string; fullyQualifiedName?: string; }): JSX.Element => ( - - {getEntityName(entity)} - + + + {getEntityName(entity)} + + {entity?.fullyQualifiedName} - + ); From 12d2e7072707d6cf738819363d7266c5e4c98c9c Mon Sep 17 00:00:00 2001 From: IceS2 Date: Wed, 8 Jan 2025 11:16:56 +0100 Subject: [PATCH 089/294] Add Match Any Certification to Policies (#19271) --- .../security/RBACConditionEvaluator.java | 20 ++++++++++ .../policyevaluator/RuleEvaluator.java | 39 +++++++++++++++++++ .../policyevaluator/RuleEvaluatorTest.java | 31 +++++++++++++++ 3 files changed, 90 insertions(+) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/security/RBACConditionEvaluator.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/security/RBACConditionEvaluator.java index f2f66a008e9f..8f8cc8a18165 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/security/RBACConditionEvaluator.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/security/RBACConditionEvaluator.java @@ -200,6 +200,10 @@ private void handleMethodReference(SpelNode node, ConditionCollector collector) String methodName = methodRef.getName(); switch (methodName) { + case "matchAnyCertification" -> { + List certificationLabels = extractMethodArguments(methodRef); + matchAnyCertification(certificationLabels, collector); + } case "matchAnyTag" -> { List tags = extractMethodArguments(methodRef); matchAnyTag(tags, collector); @@ -259,6 +263,22 @@ public void matchAllTags(List tags, ConditionCollector collector) { } } + public void matchAnyCertification( + List certificationLabels, ConditionCollector collector) { + List certificationQueries = new ArrayList<>(); + for (String certificationLabel : certificationLabels) { + certificationQueries.add( + queryBuilderFactory.termQuery("certification.tagLabel.tagFQN", certificationLabel)); + } + OMQueryBuilder certificationQueriesCombined; + if (certificationQueries.size() == 1) { + certificationQueriesCombined = certificationQueries.get(0); + } else { + certificationQueriesCombined = queryBuilderFactory.boolQuery().should(certificationQueries); + } + collector.addMust(certificationQueriesCombined); + } + public void isOwner(User user, ConditionCollector collector) { List ownerQueries = new ArrayList<>(); ownerQueries.add(queryBuilderFactory.termQuery("owners.id", user.getId().toString())); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/RuleEvaluator.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/RuleEvaluator.java index 0327ed8a6280..eef29bc8d2f7 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/RuleEvaluator.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/RuleEvaluator.java @@ -5,8 +5,10 @@ import java.util.Arrays; import java.util.List; +import java.util.Optional; import lombok.extern.slf4j.Slf4j; import org.openmetadata.schema.Function; +import org.openmetadata.schema.type.AssetCertification; import org.openmetadata.schema.type.TagLabel; import org.openmetadata.service.Entity; import org.openmetadata.service.security.policyevaluator.SubjectContext.PolicyContext; @@ -156,6 +158,43 @@ public boolean matchAnyTag(String... tagFQNs) { return false; } + @Function( + name = "matchAnyCertification", + input = "List of comma separated Certification fully qualified names", + description = + "Returns true if the entity being accessed has any of the Certification given as input", + examples = {"matchAnyCertification('Certification.Silver', 'Certification.Gold')"}) + @SuppressWarnings("unused") // Used in SpelExpressions + public boolean matchAnyCertification(String... tagFQNs) { + if (expressionValidation) { + for (String tagFqn : tagFQNs) { + Entity.getEntityReferenceByName(Entity.TAG, tagFqn, NON_DELETED); // Validate tag exists + } + return false; + } + if (resourceContext == null) { + return false; + } + + Optional oCertification = + Optional.ofNullable(resourceContext.getEntity().getCertification()); + + if (oCertification.isEmpty()) { + LOG.debug( + "matchAnyCertification {} resourceCertification is null.", Arrays.toString(tagFQNs)); + return false; + } else { + AssetCertification certification = oCertification.get(); + + LOG.debug( + "matchAnyCertification {} resourceCertification {}", + Arrays.toString(tagFQNs), + certification.getTagLabel().getTagFQN()); + return Arrays.stream(tagFQNs) + .anyMatch(tagFQN -> tagFQN.equals(certification.getTagLabel().getTagFQN())); + } + } + @Function( name = "matchTeam", input = "None", diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/security/policyevaluator/RuleEvaluatorTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/security/policyevaluator/RuleEvaluatorTest.java index 40c9397316d7..7f930c93fa9b 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/security/policyevaluator/RuleEvaluatorTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/security/policyevaluator/RuleEvaluatorTest.java @@ -25,6 +25,7 @@ import org.openmetadata.schema.entity.teams.Role; import org.openmetadata.schema.entity.teams.Team; import org.openmetadata.schema.entity.teams.User; +import org.openmetadata.schema.type.AssetCertification; import org.openmetadata.schema.type.EntityReference; import org.openmetadata.schema.type.Include; import org.openmetadata.schema.type.TagLabel; @@ -187,6 +188,36 @@ void test_matchAnyTag() { assertTrue(evaluateExpression("!matchAnyTag('tag4')")); } + @Test + void test_matchAnyCertification() { + // Certification is not Present + assertFalse(evaluateExpression("!matchAnyCertification('Certification.Gold')")); + assertFalse( + evaluateExpression("!matchAnyCertification('Certification.Gold', 'Certification.Silver')")); + assertFalse(evaluateExpression("matchAnyCertification('Certification.Bronze')")); + + table.withCertification( + new AssetCertification().withTagLabel(new TagLabel().withTagFQN("Certification.Gold"))); + + // Certification is present + assertTrue( + evaluateExpression("matchAnyCertification('Certification.Gold', 'Certification.Silver')")); + assertFalse( + evaluateExpression("!matchAnyCertification('Certification.Gold', 'Certification.Silver')")); + assertTrue(evaluateExpression("matchAnyCertification('Certification.Gold')")); + assertFalse(evaluateExpression("!matchAnyCertification('Certification.Gold')")); + + // Certification is different + assertFalse( + evaluateExpression( + "matchAnyCertification('Certification.Bronze', 'Certification.Silver')")); + assertTrue( + evaluateExpression( + "!matchAnyCertification('Certification.Bronze', 'Certification.Silver')")); + assertFalse(evaluateExpression("matchAnyCertification('Certification.Bronze')")); + assertTrue(evaluateExpression("!matchAnyCertification('Certification.Bronze')")); + } + @Test void test_matchTeam() { // Create a team hierarchy From 64a1be9e57d0833213caa1753bdeb4928c9b2ca8 Mon Sep 17 00:00:00 2001 From: Matt Chamberlin Date: Wed, 8 Jan 2025 05:17:18 -0500 Subject: [PATCH 090/294] fix: Add docStore endpoints to Ometa API routes (#19172) (cherry picked from commit bf8a9e4f46cf62472c810d02392df6bcf826759b) --- ingestion/src/metadata/ingestion/ometa/routes.py | 4 ++++ .../resources/json/schema/api/docStore/createDocument.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/ingestion/src/metadata/ingestion/ometa/routes.py b/ingestion/src/metadata/ingestion/ometa/routes.py index 2750991a0537..8759c663059b 100644 --- a/ingestion/src/metadata/ingestion/ometa/routes.py +++ b/ingestion/src/metadata/ingestion/ometa/routes.py @@ -54,6 +54,7 @@ ) from metadata.generated.schema.api.data.createTable import CreateTableRequest from metadata.generated.schema.api.data.createTopic import CreateTopicRequest +from metadata.generated.schema.api.docStore.createDocument import CreateDocumentRequest from metadata.generated.schema.api.domains.createDataProduct import ( CreateDataProductRequest, ) @@ -136,6 +137,7 @@ from metadata.generated.schema.entity.data.storedProcedure import StoredProcedure from metadata.generated.schema.entity.data.table import Table from metadata.generated.schema.entity.data.topic import Topic +from metadata.generated.schema.entity.docStore.document import Document from metadata.generated.schema.entity.domains.dataProduct import DataProduct from metadata.generated.schema.entity.domains.domain import Domain from metadata.generated.schema.entity.feed.suggestion import Suggestion @@ -198,6 +200,8 @@ CreateAPIEndpointRequest.__name__: "/apiEndpoints", APICollection.__name__: "/apiCollections", CreateAPICollectionRequest.__name__: "/apiCollections", + Document.__name__: "/docStore", + CreateDocumentRequest.__name__: "/docStore", # Classifications Tag.__name__: "/tags", CreateTagRequest.__name__: "/tags", diff --git a/openmetadata-spec/src/main/resources/json/schema/api/docStore/createDocument.json b/openmetadata-spec/src/main/resources/json/schema/api/docStore/createDocument.json index d05a04d042e6..3daac09b4965 100644 --- a/openmetadata-spec/src/main/resources/json/schema/api/docStore/createDocument.json +++ b/openmetadata-spec/src/main/resources/json/schema/api/docStore/createDocument.json @@ -1,7 +1,7 @@ { "$id": "https://open-metadata.org/schema/entity/docStore/document.json", "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Document", + "title": "CreateDocumentRequest", "description": "This schema defines Document. A Generic entity to capture any kind of Json Payload.", "type": "object", "javaType": "org.openmetadata.schema.entities.docStore.CreateDocument", From fd4efb0c1e7d1e55a0e55a1bec0a1be17953b51d Mon Sep 17 00:00:00 2001 From: Aniket Katkar Date: Wed, 8 Jan 2025 18:58:11 +0530 Subject: [PATCH 091/294] Minor: Update the what's new for 1.6.2 (#19285) * Update the what's new for 1.6.2 * update the version id for cookie (cherry picked from commit 6d1ad23a969dd0d64b64d5683493db0ef2b37193) --- .../Modals/WhatsNewModal/whats-new-modal.less | 4 ++ .../Modals/WhatsNewModal/whatsNewData.ts | 48 ++++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Modals/WhatsNewModal/whats-new-modal.less b/openmetadata-ui/src/main/resources/ui/src/components/Modals/WhatsNewModal/whats-new-modal.less index 67f4a9800018..c83c2d6e73d6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Modals/WhatsNewModal/whats-new-modal.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/Modals/WhatsNewModal/whats-new-modal.less @@ -26,6 +26,10 @@ margin: 0 4px !important; vertical-align: middle; } + + .node-image { + display: inline-block; + } } .whats-new-modal-button-container { display: inline-flex; 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 7eb63e10b6c6..15a2c386e719 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_6_1'; // To be changed with each release. +export const COOKIE_VERSION = 'VERSION_1_6_2'; // 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 @@ -1454,4 +1454,50 @@ Since we are introducing the Auto Classification workflow, we are going to remov [`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). Introducing the "connector specifcication class (ServiceSpec)". 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 for an implementation example.`, }, }, + { + id: 58, + version: 'v1.6.2', + description: 'Released on 8th January 2025.', + features: [], + changeLogs: { + Improvements: `- **Fix**: Test case getting removed from logical test suite after editing the test case. +- **Fix**: Edit Lineage Operation not working with isOwner() condition +- **Fix**: EditLineage permission not allowing users to edit the lineage. +- **Fix**: ViewAll permission not working with matchAnyTag() and isOwner() conditions +- **Fix**: Vulnerability security on 1.5.6 version package com.google.protobuf_protobuf-java. +- **Fix**: DBT Data ingestion not working. +- **Fix**: Table owners not shown properly after a dbt ingestion and re-indexing. +- **Fix**: Glossary Listing Limits to 50 without scrolling to next page. +- **Fix**: Mask encrypted password for email. +- **Fix**: Profiler failing on ingesting data type for postgres. +- **Fix**: Column lineage ingestion failed to parse column due to subquery raw_name AttributeError. +- **Fix**: Data Insight Tier Filter does not work. +- **Fix**: Add depth support for storage connector. +- **Fix**: The tag also adding to a different asset when a different asset with the same name is updated with the tag. +- **Fix**: Replace the description editor with a new block editor. +- **Fix**: Redshift Metadata ingestion failing for Stored Procedure. +- **Fix**: Lineage view not showing all the nodes in case of circular lineage. +- **Fix**: Deleting Data Product should delete the data asset relationships. +- **Fix**: styling (color, icon) is lost if a glossaryTerm is updated via the bulk upload. +- **Fix**: Unable to see complete column type info for long column type. +- **Fix**: ApiEndpoint reindexing failure. +- **Fix**: Auto Classification Ingestion - AttributeError: 'DataType' object has no attribute 'dialect_impl'. +- **Fix**: Adding the profiler for doris failing to execute. +- **Fix**: Unable to remove existing values from custom property (enum data type). +- **Improvement**: Ability to disable soft delete when Ingestion pipeline fails. +- **Improvement**: Ability to sort the DI charts based on date or term. +- **Improvement**: Support test connection api cancellation on click of cancel. +- **Improvement**: Highlight the search term for schema table on table details page. +- **Improvement**: Add Algorithm option for authentication token validation in yaml. +- **Improvement**: Make all Test Suites executable. +- **Improvement**: Activity feed pagination. +- **Fix**: Custom DI description getting added with HTML p tag. ${CollateIconWithLinkMD} +- **Fix**: Knowledge Page hierarchy state doesn't persist on refresh. ${CollateIconWithLinkMD} +- **Fix**: Reindex Page Entitiy is Missing on Collate. ${CollateIconWithLinkMD} +- **Fix**: Avoid pluralizing for custom charts. ${CollateIconWithLinkMD} +- **Improvement**: Add the missing filters for different assets in the Automator(Ex. Database filter for Database Schema asset). ${CollateIconWithLinkMD} +- **Improvement**: Add Glossary Term and Metric as assets for Automation. ${CollateIconWithLinkMD} +`, + }, + }, ]; From 9cec8c51908f60910cea8696a7450d3a4a50e73c Mon Sep 17 00:00:00 2001 From: Ashish Gupta Date: Wed, 8 Jan 2025 19:42:57 +0530 Subject: [PATCH 092/294] fix the inline edit button spacing on user page (#19289) (cherry picked from commit 1360498ff6117b24db3f1c19e38ec16e531c0e12) --- .../common/CustomPropertyTable/property-value.less | 5 +++++ .../components/common/InlineEdit/InlineEdit.component.tsx | 1 - .../ui/src/components/common/InlineEdit/inline-edit.less | 3 --- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/property-value.less b/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/property-value.less index cd625c814427..7de5af548f1b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/property-value.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/property-value.less @@ -95,5 +95,10 @@ .custom-property-inline-edit-container { width: 100%; + flex-wrap: wrap; overflow-x: scroll; + + .ant-space-item:first-child { + width: inherit; + } } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/InlineEdit/InlineEdit.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/InlineEdit/InlineEdit.component.tsx index 97e203b9fd16..f34f188d4417 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/InlineEdit/InlineEdit.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/InlineEdit/InlineEdit.component.tsx @@ -29,7 +29,6 @@ const InlineEdit = ({ }: InlineEditProps) => { return ( Date: Thu, 19 Dec 2024 20:01:04 -0800 Subject: [PATCH 093/294] issue-18099: Edit Lineage Operation not working with isOwner() condition (#19070) --- .../resources/lineage/LineageResource.java | 33 +++++++++++++++---- .../policyevaluator/RuleEvaluator.java | 20 ++++++----- 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/lineage/LineageResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/lineage/LineageResource.java index b5ace8f9ab59..cd71dfe5fe8d 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/lineage/LineageResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/lineage/LineageResource.java @@ -60,6 +60,7 @@ import org.openmetadata.service.resources.Collection; import org.openmetadata.service.security.Authorizer; import org.openmetadata.service.security.policyevaluator.OperationContext; +import org.openmetadata.service.security.policyevaluator.ResourceContext; import org.openmetadata.service.security.policyevaluator.ResourceContextInterface; import org.openmetadata.service.util.AsyncService; import org.openmetadata.service.util.CSVExportMessage; @@ -349,8 +350,20 @@ public Response addLineage( @Valid AddLineage addLineage) { authorizer.authorize( securityContext, - new OperationContext(LINEAGE_FIELD, MetadataOperation.EDIT_LINEAGE), - new LineageResourceContext()); + new OperationContext( + addLineage.getEdge().getFromEntity().getType(), MetadataOperation.EDIT_LINEAGE), + new ResourceContext<>( + addLineage.getEdge().getFromEntity().getType(), + addLineage.getEdge().getFromEntity().getId(), + addLineage.getEdge().getFromEntity().getName())); + authorizer.authorize( + securityContext, + new OperationContext( + addLineage.getEdge().getToEntity().getType(), MetadataOperation.EDIT_LINEAGE), + new ResourceContext<>( + addLineage.getEdge().getToEntity().getType(), + addLineage.getEdge().getToEntity().getId(), + addLineage.getEdge().getToEntity().getName())); dao.addLineage(addLineage); return Response.status(Status.OK).build(); } @@ -426,8 +439,12 @@ public Response patchLineageEdge( JsonPatch patch) { authorizer.authorize( securityContext, - new OperationContext(LINEAGE_FIELD, MetadataOperation.EDIT_LINEAGE), - new LineageResourceContext()); + new OperationContext(fromEntity, MetadataOperation.EDIT_LINEAGE), + new ResourceContext<>(fromEntity, fromId, null)); + authorizer.authorize( + securityContext, + new OperationContext(toEntity, MetadataOperation.EDIT_LINEAGE), + new ResourceContext<>(toEntity, toId, null)); return dao.patchLineageEdge(fromEntity, fromId, toEntity, toId, patch); } @@ -467,8 +484,12 @@ public Response deleteLineage( String toId) { authorizer.authorize( securityContext, - new OperationContext(LINEAGE_FIELD, MetadataOperation.EDIT_LINEAGE), - new LineageResourceContext()); + new OperationContext(fromEntity, MetadataOperation.EDIT_LINEAGE), + new ResourceContext<>(fromEntity, UUID.fromString(fromId), null)); + authorizer.authorize( + securityContext, + new OperationContext(toEntity, MetadataOperation.EDIT_LINEAGE), + new ResourceContext<>(toEntity, UUID.fromString(toId), null)); boolean deleted = dao.deleteLineage(fromEntity, fromId, toEntity, toId); if (!deleted) { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/RuleEvaluator.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/RuleEvaluator.java index eef29bc8d2f7..4c5bff12f9c5 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/RuleEvaluator.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/RuleEvaluator.java @@ -144,15 +144,17 @@ public boolean matchAnyTag(String... tagFQNs) { return false; } List tags = resourceContext.getTags(); - LOG.debug( - "matchAnyTag {} resourceTags {}", - Arrays.toString(tagFQNs), - Arrays.toString(tags.toArray())); - for (String tagFQN : tagFQNs) { - TagLabel found = - tags.stream().filter(t -> t.getTagFQN().equals(tagFQN)).findAny().orElse(null); - if (found != null) { - return true; + if (!nullOrEmpty(tags)) { + LOG.debug( + "matchAnyTag {} resourceTags {}", + Arrays.toString(tagFQNs), + Arrays.toString(tags.toArray())); + for (String tagFQN : tagFQNs) { + TagLabel found = + tags.stream().filter(t -> t.getTagFQN().equals(tagFQN)).findAny().orElse(null); + if (found != null) { + return true; + } } } return false; From c544045cfe6fd3eb3b515f52c6d3efe9e334d744 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Tue, 7 Jan 2025 07:39:46 -0800 Subject: [PATCH 094/294] Issue-19066: ViewAll() not working with matchAnyTag() and isOwner() conditions (#19209) * Issue-19066: ViewAll() not working with matchAnyTag() and isOwner() conditions * Issue-19066: ViewAll() not working with matchAnyTag() and isOwner() conditions * Fix tests * Minor: Fix checkstyle --------- Co-authored-by: Aniket Katkar --- .../java/org/openmetadata/service/Entity.java | 27 +++++++++++++++++++ .../service/jdbi3/ListFilter.java | 20 ++++++++++++++ .../service/resources/EntityResource.java | 4 +-- .../policyevaluator/CompiledRule.java | 7 ++--- 4 files changed, 53 insertions(+), 5 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java b/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java index 97b2b5e1307d..5b3d52970f0b 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java @@ -250,6 +250,8 @@ public final class Entity { public static final String DOCUMENT = "document"; // ServiceType - Service Entity name map static final Map SERVICE_TYPE_ENTITY_MAP = new EnumMap<>(ServiceType.class); + // entity type to service entity name map + static final Map ENTITY_SERVICE_TYPE_MAP = new HashMap<>(); public static final List PARENT_ENTITY_TYPES = new ArrayList<>(); static { @@ -262,6 +264,24 @@ public final class Entity { SERVICE_TYPE_ENTITY_MAP.put(ServiceType.STORAGE, STORAGE_SERVICE); SERVICE_TYPE_ENTITY_MAP.put(ServiceType.SEARCH, SEARCH_SERVICE); SERVICE_TYPE_ENTITY_MAP.put(ServiceType.API, API_SERVICE); + + ENTITY_SERVICE_TYPE_MAP.put(DATABASE, DATABASE_SERVICE); + ENTITY_SERVICE_TYPE_MAP.put(DATABASE_SCHEMA, DATABASE_SERVICE); + ENTITY_SERVICE_TYPE_MAP.put(TABLE, DATABASE_SERVICE); + ENTITY_SERVICE_TYPE_MAP.put(STORED_PROCEDURE, DATABASE_SERVICE); + ENTITY_SERVICE_TYPE_MAP.put(QUERY, DATABASE_SERVICE); + ENTITY_SERVICE_TYPE_MAP.put(DASHBOARD, DASHBOARD_SERVICE); + ENTITY_SERVICE_TYPE_MAP.put(DASHBOARD_DATA_MODEL, DASHBOARD_SERVICE); + ENTITY_SERVICE_TYPE_MAP.put(CHART, DASHBOARD_SERVICE); + ENTITY_SERVICE_TYPE_MAP.put(PIPELINE, PIPELINE_SERVICE); + ENTITY_SERVICE_TYPE_MAP.put(MLMODEL, MLMODEL_SERVICE); + ENTITY_SERVICE_TYPE_MAP.put(TOPIC, MESSAGING_SERVICE); + ENTITY_SERVICE_TYPE_MAP.put(API, API_SERVICE); + ENTITY_SERVICE_TYPE_MAP.put(API_COLLCECTION, API_SERVICE); + ENTITY_SERVICE_TYPE_MAP.put(API_ENDPOINT, API_SERVICE); + ENTITY_SERVICE_TYPE_MAP.put(CONTAINER, STORAGE_SERVICE); + ENTITY_SERVICE_TYPE_MAP.put(SEARCH_INDEX, SEARCH_SERVICE); + PARENT_ENTITY_TYPES.addAll( listOf( DATABASE_SERVICE, @@ -639,4 +659,11 @@ public static T getDao() { public static T getSearchRepo() { return (T) searchRepository; } + + public static String getServiceType(String entityType) { + if (ENTITY_SERVICE_TYPE_MAP.containsKey(entityType)) { + return ENTITY_SERVICE_TYPE_MAP.get(entityType); + } + return entityType; + } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ListFilter.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ListFilter.java index e402dc5f38b7..425c49ed2548 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ListFilter.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ListFilter.java @@ -11,6 +11,7 @@ import org.openmetadata.schema.utils.EntityInterfaceUtil; import org.openmetadata.service.Entity; import org.openmetadata.service.resources.databases.DatasourceConfig; +import org.openmetadata.service.security.policyevaluator.ResourceContext; import org.openmetadata.service.util.FullyQualifiedName; public class ListFilter extends Filter { @@ -50,6 +51,25 @@ public String getCondition(String tableName) { return condition.isEmpty() ? "WHERE TRUE" : "WHERE " + condition; } + public ResourceContext getResourceContext(String entityType) { + if (queryParams.containsKey("service") && queryParams.get("service") != null) { + return new ResourceContext<>( + Entity.getServiceType(entityType), null, queryParams.get("service")); + } else if (queryParams.containsKey(Entity.DATABASE) + && queryParams.get(Entity.DATABASE) != null) { + return new ResourceContext<>(Entity.DATABASE, null, queryParams.get(Entity.DATABASE)); + } else if (queryParams.containsKey(Entity.DATABASE_SCHEMA) + && queryParams.get(Entity.DATABASE_SCHEMA) != null) { + return new ResourceContext<>( + Entity.DATABASE_SCHEMA, null, queryParams.get(Entity.DATABASE_SCHEMA)); + } else if (queryParams.containsKey(Entity.API_COLLCECTION) + && queryParams.get(Entity.API_COLLCECTION) != null) { + return new ResourceContext<>( + Entity.API_COLLCECTION, null, queryParams.get(Entity.API_COLLCECTION)); + } + return new ResourceContext<>(entityType); + } + private String getAssignee() { String assignee = queryParams.get("assignee"); return assignee == null ? "" : String.format("assignee = '%s'", assignee); 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 740daa839b77..74733269e64e 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 @@ -144,7 +144,7 @@ public ResultList listInternal( Fields fields = getFields(fieldsParam); OperationContext listOperationContext = new OperationContext(entityType, getViewOperations(fields)); - + ResourceContext resourceContext = filter.getResourceContext(entityType); return listInternal( uriInfo, securityContext, @@ -154,7 +154,7 @@ public ResultList listInternal( before, after, listOperationContext, - getResourceContext()); + resourceContext); } public ResultList listInternal( diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/CompiledRule.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/CompiledRule.java index 659cb9fb23dd..2a2c25c2c0a6 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/CompiledRule.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/CompiledRule.java @@ -197,16 +197,17 @@ protected boolean matchResource(String resource) { } private boolean matchOperation(MetadataOperation operation) { - if (getOperations().contains(MetadataOperation.ALL)) { + List operations = getOperations(); + if (operations.contains(MetadataOperation.ALL)) { LOG.debug("matched all operations"); return true; // Match all operations } - if (getOperations().contains(MetadataOperation.EDIT_ALL) + if (operations.contains(MetadataOperation.EDIT_ALL) && OperationContext.isEditOperation(operation)) { LOG.debug("matched editAll operations"); return true; } - if (getOperations().contains(MetadataOperation.VIEW_ALL) + if (operations.contains(MetadataOperation.VIEW_ALL) && OperationContext.isViewOperation(operation)) { LOG.debug("matched viewAll operations"); return true; From 2745552d005583ba213aab12d691e76c7bd3ff42 Mon Sep 17 00:00:00 2001 From: Mohit Yadav <105265192+mohityadav766@users.noreply.github.com> Date: Thu, 9 Jan 2025 00:55:13 +0530 Subject: [PATCH 095/294] Default App Config to all (#19291) (cherry picked from commit 78e8d360a521c9e207fb28b4da86338d59d518e1) --- .../data/app/SearchIndexingApplication.json | 42 +------------------ .../SearchIndexingApplication.json | 42 +------------------ 2 files changed, 2 insertions(+), 82 deletions(-) diff --git a/openmetadata-service/src/main/resources/json/data/app/SearchIndexingApplication.json b/openmetadata-service/src/main/resources/json/data/app/SearchIndexingApplication.json index 015d8f6ede2a..debe210dba62 100644 --- a/openmetadata-service/src/main/resources/json/data/app/SearchIndexingApplication.json +++ b/openmetadata-service/src/main/resources/json/data/app/SearchIndexingApplication.json @@ -3,47 +3,7 @@ "displayName": "Search Indexing", "appConfiguration": { "entities": [ - "table", - "dashboard", - "topic", - "pipeline", - "ingestionPipeline", - "searchIndex", - "user", - "team", - "glossary", - "glossaryTerm", - "mlmodel", - "tag", - "classification", - "query", - "container", - "database", - "databaseSchema", - "testCase", - "testSuite", - "chart", - "dashboardDataModel", - "databaseService", - "messagingService", - "dashboardService", - "pipelineService", - "mlmodelService", - "storageService", - "metadataService", - "searchService", - "entityReportData", - "webAnalyticEntityViewReportData", - "webAnalyticUserActivityReportData", - "domain", - "storedProcedure", - "dataProduct", - "testCaseResolutionStatus", - "testCaseResult", - "apiService", - "apiEndpoint", - "apiCollection", - "metric" + "all" ], "recreateIndex": false, "batchSize": "100", diff --git a/openmetadata-service/src/main/resources/json/data/appMarketPlaceDefinition/SearchIndexingApplication.json b/openmetadata-service/src/main/resources/json/data/appMarketPlaceDefinition/SearchIndexingApplication.json index 7ec557701485..5f323da477b0 100644 --- a/openmetadata-service/src/main/resources/json/data/appMarketPlaceDefinition/SearchIndexingApplication.json +++ b/openmetadata-service/src/main/resources/json/data/appMarketPlaceDefinition/SearchIndexingApplication.json @@ -18,47 +18,7 @@ "supportsInterrupt": true, "appConfiguration": { "entities": [ - "table", - "dashboard", - "topic", - "pipeline", - "ingestionPipeline", - "searchIndex", - "user", - "team", - "glossary", - "glossaryTerm", - "mlmodel", - "tag", - "classification", - "query", - "container", - "database", - "databaseSchema", - "testCase", - "testSuite", - "chart", - "dashboardDataModel", - "databaseService", - "messagingService", - "dashboardService", - "pipelineService", - "mlmodelService", - "storageService", - "metadataService", - "searchService", - "entityReportData", - "webAnalyticEntityViewReportData", - "webAnalyticUserActivityReportData", - "domain", - "storedProcedure", - "dataProduct", - "testCaseResolutionStatus", - "testCaseResult", - "apiService", - "apiEndpoint", - "apiCollection", - "metric" + "all" ], "recreateIndex": false, "batchSize": "100", From bfcbdd3b3870e3f0ab1b07c886d646c71a5b6c71 Mon Sep 17 00:00:00 2001 From: Imri Paran Date: Thu, 9 Jan 2025 04:34:17 +0200 Subject: [PATCH 096/294] fix: reverted deprecated fields on testSuite (#19284) Added deprecated fields with a notice. This is to ensure migrations that use them do not break. (cherry picked from commit 7d059029458833ff4d6569c029fb57e2a188a34c) --- .../annotations/DeprecatedAnnotator.java | 76 +++++++++++++++++++ .../annotations/OpenMetadataAnnotator.java | 6 +- .../migration/utils/V112/MigrationUtil.java | 5 +- .../migration/utils/V114/MigrationUtil.java | 20 ++--- .../migration/utils/v110/MigrationUtil.java | 15 ++-- .../utils/v111/MigrationUtilV111.java | 12 +-- .../schema/api/tests/createTestSuite.json | 5 ++ .../json/schema/tests/testSuite.json | 10 +++ .../generated/api/tests/createTestSuite.ts | 4 + .../ui/src/generated/tests/testSuite.ts | 10 +++ 10 files changed, 137 insertions(+), 26 deletions(-) create mode 100644 common/src/main/java/org/openmetadata/annotations/DeprecatedAnnotator.java diff --git a/common/src/main/java/org/openmetadata/annotations/DeprecatedAnnotator.java b/common/src/main/java/org/openmetadata/annotations/DeprecatedAnnotator.java new file mode 100644 index 000000000000..98e568274a37 --- /dev/null +++ b/common/src/main/java/org/openmetadata/annotations/DeprecatedAnnotator.java @@ -0,0 +1,76 @@ +package org.openmetadata.annotations; + +import com.fasterxml.jackson.databind.JsonNode; +import com.sun.codemodel.JAnnotationUse; +import com.sun.codemodel.JClass; +import com.sun.codemodel.JDefinedClass; +import com.sun.codemodel.JFieldVar; +import com.sun.codemodel.JMethod; +import java.lang.reflect.Field; +import java.util.TreeMap; +import org.jsonschema2pojo.AbstractAnnotator; + +/** Add {@link Deprecated} annotation to generated Java classes */ +public class DeprecatedAnnotator extends AbstractAnnotator { + + /** Add {@link Deprecated} annotation to property fields */ + @Override + public void propertyField( + JFieldVar field, JDefinedClass clazz, String propertyName, JsonNode propertyNode) { + super.propertyField(field, clazz, propertyName, propertyNode); + if (propertyNode.get("deprecated") != null && propertyNode.get("deprecated").asBoolean()) { + field.annotate(Deprecated.class); + } + } + + /** Add {@link Deprecated} annotation to getter methods */ + @Override + public void propertyGetter(JMethod getter, JDefinedClass clazz, String propertyName) { + super.propertyGetter(getter, clazz, propertyName); + addDeprecatedAnnotationIfApplies(getter, propertyName); + } + + /** Add {@link Deprecated} annotation to setter methods */ + @Override + public void propertySetter(JMethod setter, JDefinedClass clazz, String propertyName) { + super.propertySetter(setter, clazz, propertyName); + addDeprecatedAnnotationIfApplies(setter, propertyName); + } + + /** + * Use reflection methods to access the {@link JDefinedClass} of the {@link JMethod} object. If + * the {@link JMethod} is pointing to a field annotated with {@link Deprecated} then annotates + * the {@link JMethod} object with {@link Deprecated} + */ + private void addDeprecatedAnnotationIfApplies(JMethod jMethod, String propertyName) { + try { + Field outerClassField = JMethod.class.getDeclaredField("outer"); + outerClassField.setAccessible(true); + JDefinedClass outerClass = (JDefinedClass) outerClassField.get(jMethod); + + TreeMap insensitiveFieldsMap = + new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + insensitiveFieldsMap.putAll(outerClass.fields()); + + if (insensitiveFieldsMap.containsKey(propertyName) + && insensitiveFieldsMap.get(propertyName).annotations().stream() + .anyMatch( + annotation -> + Deprecated.class.getName().equals(getAnnotationClassName(annotation)))) { + jMethod.annotate(Deprecated.class); + } + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + } + + private String getAnnotationClassName(JAnnotationUse annotation) { + try { + Field clazzField = JAnnotationUse.class.getDeclaredField("clazz"); + clazzField.setAccessible(true); + return ((JClass) clazzField.get(annotation)).fullName(); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + } +} diff --git a/common/src/main/java/org/openmetadata/annotations/OpenMetadataAnnotator.java b/common/src/main/java/org/openmetadata/annotations/OpenMetadataAnnotator.java index 15f0bcd948be..79209d8515d5 100644 --- a/common/src/main/java/org/openmetadata/annotations/OpenMetadataAnnotator.java +++ b/common/src/main/java/org/openmetadata/annotations/OpenMetadataAnnotator.java @@ -19,6 +19,10 @@ public class OpenMetadataAnnotator extends CompositeAnnotator { public OpenMetadataAnnotator() { // we can add multiple annotators - super(new ExposedAnnotator(), new MaskedAnnotator(), new PasswordAnnotator()); + super( + new ExposedAnnotator(), + new MaskedAnnotator(), + new PasswordAnnotator(), + new DeprecatedAnnotator()); } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/migration/utils/V112/MigrationUtil.java b/openmetadata-service/src/main/java/org/openmetadata/service/migration/utils/V112/MigrationUtil.java index a97b3e9978a7..1c4c0cd0065f 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/migration/utils/V112/MigrationUtil.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/migration/utils/V112/MigrationUtil.java @@ -25,8 +25,9 @@ public static void fixExecutableTestSuiteFQN(CollectionDAO collectionDAO) { testSuiteRepository.listAll( new EntityUtil.Fields(Set.of("id")), new ListFilter(Include.ALL)); for (TestSuite suite : testSuites) { - if (Boolean.TRUE.equals(suite.getBasic()) && suite.getBasicEntityReference() != null) { - String tableFQN = suite.getBasicEntityReference().getFullyQualifiedName(); + if (Boolean.TRUE.equals(suite.getExecutable()) + && suite.getExecutableEntityReference() != null) { + String tableFQN = suite.getExecutableEntityReference().getFullyQualifiedName(); String suiteFQN = tableFQN + ".testSuite"; suite.setName(suiteFQN); suite.setFullyQualifiedName(suiteFQN); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/migration/utils/V114/MigrationUtil.java b/openmetadata-service/src/main/java/org/openmetadata/service/migration/utils/V114/MigrationUtil.java index 06b0d6b80039..a9a4c74769f1 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/migration/utils/V114/MigrationUtil.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/migration/utils/V114/MigrationUtil.java @@ -48,13 +48,13 @@ public static void fixTestSuites(CollectionDAO collectionDAO) { testSuiteRepository.listAll( new EntityUtil.Fields(Set.of("id")), new ListFilter(Include.ALL)); for (TestSuite suite : testSuites) { - if (suite.getBasicEntityReference() != null - && (!suite.getBasic() || !suite.getFullyQualifiedName().contains("testSuite"))) { - String tableFQN = suite.getBasicEntityReference().getFullyQualifiedName(); + if (suite.getExecutableEntityReference() != null + && (!suite.getExecutable() || !suite.getFullyQualifiedName().contains("testSuite"))) { + String tableFQN = suite.getExecutableEntityReference().getFullyQualifiedName(); String suiteFQN = tableFQN + ".testSuite"; suite.setName(suiteFQN); suite.setFullyQualifiedName(suiteFQN); - suite.setBasic(true); + suite.setExecutable(true); collectionDAO.testSuiteDAO().update(suite); } } @@ -80,7 +80,7 @@ public static void fixTestSuites(CollectionDAO collectionDAO) { try { TestSuite existingTestSuite = testSuiteRepository.getDao().findEntityById(existingTestSuiteRel.getId()); - if (Boolean.TRUE.equals(existingTestSuite.getBasic()) + if (Boolean.TRUE.equals(existingTestSuite.getExecutable()) && existingTestSuite.getFullyQualifiedName().equals(executableTestSuiteFQN)) { // There is a native test suite associated with this testCase. relationWithExecutableTestSuiteExists = true; @@ -111,7 +111,7 @@ public static void fixTestSuites(CollectionDAO collectionDAO) { // check from table -> nativeTestSuite there should only one relation List testSuiteRels = testSuiteRepository.findToRecords( - executableTestSuite.getBasicEntityReference().getId(), + executableTestSuite.getExecutableEntityReference().getId(), TABLE, Relationship.CONTAINS, TEST_SUITE); @@ -122,7 +122,7 @@ public static void fixTestSuites(CollectionDAO collectionDAO) { // if testsuite cannot be retrieved but the relation exists, then this is orphaned // relation, we will delete the relation testSuiteRepository.deleteRelationship( - executableTestSuite.getBasicEntityReference().getId(), + executableTestSuite.getExecutableEntityReference().getId(), TABLE, testSuiteRel.getId(), TEST_SUITE, @@ -158,9 +158,9 @@ private static TestSuite getOrCreateExecutableTestSuite( new CreateTestSuite() .withName(FullyQualifiedName.buildHash(executableTestSuiteFQN)) .withDisplayName(executableTestSuiteFQN) - .withBasicEntityReference(entityLink.getEntityFQN()), + .withExecutableEntityReference(entityLink.getEntityFQN()), "ingestion-bot") - .withBasic(true) + .withExecutable(true) .withFullyQualifiedName(executableTestSuiteFQN); testSuiteRepository.prepareInternal(newExecutableTestSuite, false); testSuiteRepository @@ -169,7 +169,7 @@ private static TestSuite getOrCreateExecutableTestSuite( "fqnHash", newExecutableTestSuite, newExecutableTestSuite.getFullyQualifiedName()); // add relationship between executable TestSuite with Table testSuiteRepository.addRelationship( - newExecutableTestSuite.getBasicEntityReference().getId(), + newExecutableTestSuite.getExecutableEntityReference().getId(), newExecutableTestSuite.getId(), Entity.TABLE, TEST_SUITE, diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/migration/utils/v110/MigrationUtil.java b/openmetadata-service/src/main/java/org/openmetadata/service/migration/utils/v110/MigrationUtil.java index e719728ab85e..fd815283029a 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/migration/utils/v110/MigrationUtil.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/migration/utils/v110/MigrationUtil.java @@ -460,16 +460,17 @@ public static TestSuite getTestSuite(CollectionDAO dao, CreateTestSuite create, .withDescription(create.getDescription()) .withDisplayName(create.getDisplayName()) .withName(create.getName()); - if (create.getBasicEntityReference() != null) { + if (create.getExecutableEntityReference() != null) { Table table = - Entity.getEntityByName(Entity.TABLE, create.getBasicEntityReference(), "", Include.ALL); + Entity.getEntityByName( + Entity.TABLE, create.getExecutableEntityReference(), "", Include.ALL); EntityReference entityReference = new EntityReference() .withId(table.getId()) .withFullyQualifiedName(table.getFullyQualifiedName()) .withName(table.getName()) .withType(Entity.TABLE); - testSuite.setBasicEntityReference(entityReference); + testSuite.setExecutableEntityReference(entityReference); } return testSuite; } @@ -516,9 +517,9 @@ public static void testSuitesMigration(CollectionDAO collectionDAO) { new CreateTestSuite() .withName(FullyQualifiedName.buildHash(nativeTestSuiteFqn)) .withDisplayName(nativeTestSuiteFqn) - .withBasicEntityReference(entityLink.getEntityFQN()), + .withExecutableEntityReference(entityLink.getEntityFQN()), "ingestion-bot") - .withBasic(true) + .withExecutable(true) .withFullyQualifiedName(nativeTestSuiteFqn); testSuiteRepository.prepareInternal(newExecutableTestSuite, false); try { @@ -533,7 +534,7 @@ public static void testSuitesMigration(CollectionDAO collectionDAO) { } // add relationship between executable TestSuite with Table testSuiteRepository.addRelationship( - newExecutableTestSuite.getBasicEntityReference().getId(), + newExecutableTestSuite.getExecutableEntityReference().getId(), newExecutableTestSuite.getId(), Entity.TABLE, TEST_SUITE, @@ -564,7 +565,7 @@ private static void migrateExistingTestSuitesToLogical(CollectionDAO collectionD ListFilter filter = new ListFilter(Include.ALL); List testSuites = testSuiteRepository.listAll(new Fields(Set.of("id")), filter); for (TestSuite testSuite : testSuites) { - testSuite.setBasic(false); + testSuite.setExecutable(false); List ingestionPipelineRecords = collectionDAO .relationshipDAO() diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/migration/utils/v111/MigrationUtilV111.java b/openmetadata-service/src/main/java/org/openmetadata/service/migration/utils/v111/MigrationUtilV111.java index a2a368d22bcf..0bffc8871e82 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/migration/utils/v111/MigrationUtilV111.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/migration/utils/v111/MigrationUtilV111.java @@ -106,16 +106,16 @@ public static void runTestSuiteMigration( suite = JsonUtils.readValue(pgObject.getValue(), TestSuite.class); } // Only Test Suite which are executable needs to be updated - if (Boolean.TRUE.equals(suite.getBasic())) { - if (suite.getBasicEntityReference() != null) { + if (Boolean.TRUE.equals(suite.getExecutable())) { + if (suite.getExecutableEntityReference() != null) { updateTestSuite(handle, suite, updateSql); } else { String entityName = StringUtils.replaceOnce(suite.getDisplayName(), ".testSuite", ""); try { Table table = collectionDAO.tableDAO().findEntityByName(entityName, Include.ALL); // Update Test Suite - suite.setBasic(true); - suite.setBasicEntityReference(table.getEntityReference()); + suite.setExecutable(true); + suite.setExecutableEntityReference(table.getEntityReference()); updateTestSuite(handle, suite, updateSql); removeDuplicateTestCases(collectionDAO, handle, getSql); } catch (Exception ex) { @@ -133,9 +133,9 @@ public static void runTestSuiteMigration( } public static void updateTestSuite(Handle handle, TestSuite suite, String updateSql) { - if (suite.getBasicEntityReference() != null) { + if (suite.getExecutableEntityReference() != null) { try { - EntityReference executableEntityRef = suite.getBasicEntityReference(); + EntityReference executableEntityRef = suite.getExecutableEntityReference(); // Run new Migrations suite.setName(String.format("%s.testSuite", executableEntityRef.getName())); suite.setFullyQualifiedName( diff --git a/openmetadata-spec/src/main/resources/json/schema/api/tests/createTestSuite.json b/openmetadata-spec/src/main/resources/json/schema/api/tests/createTestSuite.json index d20860176143..1d01b9571108 100644 --- a/openmetadata-spec/src/main/resources/json/schema/api/tests/createTestSuite.json +++ b/openmetadata-spec/src/main/resources/json/schema/api/tests/createTestSuite.json @@ -37,6 +37,11 @@ "description": "Entity reference the test suite needs to execute the test against. Only applicable if the test suite is basic.", "$ref": "../../type/basic.json#/definitions/fullyQualifiedEntityName" }, + "executableEntityReference": { + "description": "DEPRECATED in 1.6.2: use 'basicEntityReference'", + "$ref": "../../type/basic.json#/definitions/fullyQualifiedEntityName", + "deprecated": true + }, "domain": { "description": "Fully qualified name of the domain the Table belongs to.", "type": "string" diff --git a/openmetadata-spec/src/main/resources/json/schema/tests/testSuite.json b/openmetadata-spec/src/main/resources/json/schema/tests/testSuite.json index fc4d2c0efb24..87d7ab558d13 100644 --- a/openmetadata-spec/src/main/resources/json/schema/tests/testSuite.json +++ b/openmetadata-spec/src/main/resources/json/schema/tests/testSuite.json @@ -125,10 +125,20 @@ "type": "boolean", "default": false }, + "executable": { + "description": "DEPRECATED in 1.6.2: Use 'basic'", + "type": "boolean", + "deprecated": true + }, "basicEntityReference": { "description": "Entity reference the test suite needs to execute the test against. Only applicable if the test suite is basic.", "$ref": "../type/entityReference.json" }, + "executableEntityReference": { + "description": "DEPRECATED in 1.6.2: Use 'basicEntityReference'.", + "$ref": "../type/entityReference.json", + "deprecated": true + }, "summary": { "description": "Summary of the previous day test cases execution for this test suite.", "$ref": "./basic.json#/definitions/testSummary" diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/tests/createTestSuite.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/tests/createTestSuite.ts index a62d69b4dc13..7ce9141645a9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/api/tests/createTestSuite.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/tests/createTestSuite.ts @@ -31,6 +31,10 @@ export interface CreateTestSuite { * Fully qualified name of the domain the Table belongs to. */ domain?: string; + /** + * DEPRECATED in 1.6.2: use 'basicEntityReference' + */ + executableEntityReference?: string; /** * Name that identifies this test suite. */ diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/tests/testSuite.ts b/openmetadata-ui/src/main/resources/ui/src/generated/tests/testSuite.ts index ab6d7ed391d3..9cb2a6ca828a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/tests/testSuite.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/tests/testSuite.ts @@ -50,6 +50,14 @@ export interface TestSuite { * the table it belongs to. */ domain?: EntityReference; + /** + * DEPRECATED in 1.6.2: Use 'basic' + */ + executable?: boolean; + /** + * DEPRECATED in 1.6.2: Use 'basicEntityReference'. + */ + executableEntityReference?: EntityReference; /** * FullyQualifiedName same as `name`. */ @@ -127,6 +135,8 @@ export interface TestSuite { * Domain the test Suite belongs to. When not set, the test Suite inherits the domain from * the table it belongs to. * + * DEPRECATED in 1.6.2: Use 'basicEntityReference'. + * * Owners of this TestCase definition. * * This schema defines the EntityReferenceList type used for referencing an entity. From f5d2b0167e5b0ff8819944f7269e23588c890232 Mon Sep 17 00:00:00 2001 From: Siddhant <86899184+Siddhanttimeline@users.noreply.github.com> Date: Thu, 19 Dec 2024 10:40:49 +0530 Subject: [PATCH 097/294] fix: Mask email password (#19120) --- .../org/openmetadata/service/jdbi3/SystemRepository.java | 7 +++++++ .../service/resources/system/SystemResourceTest.java | 7 ++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SystemRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SystemRepository.java index 01b7d76d12f9..fbcd2118163d 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SystemRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SystemRepository.java @@ -42,6 +42,7 @@ import org.openmetadata.service.search.SearchRepository; import org.openmetadata.service.secrets.SecretsManager; import org.openmetadata.service.secrets.SecretsManagerFactory; +import org.openmetadata.service.secrets.masker.PasswordEntityMasker; import org.openmetadata.service.security.JwtFilter; import org.openmetadata.service.security.auth.LoginAttemptCache; import org.openmetadata.service.util.JsonUtils; @@ -108,6 +109,12 @@ public Settings getConfigWithKey(String key) { return null; } + if (fetchedSettings.getConfigType() == SettingsType.EMAIL_CONFIGURATION) { + SmtpSettings emailConfig = (SmtpSettings) fetchedSettings.getConfigValue(); + emailConfig.setPassword(PasswordEntityMasker.PASSWORD_MASK); + fetchedSettings.setConfigValue(emailConfig); + } + return fetchedSettings; } catch (Exception ex) { LOG.error("Error while trying fetch Settings ", ex); diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/system/SystemResourceTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/system/SystemResourceTest.java index 686de7c1bd3a..d7cc9d04cddd 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/resources/system/SystemResourceTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/system/SystemResourceTest.java @@ -81,6 +81,7 @@ import org.openmetadata.service.resources.teams.TeamResourceTest; import org.openmetadata.service.resources.teams.UserResourceTest; import org.openmetadata.service.resources.topics.TopicResourceTest; +import org.openmetadata.service.secrets.masker.PasswordEntityMasker; import org.openmetadata.service.util.JsonUtils; import org.openmetadata.service.util.TestUtils; @@ -190,10 +191,10 @@ void testSystemConfigs() throws HttpResponseException { // Test Email Config Settings emailSettings = getSystemConfig(SettingsType.EMAIL_CONFIGURATION); SmtpSettings smtp = JsonUtils.convertValue(emailSettings.getConfigValue(), SmtpSettings.class); - // Password for Email is encrypted using fernet + // Password for Email is always sent in hidden SmtpSettings expected = config.getSmtpSettings(); - expected.setPassword(smtp.getPassword()); - assertEquals(config.getSmtpSettings(), smtp); + expected.setPassword(PasswordEntityMasker.PASSWORD_MASK); + assertEquals(expected, smtp); // Test Custom Ui Theme Preference Config Settings uiThemeConfigWrapped = getSystemConfig(SettingsType.CUSTOM_UI_THEME_PREFERENCE); From e02ab41942ca6e1e8b030e7b843b370b928fd820 Mon Sep 17 00:00:00 2001 From: sonika-shah <58761340+sonika-shah@users.noreply.github.com> Date: Tue, 7 Jan 2025 12:10:36 +0530 Subject: [PATCH 098/294] Persist Knowledge Page hierarchy state on refresh (#19236) --- .../java/org/openmetadata/service/search/SearchClient.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchClient.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchClient.java index d764b11a8b00..7e8be5eeb471 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchClient.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchClient.java @@ -212,7 +212,8 @@ Response searchSchemaEntityRelationship( Used for listing knowledge page hierarchy for a given parent and page type, used in Elastic/Open SearchClientExtension */ @SuppressWarnings("unused") - default ResultList listPageHierarchy(String parent, String pageType, int offset, int limit) { + default ResultList listPageHierarchy( + String parent, String activeFqn, String pageType, int offset, int limit) { throw new CustomExceptionMessage( Response.Status.NOT_IMPLEMENTED, NOT_IMPLEMENTED_ERROR_TYPE, NOT_IMPLEMENTED_METHOD); } From 249b9eeb2ded66c5bf1ebbc68820a7dfe3bcca80 Mon Sep 17 00:00:00 2001 From: Aniket Katkar Date: Thu, 9 Jan 2025 12:45:24 +0530 Subject: [PATCH 099/294] revert Persist Knowledge Page hierarchy state on refresh --- .../java/org/openmetadata/service/search/SearchClient.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchClient.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchClient.java index 7e8be5eeb471..d764b11a8b00 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchClient.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchClient.java @@ -212,8 +212,7 @@ Response searchSchemaEntityRelationship( Used for listing knowledge page hierarchy for a given parent and page type, used in Elastic/Open SearchClientExtension */ @SuppressWarnings("unused") - default ResultList listPageHierarchy( - String parent, String activeFqn, String pageType, int offset, int limit) { + default ResultList listPageHierarchy(String parent, String pageType, int offset, int limit) { throw new CustomExceptionMessage( Response.Status.NOT_IMPLEMENTED, NOT_IMPLEMENTED_ERROR_TYPE, NOT_IMPLEMENTED_METHOD); } From 875762460ef16c5198b8d706725dc02c33cfa6ef Mon Sep 17 00:00:00 2001 From: Mayur Singal <39544459+ulixius9@users.noreply.github.com> Date: Thu, 9 Jan 2025 15:21:04 +0530 Subject: [PATCH 100/294] MINOR: Implement pagination in pipeline lineage render (#19295) --- .../elasticsearch/ElasticSearchClient.java | 114 ++++++++++-------- .../search/opensearch/OpenSearchClient.java | 112 +++++++++-------- 2 files changed, 132 insertions(+), 94 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java index 50a2248f9312..2378e7127afd 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java @@ -1257,56 +1257,76 @@ private Map searchPipelineLineage( Set visitedFQN = new HashSet<>(); Set> edges = new HashSet<>(); Set> nodes = new HashSet<>(); - es.org.elasticsearch.action.search.SearchRequest searchRequest = - new es.org.elasticsearch.action.search.SearchRequest( - Entity.getSearchRepository().getIndexOrAliasName(GLOBAL_SEARCH_ALIAS)); - es.org.elasticsearch.index.query.BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery(); - boolQueryBuilder.should( - QueryBuilders.boolQuery() - .must(QueryBuilders.termQuery("lineage.pipeline.fullyQualifiedName.keyword", fqn))); - SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder(); - searchSourceBuilder.fetchSource(null, SOURCE_FIELDS_TO_EXCLUDE.toArray(String[]::new)); - searchSourceBuilder.query(boolQueryBuilder); - if (CommonUtil.nullOrEmpty(deleted)) { - searchSourceBuilder.query( + Object[] searchAfter = null; + long processedRecords = 0; + long totalRecords = -1; + while (totalRecords != processedRecords) { + es.org.elasticsearch.action.search.SearchRequest searchRequest = + new es.org.elasticsearch.action.search.SearchRequest( + Entity.getSearchRepository().getIndexOrAliasName(GLOBAL_SEARCH_ALIAS)); + es.org.elasticsearch.index.query.BoolQueryBuilder boolQueryBuilder = + QueryBuilders.boolQuery(); + boolQueryBuilder.should( QueryBuilders.boolQuery() - .must(boolQueryBuilder) - .must(QueryBuilders.termQuery("deleted", deleted))); - } - buildSearchSourceFilter(queryFilter, searchSourceBuilder); - searchRequest.source(searchSourceBuilder); - SearchResponse searchResponse = client.search(searchRequest, RequestOptions.DEFAULT); - for (var hit : searchResponse.getHits().getHits()) { - List> lineage = - (List>) hit.getSourceAsMap().get("lineage"); - HashMap tempMap = new HashMap<>(JsonUtils.getMap(hit.getSourceAsMap())); - nodes.add(tempMap); - for (Map lin : lineage) { - HashMap fromEntity = (HashMap) lin.get("fromEntity"); - HashMap toEntity = (HashMap) lin.get("toEntity"); - HashMap pipeline = (HashMap) lin.get("pipeline"); - if (pipeline != null && pipeline.get("fullyQualifiedName").equalsIgnoreCase(fqn)) { - edges.add(lin); - getLineage( - fromEntity.get("fqn"), - visitedFQN, - upstreamDepth, - edges, - nodes, - queryFilter, - "lineage.toEntity.fqn.keyword", - deleted); - getLineage( - toEntity.get("fqn"), - visitedFQN, - downstreamDepth, - edges, - nodes, - queryFilter, - "lineage.fromEntity.fqn.keyword", - deleted); + .must(QueryBuilders.termQuery("lineage.pipeline.fullyQualifiedName.keyword", fqn))); + SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder(); + searchSourceBuilder.fetchSource(null, SOURCE_FIELDS_TO_EXCLUDE.toArray(String[]::new)); + FieldSortBuilder sortBuilder = SortBuilders.fieldSort("fullyQualifiedName"); + searchSourceBuilder.sort(sortBuilder); + searchSourceBuilder.query(boolQueryBuilder); + if (searchAfter != null) { + searchSourceBuilder.searchAfter(searchAfter); + } + if (CommonUtil.nullOrEmpty(deleted)) { + searchSourceBuilder.query( + QueryBuilders.boolQuery() + .must(boolQueryBuilder) + .must(QueryBuilders.termQuery("deleted", deleted))); + } + buildSearchSourceFilter(queryFilter, searchSourceBuilder); + searchRequest.source(searchSourceBuilder); + SearchResponse searchResponse = client.search(searchRequest, RequestOptions.DEFAULT); + + for (var hit : searchResponse.getHits().getHits()) { + List> lineage = + (List>) hit.getSourceAsMap().get("lineage"); + HashMap tempMap = new HashMap<>(JsonUtils.getMap(hit.getSourceAsMap())); + nodes.add(tempMap); + for (Map lin : lineage) { + HashMap fromEntity = (HashMap) lin.get("fromEntity"); + HashMap toEntity = (HashMap) lin.get("toEntity"); + HashMap pipeline = (HashMap) lin.get("pipeline"); + if (pipeline != null && pipeline.get("fullyQualifiedName").equalsIgnoreCase(fqn)) { + edges.add(lin); + getLineage( + fromEntity.get("fqn"), + visitedFQN, + upstreamDepth, + edges, + nodes, + queryFilter, + "lineage.toEntity.fqn.keyword", + deleted); + getLineage( + toEntity.get("fqn"), + visitedFQN, + downstreamDepth, + edges, + nodes, + queryFilter, + "lineage.fromEntity.fqn.keyword", + deleted); + } } } + totalRecords = searchResponse.getHits().getTotalHits().value; + int currentHits = searchResponse.getHits().getHits().length; + processedRecords += currentHits; + if (currentHits > 0) { + searchAfter = searchResponse.getHits().getHits()[currentHits - 1].getSortValues(); + } else { + searchAfter = null; + } } getLineage( fqn, diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java index cbce3b06e1ea..82f26cf2b218 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java @@ -1256,57 +1256,75 @@ private Map searchPipelineLineage( Set> edges = new HashSet<>(); Set> nodes = new HashSet<>(); responseMap.put("entity", null); - os.org.opensearch.action.search.SearchRequest searchRequest = - new os.org.opensearch.action.search.SearchRequest( - Entity.getSearchRepository().getIndexOrAliasName(GLOBAL_SEARCH_ALIAS)); - BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery(); - boolQueryBuilder.should( - QueryBuilders.boolQuery() - .must(QueryBuilders.termQuery("lineage.pipeline.fullyQualifiedName.keyword", fqn))); - SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder(); - searchSourceBuilder.fetchSource(null, SOURCE_FIELDS_TO_EXCLUDE.toArray(String[]::new)); - searchSourceBuilder.query(boolQueryBuilder); - if (CommonUtil.nullOrEmpty(deleted)) { - searchSourceBuilder.query( + Object[] searchAfter = null; + long processedRecords = 0; + long totalRecords = -1; + while (totalRecords != processedRecords) { + os.org.opensearch.action.search.SearchRequest searchRequest = + new os.org.opensearch.action.search.SearchRequest( + Entity.getSearchRepository().getIndexOrAliasName(GLOBAL_SEARCH_ALIAS)); + BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery(); + boolQueryBuilder.should( QueryBuilders.boolQuery() - .must(boolQueryBuilder) - .must(QueryBuilders.termQuery("deleted", deleted))); - } - buildSearchSourceFilter(queryFilter, searchSourceBuilder); + .must(QueryBuilders.termQuery("lineage.pipeline.fullyQualifiedName.keyword", fqn))); + SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder(); + searchSourceBuilder.fetchSource(null, SOURCE_FIELDS_TO_EXCLUDE.toArray(String[]::new)); + FieldSortBuilder sortBuilder = SortBuilders.fieldSort("fullyQualifiedName"); + searchSourceBuilder.sort(sortBuilder); + searchSourceBuilder.query(boolQueryBuilder); + if (searchAfter != null) { + searchSourceBuilder.searchAfter(searchAfter); + } + if (CommonUtil.nullOrEmpty(deleted)) { + searchSourceBuilder.query( + QueryBuilders.boolQuery() + .must(boolQueryBuilder) + .must(QueryBuilders.termQuery("deleted", deleted))); + } + buildSearchSourceFilter(queryFilter, searchSourceBuilder); - searchRequest.source(searchSourceBuilder); - SearchResponse searchResponse = client.search(searchRequest, RequestOptions.DEFAULT); - for (var hit : searchResponse.getHits().getHits()) { - List> lineage = - (List>) hit.getSourceAsMap().get("lineage"); - HashMap tempMap = new HashMap<>(JsonUtils.getMap(hit.getSourceAsMap())); - nodes.add(tempMap); - for (Map lin : lineage) { - HashMap fromEntity = (HashMap) lin.get("fromEntity"); - HashMap toEntity = (HashMap) lin.get("toEntity"); - HashMap pipeline = (HashMap) lin.get("pipeline"); - if (pipeline != null && pipeline.get("fullyQualifiedName").equalsIgnoreCase(fqn)) { - edges.add(lin); - getLineage( - fromEntity.get("fqn"), - visitedFQN, - upstreamDepth, - edges, - nodes, - queryFilter, - "lineage.toEntity.fqn.keyword", - deleted); - getLineage( - toEntity.get("fqn"), - visitedFQN, - downstreamDepth, - edges, - nodes, - queryFilter, - "lineage.fromEntity.fqn.keyword", - deleted); + searchRequest.source(searchSourceBuilder); + SearchResponse searchResponse = client.search(searchRequest, RequestOptions.DEFAULT); + for (var hit : searchResponse.getHits().getHits()) { + List> lineage = + (List>) hit.getSourceAsMap().get("lineage"); + HashMap tempMap = new HashMap<>(JsonUtils.getMap(hit.getSourceAsMap())); + nodes.add(tempMap); + for (Map lin : lineage) { + HashMap fromEntity = (HashMap) lin.get("fromEntity"); + HashMap toEntity = (HashMap) lin.get("toEntity"); + HashMap pipeline = (HashMap) lin.get("pipeline"); + if (pipeline != null && pipeline.get("fullyQualifiedName").equalsIgnoreCase(fqn)) { + edges.add(lin); + getLineage( + fromEntity.get("fqn"), + visitedFQN, + upstreamDepth, + edges, + nodes, + queryFilter, + "lineage.toEntity.fqn.keyword", + deleted); + getLineage( + toEntity.get("fqn"), + visitedFQN, + downstreamDepth, + edges, + nodes, + queryFilter, + "lineage.fromEntity.fqn.keyword", + deleted); + } } } + totalRecords = searchResponse.getHits().getTotalHits().value; + int currentHits = searchResponse.getHits().getHits().length; + processedRecords += currentHits; + if (currentHits > 0) { + searchAfter = searchResponse.getHits().getHits()[currentHits - 1].getSortValues(); + } else { + searchAfter = null; + } } getLineage( fqn, From 7add4971e1d86d32166f553efbd41ab09fd73f0e Mon Sep 17 00:00:00 2001 From: Mohit Yadav <105265192+mohityadav766@users.noreply.github.com> Date: Thu, 9 Jan 2025 16:07:21 +0530 Subject: [PATCH 101/294] revert: Circular Rendering issue for lineage (#19270) (#19299) (cherry picked from commit adfbc21a008a37395918c6934744f2380fb3a34a) --- .../elasticsearch/ElasticSearchClient.java | 54 +++---------------- .../search/opensearch/OpenSearchClient.java | 54 +++---------------- 2 files changed, 12 insertions(+), 96 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java index 2378e7127afd..8364d017d589 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java @@ -775,7 +775,6 @@ public Map searchLineageInternal( boolean deleted, String entityType) throws IOException { - Set visitedFQN = new HashSet<>(); Map responseMap = new HashMap<>(); Set> edges = new HashSet<>(); Set> nodes = new HashSet<>(); @@ -800,7 +799,6 @@ public Map searchLineageInternal( } getLineage( fqn, - visitedFQN, downstreamDepth, edges, nodes, @@ -808,14 +806,7 @@ public Map searchLineageInternal( "lineage.fromEntity.fqnHash.keyword", deleted); getLineage( - fqn, - visitedFQN, - upstreamDepth, - edges, - nodes, - queryFilter, - "lineage.toEntity.fqnHash.keyword", - deleted); + fqn, upstreamDepth, edges, nodes, queryFilter, "lineage.toEntity.fqnHash.keyword", deleted); responseMap.put("edges", edges); responseMap.put("nodes", nodes); return responseMap; @@ -1041,7 +1032,6 @@ public Response searchSchemaEntityRelationship( private void getLineage( String fqn, - Set visitedFQN, int depth, Set> edges, Set> nodes, @@ -1049,10 +1039,9 @@ private void getLineage( String direction, boolean deleted) throws IOException { - if (depth <= 0 || visitedFQN.contains(fqn)) { + if (depth <= 0) { return; } - visitedFQN.add(fqn); es.org.elasticsearch.action.search.SearchRequest searchRequest = new es.org.elasticsearch.action.search.SearchRequest( Entity.getSearchRepository().getIndexOrAliasName(GLOBAL_SEARCH_ALIAS)); @@ -1082,27 +1071,13 @@ private void getLineage( if (!edges.contains(lin) && fromEntity.get("fqn").equals(fqn)) { edges.add(lin); getLineage( - toEntity.get("fqn"), - visitedFQN, - depth - 1, - edges, - nodes, - queryFilter, - direction, - deleted); + toEntity.get("fqn"), depth - 1, edges, nodes, queryFilter, direction, deleted); } } else { if (!edges.contains(lin) && toEntity.get("fqn").equals(fqn)) { edges.add(lin); getLineage( - fromEntity.get("fqn"), - visitedFQN, - depth - 1, - edges, - nodes, - queryFilter, - direction, - deleted); + fromEntity.get("fqn"), depth - 1, edges, nodes, queryFilter, direction, deleted); } } } @@ -1254,7 +1229,6 @@ private Map searchPipelineLineage( boolean deleted, Map responseMap) throws IOException { - Set visitedFQN = new HashSet<>(); Set> edges = new HashSet<>(); Set> nodes = new HashSet<>(); Object[] searchAfter = null; @@ -1300,7 +1274,6 @@ private Map searchPipelineLineage( edges.add(lin); getLineage( fromEntity.get("fqn"), - visitedFQN, upstreamDepth, edges, nodes, @@ -1309,7 +1282,6 @@ private Map searchPipelineLineage( deleted); getLineage( toEntity.get("fqn"), - visitedFQN, downstreamDepth, edges, nodes, @@ -1329,23 +1301,9 @@ private Map searchPipelineLineage( } } getLineage( - fqn, - visitedFQN, - downstreamDepth, - edges, - nodes, - queryFilter, - "lineage.fromEntity.fqn.keyword", - deleted); + fqn, downstreamDepth, edges, nodes, queryFilter, "lineage.fromEntity.fqn.keyword", deleted); getLineage( - fqn, - visitedFQN, - upstreamDepth, - edges, - nodes, - queryFilter, - "lineage.toEntity.fqn.keyword", - deleted); + fqn, upstreamDepth, edges, nodes, queryFilter, "lineage.toEntity.fqn.keyword", deleted); // TODO: Fix this , this is hack if (edges.isEmpty()) { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java index 82f26cf2b218..b1f76a2c1a95 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java @@ -777,7 +777,6 @@ public Map searchLineageInternal( boolean deleted, String entityType) throws IOException { - Set visitedFQN = new HashSet<>(); if (entityType.equalsIgnoreCase(Entity.PIPELINE) || entityType.equalsIgnoreCase(Entity.STORED_PROCEDURE)) { return searchPipelineLineage(fqn, upstreamDepth, downstreamDepth, queryFilter, deleted); @@ -802,7 +801,6 @@ public Map searchLineageInternal( } getLineage( fqn, - visitedFQN, downstreamDepth, edges, nodes, @@ -810,14 +808,7 @@ public Map searchLineageInternal( "lineage.fromEntity.fqnHash.keyword", deleted); getLineage( - fqn, - visitedFQN, - upstreamDepth, - edges, - nodes, - queryFilter, - "lineage.toEntity.fqnHash.keyword", - deleted); + fqn, upstreamDepth, edges, nodes, queryFilter, "lineage.toEntity.fqnHash.keyword", deleted); responseMap.put("edges", edges); responseMap.put("nodes", nodes); return responseMap; @@ -1041,7 +1032,6 @@ public Response searchSchemaEntityRelationship( private void getLineage( String fqn, - Set visitedFQN, int depth, Set> edges, Set> nodes, @@ -1049,10 +1039,9 @@ private void getLineage( String direction, boolean deleted) throws IOException { - if (depth <= 0 || visitedFQN.contains(fqn)) { + if (depth <= 0) { return; } - visitedFQN.add(fqn); os.org.opensearch.action.search.SearchRequest searchRequest = new os.org.opensearch.action.search.SearchRequest( Entity.getSearchRepository().getIndexOrAliasName(GLOBAL_SEARCH_ALIAS)); @@ -1084,27 +1073,13 @@ private void getLineage( if (!edges.contains(lin) && fromEntity.get("fqn").equals(fqn)) { edges.add(lin); getLineage( - toEntity.get("fqn"), - visitedFQN, - depth - 1, - edges, - nodes, - queryFilter, - direction, - deleted); + toEntity.get("fqn"), depth - 1, edges, nodes, queryFilter, direction, deleted); } } else { if (!edges.contains(lin) && toEntity.get("fqn").equals(fqn)) { edges.add(lin); getLineage( - fromEntity.get("fqn"), - visitedFQN, - depth - 1, - edges, - nodes, - queryFilter, - direction, - deleted); + fromEntity.get("fqn"), depth - 1, edges, nodes, queryFilter, direction, deleted); } } } @@ -1251,7 +1226,6 @@ private SearchResponse performLineageSearch(String fqn, String queryFilter, bool private Map searchPipelineLineage( String fqn, int upstreamDepth, int downstreamDepth, String queryFilter, boolean deleted) throws IOException { - Set visitedFQN = new HashSet<>(); Map responseMap = new HashMap<>(); Set> edges = new HashSet<>(); Set> nodes = new HashSet<>(); @@ -1298,7 +1272,6 @@ private Map searchPipelineLineage( edges.add(lin); getLineage( fromEntity.get("fqn"), - visitedFQN, upstreamDepth, edges, nodes, @@ -1307,7 +1280,6 @@ private Map searchPipelineLineage( deleted); getLineage( toEntity.get("fqn"), - visitedFQN, downstreamDepth, edges, nodes, @@ -1327,23 +1299,9 @@ private Map searchPipelineLineage( } } getLineage( - fqn, - visitedFQN, - downstreamDepth, - edges, - nodes, - queryFilter, - "lineage.fromEntity.fqn.keyword", - deleted); + fqn, downstreamDepth, edges, nodes, queryFilter, "lineage.fromEntity.fqn.keyword", deleted); getLineage( - fqn, - visitedFQN, - upstreamDepth, - edges, - nodes, - queryFilter, - "lineage.toEntity.fqn.keyword", - deleted); + fqn, upstreamDepth, edges, nodes, queryFilter, "lineage.toEntity.fqn.keyword", deleted); if (edges.isEmpty()) { os.org.opensearch.action.search.SearchRequest searchRequestForEntity = From 740a1df260b88a52311f221ddf44ab0f65e5d0df Mon Sep 17 00:00:00 2001 From: Mohit Yadav <105265192+mohityadav766@users.noreply.github.com> Date: Thu, 9 Jan 2025 16:10:35 +0530 Subject: [PATCH 102/294] Add Operations to install Onboarding app (#19298) * Add Operations to install app * Initiate Jwt (cherry picked from commit 4c15cc0d916f892477447b351aec20a460116ce6) --- .../service/util/OpenMetadataOperations.java | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/util/OpenMetadataOperations.java b/openmetadata-service/src/main/java/org/openmetadata/service/util/OpenMetadataOperations.java index 3e55072b5b7e..c23c0d8860e8 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/util/OpenMetadataOperations.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/util/OpenMetadataOperations.java @@ -2,6 +2,7 @@ import static org.flywaydb.core.internal.info.MigrationInfoDumper.dumpToAsciiTable; import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; +import static org.openmetadata.service.Entity.ADMIN_USER_NAME; import static org.openmetadata.service.Entity.FIELD_OWNERS; import static org.openmetadata.service.formatter.decorators.MessageDecorator.getDateStringEpochMilli; import static org.openmetadata.service.jdbi3.UserRepository.AUTH_MECHANISM_FIELD; @@ -42,7 +43,12 @@ import org.openmetadata.schema.EntityInterface; import org.openmetadata.schema.ServiceEntityInterface; import org.openmetadata.schema.entity.app.App; +import org.openmetadata.schema.entity.app.AppConfiguration; +import org.openmetadata.schema.entity.app.AppMarketPlaceDefinition; import org.openmetadata.schema.entity.app.AppRunRecord; +import org.openmetadata.schema.entity.app.AppSchedule; +import org.openmetadata.schema.entity.app.CreateApp; +import org.openmetadata.schema.entity.app.ScheduleTimeline; import org.openmetadata.schema.entity.services.ingestionPipelines.IngestionPipeline; import org.openmetadata.schema.entity.teams.User; import org.openmetadata.schema.services.connections.metadata.AuthProvider; @@ -58,6 +64,7 @@ import org.openmetadata.service.clients.pipeline.PipelineServiceClientFactory; import org.openmetadata.service.exception.EntityNotFoundException; import org.openmetadata.service.fernet.Fernet; +import org.openmetadata.service.jdbi3.AppMarketPlaceRepository; import org.openmetadata.service.jdbi3.AppRepository; import org.openmetadata.service.jdbi3.CollectionDAO; import org.openmetadata.service.jdbi3.EntityRepository; @@ -69,11 +76,13 @@ import org.openmetadata.service.jdbi3.locator.ConnectionType; import org.openmetadata.service.migration.api.MigrationWorkflow; import org.openmetadata.service.resources.CollectionRegistry; +import org.openmetadata.service.resources.apps.AppMapper; import org.openmetadata.service.resources.databases.DatasourceConfig; import org.openmetadata.service.search.SearchRepository; import org.openmetadata.service.secrets.SecretsManager; import org.openmetadata.service.secrets.SecretsManagerFactory; import org.openmetadata.service.secrets.SecretsManagerUpdateService; +import org.openmetadata.service.security.jwt.JWTTokenGenerator; import org.openmetadata.service.util.jdbi.DatabaseAuthenticationProviderFactory; import org.openmetadata.service.util.jdbi.JdbiUtils; import org.slf4j.LoggerFactory; @@ -185,6 +194,49 @@ public Integer syncEmailFromEnv() { } } + @Command(name = "install-app", description = "Install the application from App MarketPlace.") + public Integer installApp( + @Option( + names = {"-n", "--name"}, + description = "Number of records to process in each batch.", + required = true) + String appName) { + try { + parseConfig(); + CollectionRegistry.initialize(); + ApplicationHandler.initialize(config); + CollectionRegistry.getInstance().loadSeedData(jdbi, config, null, null, null, true); + ApplicationHandler.initialize(config); + AppScheduler.initialize(config, collectionDAO, searchRepository); + // Instantiate JWT Token Generator + JWTTokenGenerator.getInstance() + .init( + config.getAuthenticationConfiguration().getTokenValidationAlgorithm(), + config.getJwtTokenConfiguration()); + AppMarketPlaceRepository marketPlaceRepository = + (AppMarketPlaceRepository) Entity.getEntityRepository(Entity.APP_MARKET_PLACE_DEF); + AppMarketPlaceDefinition definition = + marketPlaceRepository.getByName(null, appName, marketPlaceRepository.getFields("id")); + AppRepository appRepository = (AppRepository) Entity.getEntityRepository(Entity.APPLICATION); + CreateApp createApp = + new CreateApp() + .withName(definition.getName()) + .withDescription(definition.getDescription()) + .withDisplayName(definition.getDisplayName()) + .withAppSchedule(new AppSchedule().withScheduleTimeline(ScheduleTimeline.NONE)) + .withAppConfiguration(new AppConfiguration()); + AppMapper appMapper = new AppMapper(); + App entity = appMapper.createToEntity(createApp, ADMIN_USER_NAME); + appRepository.prepareInternal(entity, true); + appRepository.createOrUpdate(null, entity); + LOG.info("App Installed."); + return 0; + } catch (Exception e) { + LOG.error("Install Application Failed ", e); + return 1; + } + } + @Command( name = "check-connection", description = From a8683eefd225e0bf11b6b8bc311b81386cf27496 Mon Sep 17 00:00:00 2001 From: Sweta Agarwalla <105535990+sweta1308@users.noreply.github.com> Date: Thu, 9 Jan 2025 16:30:47 +0530 Subject: [PATCH 103/294] MINOR: Add applications loading condition in My Data Page (#19292) * add application loading condition in mydata * update fetchDocument condition --------- Co-authored-by: Karan Hotchandani <33024356+karanh37@users.noreply.github.com> Co-authored-by: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> (cherry picked from commit e32dbe00d4cbf49161a911b15d1f215b13256bde) --- .../ui/src/pages/MyDataPage/MyDataPage.component.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx index a5ea133dac6b..b85dd6cbd2f8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx @@ -26,6 +26,7 @@ import ActivityFeedProvider from '../../components/ActivityFeed/ActivityFeedProv import Loader from '../../components/common/Loader/Loader'; import WelcomeScreen from '../../components/MyData/WelcomeScreen/WelcomeScreen.component'; import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1'; +import { useApplicationsProvider } from '../../components/Settings/Applications/ApplicationsProvider/ApplicationsProvider'; import { KNOWLEDGE_LIST_LENGTH, LOGGED_IN_USER_STORAGE_KEY, @@ -53,6 +54,7 @@ const ReactGridLayout = WidthProvider(RGL); const MyDataPage = () => { const { t } = useTranslation(); const { currentUser, selectedPersona } = useApplicationStore(); + const { loading: applicationsLoading } = useApplicationsProvider(); const { isWelcomeVisible } = useWelcomeStore(); const [followedData, setFollowedData] = useState>([]); const [followedDataCount, setFollowedDataCount] = useState(0); @@ -105,8 +107,8 @@ const MyDataPage = () => { }; useEffect(() => { - fetchDocument(); - }, [selectedPersona]); + !applicationsLoading && fetchDocument(); + }, [selectedPersona, applicationsLoading]); useEffect(() => { isMounted.current = true; @@ -207,7 +209,7 @@ const MyDataPage = () => { - {isLoading ? ( + {isLoading || applicationsLoading ? (
From f1b0ee3b3e0a2b929ff98a3c92b055291bb9689f Mon Sep 17 00:00:00 2001 From: Mayur Singal <39544459+ulixius9@users.noreply.github.com> Date: Thu, 9 Jan 2025 18:14:52 +0530 Subject: [PATCH 104/294] Fix #19160: Add Depth Support for Structured Containers (#19288) --- .../ingestion/source/storage/s3/metadata.py | 65 ++++++++++++++--- .../v1.4/connectors/storage/manifest.md | 71 +++++++++++++++++++ .../v1.6/connectors/storage/manifest.md | 45 ++++++++++++ 3 files changed, 172 insertions(+), 9 deletions(-) diff --git a/ingestion/src/metadata/ingestion/source/storage/s3/metadata.py b/ingestion/src/metadata/ingestion/source/storage/s3/metadata.py index 777944bc204b..9949f6163301 100644 --- a/ingestion/src/metadata/ingestion/source/storage/s3/metadata.py +++ b/ingestion/src/metadata/ingestion/source/storage/s3/metadata.py @@ -12,6 +12,7 @@ import json import secrets import traceback +from copy import deepcopy from datetime import datetime, timedelta from enum import Enum from typing import Dict, Iterable, List, Optional, Tuple @@ -325,6 +326,45 @@ def _generate_container_details( ) return None + def _generate_structured_containers_by_depth( + self, + bucket_response: S3BucketResponse, + metadata_entry: MetadataEntry, + parent: Optional[EntityReference] = None, + ) -> Iterable[S3ContainerDetails]: + try: + prefix = self._get_sample_file_prefix(metadata_entry=metadata_entry) + if prefix: + response = self.s3_client.list_objects_v2( + Bucket=bucket_response.name, Prefix=prefix + ) + # total depth is depth of prefix + depth of the metadata entry + total_depth = metadata_entry.depth + len(prefix[:-1].split("/")) + candidate_keys = { + "/".join(entry.get("Key").split("/")[:total_depth]) + "/" + for entry in response[S3_CLIENT_ROOT_RESPONSE] + if entry + and entry.get("Key") + and len(entry.get("Key").split("/")) > total_depth + } + for key in candidate_keys: + metadata_entry_copy = deepcopy(metadata_entry) + metadata_entry_copy.dataPath = key.strip(KEY_SEPARATOR) + structured_container: Optional[ + S3ContainerDetails + ] = self._generate_container_details( + bucket_response=bucket_response, + metadata_entry=metadata_entry_copy, + parent=parent, + ) + if structured_container: + yield structured_container + except Exception as err: + logger.warning( + f"Error while generating structured containers by depth for {metadata_entry.dataPath} - {err}" + ) + logger.debug(traceback.format_exc()) + def _generate_structured_containers( self, bucket_response: S3BucketResponse, @@ -336,15 +376,22 @@ def _generate_structured_containers( f"Extracting metadata from path {metadata_entry.dataPath.strip(KEY_SEPARATOR)} " f"and generating structured container" ) - structured_container: Optional[ - S3ContainerDetails - ] = self._generate_container_details( - bucket_response=bucket_response, - metadata_entry=metadata_entry, - parent=parent, - ) - if structured_container: - yield structured_container + if metadata_entry.depth == 0: + structured_container: Optional[ + S3ContainerDetails + ] = self._generate_container_details( + bucket_response=bucket_response, + metadata_entry=metadata_entry, + parent=parent, + ) + if structured_container: + yield structured_container + else: + yield from self._generate_structured_containers_by_depth( + bucket_response=bucket_response, + metadata_entry=metadata_entry, + parent=parent, + ) def is_valid_unstructured_file(self, accepted_extensions: List, key: str) -> bool: # Split the string into a list of values diff --git a/openmetadata-docs/content/partials/v1.4/connectors/storage/manifest.md b/openmetadata-docs/content/partials/v1.4/connectors/storage/manifest.md index be18c834c6d9..a1f243dd6707 100644 --- a/openmetadata-docs/content/partials/v1.4/connectors/storage/manifest.md +++ b/openmetadata-docs/content/partials/v1.4/connectors/storage/manifest.md @@ -57,6 +57,56 @@ Again, this information will be added on top of the inferred schema from the dat {% /codeInfo %} +{% codeInfo srNumber=7 %} + +**Automated Container Ingestion**: Registering all the data paths one by one can be a time consuming job, +to make the automated structure container ingestion you can provide the depth at which all the data is available. + +Let us understand this with the example, suppose following is the file hierarchy within my bucket. + +``` + +# prefix/depth1/depth2/depth3 +athena_service/my_database_a/my_schema_a/table_a/date=01-01-2025/data.parquet +athena_service/my_database_a/my_schema_a/table_a/date=02-01-2025/data.parquet +athena_service/my_database_a/my_schema_a/table_b/date=01-01-2025/data.parquet +athena_service/my_database_a/my_schema_a/table_b/date=02-01-2025/data.parquet + +athena_service/my_database_b/my_schema_a/table_a/date=01-01-2025/data.parquet +athena_service/my_database_b/my_schema_a/table_a/date=02-01-2025/data.parquet +athena_service/my_database_b/my_schema_a/table_b/date=01-01-2025/data.parquet +athena_service/my_database_b/my_schema_a/table_b/date=02-01-2025/data.parquet + +``` + +all my tables folders which contains the actual data are available at depth 3, hence when you specify the `depth: 3` in +manifest entry all following path will get registered as container in OpenMetadata with this single entry + +``` +athena_service/my_database_a/my_schema_a/table_a +athena_service/my_database_a/my_schema_a/table_b +athena_service/my_database_b/my_schema_a/table_a +athena_service/my_database_b/my_schema_a/table_b +``` + +saving efforts to add 4 individual entries compared to 1 + +{% /codeInfo %} + + +{% codeInfo srNumber=6 %} + +**Unstructured Container**: OpenMetadata supports ingesting unstructured files like images, pdf's etc. We support fetching the file names, size and tags associates to such files. + +In case you want to ingest a single unstructured file, then just specifying the full path of the unstructured file in `datapath` would be enough for ingestion. + +In case you want to ingest all unstructured files with a specific extension for example `pdf` & `png` then you can provide the folder name containing such files in `dataPath` and list of extensions in the `unstructuredFormats` field. + +In case you want to ingest all unstructured files with irrespective of their file type or extension then you can provide the folder name containing such files in `dataPath` and `["*"]` in the `unstructuredFormats` field. + +{% /codeInfo %} + + {% /codeInfoContainer %} {% codeBlock fileName="openmetadata.json" %} @@ -110,6 +160,27 @@ Again, this information will be added on top of the inferred schema from the dat } ] } +``` +```json {% srNumber=7 %} + { + "dataPath": "athena_service", + "structureFormat": "parquet", + "isPartitioned": true, + "depth": 2 + } +``` +```json {% srNumber=6 %} + { + "dataPath": "path/to/solution.pdf", + }, + { + "dataPath": "path/to/unstructured_folder_png_pdf", + "unstructuredFormats": ["png","pdf"] + }, + { + "dataPath": "path/to/unstructured_folder_all", + "unstructuredFormats": ["*"] + } ] } ``` diff --git a/openmetadata-docs/content/partials/v1.6/connectors/storage/manifest.md b/openmetadata-docs/content/partials/v1.6/connectors/storage/manifest.md index 0f165a9a57fa..aae373ab44bb 100644 --- a/openmetadata-docs/content/partials/v1.6/connectors/storage/manifest.md +++ b/openmetadata-docs/content/partials/v1.6/connectors/storage/manifest.md @@ -57,6 +57,43 @@ Again, this information will be added on top of the inferred schema from the dat {% /codeInfo %} +{% codeInfo srNumber=7 %} + +**Automated Container Ingestion**: Registering all the data paths one by one can be a time consuming job, +to make the automated structure container ingestion you can provide the depth at which all the data is available. + +Let us understand this with the example, suppose following is the file hierarchy within my bucket. + +``` + +# prefix/depth1/depth2/depth3 +athena_service/my_database_a/my_schema_a/table_a/date=01-01-2025/data.parquet +athena_service/my_database_a/my_schema_a/table_a/date=02-01-2025/data.parquet +athena_service/my_database_a/my_schema_a/table_b/date=01-01-2025/data.parquet +athena_service/my_database_a/my_schema_a/table_b/date=02-01-2025/data.parquet + +athena_service/my_database_b/my_schema_a/table_a/date=01-01-2025/data.parquet +athena_service/my_database_b/my_schema_a/table_a/date=02-01-2025/data.parquet +athena_service/my_database_b/my_schema_a/table_b/date=01-01-2025/data.parquet +athena_service/my_database_b/my_schema_a/table_b/date=02-01-2025/data.parquet + +``` + +all my tables folders which contains the actual data are available at depth 3, hence when you specify the `depth: 3` in +manifest entry all following path will get registered as container in OpenMetadata with this single entry + +``` +athena_service/my_database_a/my_schema_a/table_a +athena_service/my_database_a/my_schema_a/table_b +athena_service/my_database_b/my_schema_a/table_a +athena_service/my_database_b/my_schema_a/table_b +``` + +saving efforts to add 4 individual entries compared to 1 + +{% /codeInfo %} + + {% codeInfo srNumber=6 %} **Unstructured Container**: OpenMetadata supports ingesting unstructured files like images, pdf's etc. We support fetching the file names, size and tags associates to such files. @@ -124,6 +161,14 @@ In case you want to ingest all unstructured files with irrespective of their fil ] } ``` +```json {% srNumber=7 %} + { + "dataPath": "athena_service", + "structureFormat": "parquet", + "isPartitioned": true, + "depth": 2 + } +``` ```json {% srNumber=6 %} { "dataPath": "path/to/solution.pdf", From 2eacf1bda55e4c8a20d2fc1fb4c5286b2b69be2a Mon Sep 17 00:00:00 2001 From: Mohit Yadav <105265192+mohityadav766@users.noreply.github.com> Date: Thu, 9 Jan 2025 22:51:49 +0530 Subject: [PATCH 105/294] Add Indexing Stack (#19305) (cherry picked from commit d66c689f6dcb8f66d34371b5719dceabfbf71b32) --- .../bundles/searchIndex/ElasticSearchIndexSink.java | 13 ++++++++++++- .../bundles/searchIndex/OpenSearchIndexSink.java | 13 ++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/ElasticSearchIndexSink.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/ElasticSearchIndexSink.java index af2ca8bb53e3..12bac6ad68db 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/ElasticSearchIndexSink.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/ElasticSearchIndexSink.java @@ -86,7 +86,10 @@ public void write(List entities, Map contextData) throws Sear } catch (Exception e) { entityErrorList.add( new EntityError() - .withMessage("Failed to convert entity to request: " + e.getMessage()) + .withMessage( + String.format( + "Failed to convert entity to request: %s , Stack : %s", + e.getMessage(), ExceptionUtils.exceptionStackTraceAsString(e))) .withEntity(entity.toString())); LOG.error("Error converting entity to request", e); } @@ -173,6 +176,14 @@ public void onFailure(Exception e) { handleNonRetriableException(requests.size(), e); } } catch (Exception ex) { + entityErrorList.add( + new EntityError() + .withMessage( + String.format( + "Bulk request failed: %s, StackTrace: %s", + ex.getMessage(), + ExceptionUtils.exceptionStackTraceAsString(ex))) + .withEntity(requests.toString())); LOG.error("Bulk request retry attempt {}/{} failed", 1, maxRetries, ex); } finally { semaphore.release(); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/OpenSearchIndexSink.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/OpenSearchIndexSink.java index 4a3b7384619b..65ef6d1737cb 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/OpenSearchIndexSink.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/OpenSearchIndexSink.java @@ -87,7 +87,10 @@ public void write(List entities, Map contextData) throws Sear } catch (Exception e) { entityErrorList.add( new EntityError() - .withMessage("Failed to convert entity to request: " + e.getMessage()) + .withMessage( + String.format( + "Failed to convert entity to request: %s , Stack : %s", + e.getMessage(), ExceptionUtils.exceptionStackTraceAsString(e))) .withEntity(entity.toString())); LOG.error("Error converting entity to request", e); } @@ -174,6 +177,14 @@ public void onFailure(Exception e) { handleNonRetriableException(requests.size(), e); } } catch (Exception ex) { + entityErrorList.add( + new EntityError() + .withMessage( + String.format( + "Bulk request failed: %s, StackTrace: %s", + ex.getMessage(), + ExceptionUtils.exceptionStackTraceAsString(ex))) + .withEntity(requests.toString())); LOG.error("Bulk request retry attempt {}/{} failed", 1, maxRetries, ex); } finally { semaphore.release(); From b5d240b8f93d43ec62373a76f5cbd1c977931beb Mon Sep 17 00:00:00 2001 From: Teddy Date: Fri, 10 Jan 2025 04:36:11 +0100 Subject: [PATCH 106/294] fix= (#19304) Co-authored-by: Sriharsha Chintalapani --- .../openmetadata/service/resources/dqtests/TestCaseResource.java | 1 + 1 file changed, 1 insertion(+) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseResource.java index 4a84e03dacb3..4f0add675282 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/dqtests/TestCaseResource.java @@ -916,6 +916,7 @@ public Response addTestCaseResult( repository.deleteTestCaseFailedRowsSample(testCase.getId()); } RestUtil.validateTimestampMilliseconds(testCaseResult.getTimestamp()); + testCaseResult.withId(UUID.randomUUID()).withTestCaseFQN(fqn); return repository .addTestCaseResult( securityContext.getUserPrincipal().getName(), uriInfo, fqn, testCaseResult) From a68cc5de8dc1bf24c9f769f2fd746c870ef264a3 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 9 Jan 2025 19:35:52 -0800 Subject: [PATCH 107/294] fix checkstyle --- .../java/org/openmetadata/service/jdbi3/MlModelRepository.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/MlModelRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/MlModelRepository.java index 2937030e0e63..e9ada22bd098 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/MlModelRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/MlModelRepository.java @@ -211,7 +211,8 @@ private void setMlFeatureSourcesLineage(MlModel mlModel) { .getFeatureSources() .forEach( mlFeatureSource -> { - EntityReference targetEntity = getEntityReference(mlFeatureSource.getDataSource(), Include.ALL); + EntityReference targetEntity = + getEntityReference(mlFeatureSource.getDataSource(), Include.ALL); if (targetEntity != null) { addRelationship( targetEntity.getId(), From fef6376d1fe34a02b4ebf1ab833b3df1201c390f Mon Sep 17 00:00:00 2001 From: Pere Miquel Brull Date: Fri, 20 Dec 2024 07:14:52 +0100 Subject: [PATCH 108/294] MINOR - Add Gov entities for entity ref & Automator Actions (#19090) * MINOR - Add Gov entities for entity ref * MINOR - Add Gov entities for entity ref * add automator configs * add automator configs --- ingestion/src/metadata/utils/constants.py | 7 ++++ ingestion/src/metadata/utils/elasticsearch.py | 4 +- .../automator/addCustomProperties.json | 36 +++++++++++++++++ .../removeCustomPropertiesAction.json | 32 +++++++++++++++ .../external/automatorAppConfig.json | 6 +++ .../external/automator/addCustomProperties.ts | 39 +++++++++++++++++++ .../automator/removeCustomPropertiesAction.ts | 34 ++++++++++++++++ .../external/automatorAppConfig.ts | 23 +++++++++-- 8 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 openmetadata-spec/src/main/resources/json/schema/entity/applications/configuration/external/automator/addCustomProperties.json create mode 100644 openmetadata-spec/src/main/resources/json/schema/entity/applications/configuration/external/automator/removeCustomPropertiesAction.json create mode 100644 openmetadata-ui/src/main/resources/ui/src/generated/entity/applications/configuration/external/automator/addCustomProperties.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/generated/entity/applications/configuration/external/automator/removeCustomPropertiesAction.ts diff --git a/ingestion/src/metadata/utils/constants.py b/ingestion/src/metadata/utils/constants.py index 02803d955470..62655909f832 100644 --- a/ingestion/src/metadata/utils/constants.py +++ b/ingestion/src/metadata/utils/constants.py @@ -20,6 +20,9 @@ from metadata.generated.schema.entity.data.dashboardDataModel import DashboardDataModel from metadata.generated.schema.entity.data.database import Database from metadata.generated.schema.entity.data.databaseSchema import DatabaseSchema +from metadata.generated.schema.entity.data.glossary import Glossary +from metadata.generated.schema.entity.data.glossaryTerm import GlossaryTerm +from metadata.generated.schema.entity.data.metric import Metric from metadata.generated.schema.entity.data.mlmodel import MlModel from metadata.generated.schema.entity.data.pipeline import Pipeline from metadata.generated.schema.entity.data.searchIndex import SearchIndex @@ -144,6 +147,10 @@ # Domain "domain": Domain, "dataProduct": DataProduct, + # Governance + "metric": Metric, + "glossary": Glossary, + "glossaryTerm": GlossaryTerm, } ENTITY_REFERENCE_TYPE_MAP = { diff --git a/ingestion/src/metadata/utils/elasticsearch.py b/ingestion/src/metadata/utils/elasticsearch.py index 14bbe94ab02c..8490309ec18f 100644 --- a/ingestion/src/metadata/utils/elasticsearch.py +++ b/ingestion/src/metadata/utils/elasticsearch.py @@ -28,6 +28,7 @@ from metadata.generated.schema.entity.data.databaseSchema import DatabaseSchema from metadata.generated.schema.entity.data.glossary import Glossary from metadata.generated.schema.entity.data.glossaryTerm import GlossaryTerm +from metadata.generated.schema.entity.data.metric import Metric from metadata.generated.schema.entity.data.mlmodel import MlModel from metadata.generated.schema.entity.data.pipeline import Pipeline from metadata.generated.schema.entity.data.query import Query @@ -62,12 +63,13 @@ Topic.__name__: "topic_search_index", Pipeline.__name__: "pipeline_search_index", Glossary.__name__: "glossary_search_index", - GlossaryTerm.__name__: "glossary_search_index", + GlossaryTerm.__name__: "glossary_term_search_index", MlModel.__name__: "mlmodel_search_index", Tag.__name__: "tag_search_index", Container.__name__: "container_search_index", Query.__name__: "query_search_index", ReportData.__name__: "entity_report_data_index", + Metric.__name__: "metric_search_index", "web_analytic_user_activity_report": "web_analytic_user_activity_report_data_index", "web_analytic_entity_view_report": "web_analytic_entity_view_report_data_index", } diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/applications/configuration/external/automator/addCustomProperties.json b/openmetadata-spec/src/main/resources/json/schema/entity/applications/configuration/external/automator/addCustomProperties.json new file mode 100644 index 000000000000..476192e9e7d2 --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/entity/applications/configuration/external/automator/addCustomProperties.json @@ -0,0 +1,36 @@ +{ + "$id": "https://open-metadata.org/schema/entity/applications/configuration/external/automator/addCustomPropertiesAction.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AddCustomPropertiesAction", + "description": "Add a Custom Property to the selected assets.", + "type": "object", + "definitions": { + "AddCustomPropertiesActionType": { + "description": "Add Custom Properties Action Type.", + "type": "string", + "enum": ["AddCustomPropertiesAction"], + "default": "AddCustomPropertiesAction" + } + }, + "properties": { + "type": { + "title": "Application Type", + "description": "Application Type", + "$ref": "#/definitions/AddCustomPropertiesActionType", + "default": "AddCustomPropertiesAction" + }, + "customProperties": { + "description": "Owners to apply", + "$ref": "../../../../../type/basic.json#/definitions/entityExtension", + "default": null + }, + "overwriteMetadata": { + "title": "Overwrite Metadata", + "description": "Update the Custom Property even if it is defined in the asset. By default, we will only apply the owners to assets without the given Custom Property informed.", + "type": "boolean", + "default": false + } + }, + "required": ["type", "customProperties"], + "additionalProperties": false +} diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/applications/configuration/external/automator/removeCustomPropertiesAction.json b/openmetadata-spec/src/main/resources/json/schema/entity/applications/configuration/external/automator/removeCustomPropertiesAction.json new file mode 100644 index 000000000000..e975ab6c1622 --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/entity/applications/configuration/external/automator/removeCustomPropertiesAction.json @@ -0,0 +1,32 @@ +{ + "$id": "https://open-metadata.org/schema/entity/applications/configuration/external/automator/removeCustomPropertiesAction.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "RemoveCustomPropertiesAction", + "description": "Remove Custom Properties Action Type", + "type": "object", + "definitions": { + "removeCustomPropertiesActionType": { + "description": "Remove Custom Properties Action Type.", + "type": "string", + "enum": ["RemoveCustomPropertiesAction"], + "default": "RemoveCustomPropertiesAction" + } + }, + "properties": { + "type": { + "title": "Application Type", + "description": "Application Type", + "$ref": "#/definitions/removeCustomPropertiesActionType", + "default": "removeCustomPropertiesActionType" + }, + "customProperties": { + "description": "Custom Properties keys to remove", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["type", "customProperties"], + "additionalProperties": false +} diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/applications/configuration/external/automatorAppConfig.json b/openmetadata-spec/src/main/resources/json/schema/entity/applications/configuration/external/automatorAppConfig.json index a47c48c933fd..9985ec39e63e 100644 --- a/openmetadata-spec/src/main/resources/json/schema/entity/applications/configuration/external/automatorAppConfig.json +++ b/openmetadata-spec/src/main/resources/json/schema/entity/applications/configuration/external/automatorAppConfig.json @@ -47,6 +47,9 @@ { "$ref": "automator/addDescriptionAction.json" }, + { + "$ref": "automator/addCustomProperties.json" + }, { "$ref": "automator/removeDescriptionAction.json" }, @@ -62,6 +65,9 @@ { "$ref": "automator/removeOwnerAction.json" }, + { + "$ref": "automator/removeCustomPropertiesAction.json" + }, { "$ref": "automator/lineagePropagationAction.json" }, diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/applications/configuration/external/automator/addCustomProperties.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/applications/configuration/external/automator/addCustomProperties.ts new file mode 100644 index 000000000000..870b52590cee --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/applications/configuration/external/automator/addCustomProperties.ts @@ -0,0 +1,39 @@ +/* + * 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. + */ +/** + * Add a Custom Property to the selected assets. + */ +export interface AddCustomProperties { + /** + * Owners to apply + */ + customProperties: any; + /** + * Update the Custom Property even if it is defined in the asset. By default, we will only + * apply the owners to assets without the given Custom Property informed. + */ + overwriteMetadata?: boolean; + /** + * Application Type + */ + type: AddCustomPropertiesActionType; +} + +/** + * Application Type + * + * Add Custom Properties Action Type. + */ +export enum AddCustomPropertiesActionType { + AddCustomPropertiesAction = "AddCustomPropertiesAction", +} diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/applications/configuration/external/automator/removeCustomPropertiesAction.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/applications/configuration/external/automator/removeCustomPropertiesAction.ts new file mode 100644 index 000000000000..11b4815a42fe --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/applications/configuration/external/automator/removeCustomPropertiesAction.ts @@ -0,0 +1,34 @@ +/* + * 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. + */ +/** + * Remove Custom Properties Action Type + */ +export interface RemoveCustomPropertiesAction { + /** + * Custom Properties keys to remove + */ + customProperties: string[]; + /** + * Application Type + */ + type: RemoveCustomPropertiesActionType; +} + +/** + * Application Type + * + * Remove Custom Properties Action Type. + */ +export enum RemoveCustomPropertiesActionType { + RemoveCustomPropertiesAction = "RemoveCustomPropertiesAction", +} diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/applications/configuration/external/automatorAppConfig.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/applications/configuration/external/automatorAppConfig.ts index 5085ffc7370a..88710da4a485 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/applications/configuration/external/automatorAppConfig.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/applications/configuration/external/automatorAppConfig.ts @@ -10,9 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - - - /** +/** * Configuration for the Automator External Application. */ export interface AutomatorAppConfig { @@ -43,8 +41,12 @@ export interface AutomatorAppConfig { * * Remove Owner Action Type * + * Add a Custom Property to the selected assets. + * * Add owners to the selected assets. * + * Remove Custom Properties Action Type + * * Propagate description, tags and glossary terms via lineage * * ML Tagging action configuration for external automator. @@ -74,6 +76,9 @@ export interface Action { * Update the description even if they are already defined in the asset. By default, we'll * only add the descriptions to assets without the description set. * + * Update the Custom Property even if it is defined in the asset. By default, we will only + * apply the owners to assets without the given Custom Property informed. + * * Update the tier even if it is defined in the asset. By default, we will only apply the * tier to assets without tier. * @@ -103,6 +108,12 @@ export interface Action { * Description to apply */ description?: string; + /** + * Owners to apply + * + * Custom Properties to remove + */ + customProperties?: any; /** * tier to apply */ @@ -301,6 +312,8 @@ export interface Style { * * Add Description Action Type. * + * Add Custom Properties Action Type. + * * Remove Description Action Type * * Add Tier Action Type. @@ -309,11 +322,14 @@ export interface Style { * * Remove Owner Action Type * + * Remove Custom Properties Action Type. + * * Lineage propagation action type. * * ML PII Tagging action type. */ export enum ActionType { + AddCustomPropertiesAction = "AddCustomPropertiesAction", AddDescriptionAction = "AddDescriptionAction", AddDomainAction = "AddDomainAction", AddOwnerAction = "AddOwnerAction", @@ -321,6 +337,7 @@ export enum ActionType { AddTierAction = "AddTierAction", LineagePropagationAction = "LineagePropagationAction", MLTaggingAction = "MLTaggingAction", + RemoveCustomPropertiesAction = "RemoveCustomPropertiesAction", RemoveDescriptionAction = "RemoveDescriptionAction", RemoveDomainAction = "RemoveDomainAction", RemoveOwnerAction = "RemoveOwnerAction", From dd4b29c10c4912b090ce43a2cbb4d5ab2a0a6256 Mon Sep 17 00:00:00 2001 From: Aniket Katkar Date: Fri, 10 Jan 2025 14:40:52 +0530 Subject: [PATCH 109/294] Minor: Update what's new content (#19300) * update what's new content * Update the release date (cherry picked from commit d394fd0cffc0c79439f840dc6ea170eed728a85d) --- .../ui/src/components/Modals/WhatsNewModal/whatsNewData.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 15a2c386e719..aa4ffc40acbd 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 @@ -1457,7 +1457,7 @@ Since we are introducing the Auto Classification workflow, we are going to remov { id: 58, version: 'v1.6.2', - description: 'Released on 8th January 2025.', + description: 'Released on 10th January 2025.', features: [], changeLogs: { Improvements: `- **Fix**: Test case getting removed from logical test suite after editing the test case. @@ -1473,7 +1473,6 @@ Since we are introducing the Auto Classification workflow, we are going to remov - **Fix**: Column lineage ingestion failed to parse column due to subquery raw_name AttributeError. - **Fix**: Data Insight Tier Filter does not work. - **Fix**: Add depth support for storage connector. -- **Fix**: The tag also adding to a different asset when a different asset with the same name is updated with the tag. - **Fix**: Replace the description editor with a new block editor. - **Fix**: Redshift Metadata ingestion failing for Stored Procedure. - **Fix**: Lineage view not showing all the nodes in case of circular lineage. @@ -1484,7 +1483,6 @@ Since we are introducing the Auto Classification workflow, we are going to remov - **Fix**: Auto Classification Ingestion - AttributeError: 'DataType' object has no attribute 'dialect_impl'. - **Fix**: Adding the profiler for doris failing to execute. - **Fix**: Unable to remove existing values from custom property (enum data type). -- **Improvement**: Ability to disable soft delete when Ingestion pipeline fails. - **Improvement**: Ability to sort the DI charts based on date or term. - **Improvement**: Support test connection api cancellation on click of cancel. - **Improvement**: Highlight the search term for schema table on table details page. From 6fd13d940c4c827b5e65a89b1e76e1e36303a4e7 Mon Sep 17 00:00:00 2001 From: Sweta Agarwalla <105535990+sweta1308@users.noreply.github.com> Date: Fri, 10 Jan 2025 15:55:27 +0530 Subject: [PATCH 110/294] Update welcome page condition (#19308) * update welcome page condition * update Welcome page consition * fix conditions --------- Co-authored-by: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> (cherry picked from commit 63090da961c89b5314a8270262aa8d3faf4c7eb9) --- .../src/main/resources/ui/src/App.tsx | 3 -- .../resources/ui/src/hooks/useWelcomeStore.ts | 2 +- .../pages/MyDataPage/MyDataPage.component.tsx | 44 +++++++++---------- 3 files changed, 21 insertions(+), 28 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/App.tsx b/openmetadata-ui/src/main/resources/ui/src/App.tsx index a511d1c40041..cbcc1eaef496 100644 --- a/openmetadata-ui/src/main/resources/ui/src/App.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/App.tsx @@ -30,7 +30,6 @@ import PermissionProvider from './context/PermissionProvider/PermissionProvider' import TourProvider from './context/TourProvider/TourProvider'; import WebSocketProvider from './context/WebSocketProvider/WebSocketProvider'; import { useApplicationStore } from './hooks/useApplicationStore'; -import { useWelcomeStore } from './hooks/useWelcomeStore'; import { getCustomUiThemePreference } from './rest/settingConfigAPI'; import { history } from './utils/HistoryUtils'; import i18n from './utils/i18next/LocalUtil'; @@ -38,7 +37,6 @@ import { getThemeConfig } from './utils/ThemeUtils'; const App: FC = () => { const { applicationConfig, setApplicationConfig } = useApplicationStore(); - const { setIsWelcomeVisible } = useWelcomeStore(); const fetchApplicationConfig = async () => { try { @@ -56,7 +54,6 @@ const App: FC = () => { useEffect(() => { fetchApplicationConfig(); - setIsWelcomeVisible(true); }, []); useEffect(() => { diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useWelcomeStore.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useWelcomeStore.ts index e2d0ffca19fb..fdca6e21bb70 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useWelcomeStore.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useWelcomeStore.ts @@ -18,7 +18,7 @@ interface WelcomeStore { } export const useWelcomeStore = create()((set) => ({ - isWelcomeVisible: false, + isWelcomeVisible: true, setIsWelcomeVisible: (isVisible: boolean) => { set({ isWelcomeVisible: isVisible }); }, diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx index b85dd6cbd2f8..af1078adf031 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx @@ -115,7 +115,7 @@ const MyDataPage = () => { updateWelcomeScreen(!usernameExistsInCookie && isWelcomeVisible); return () => updateWelcomeScreen(false); - }, []); + }, [isWelcomeVisible]); const fetchUserFollowedData = async () => { if (!currentUser?.id) { @@ -196,6 +196,10 @@ const MyDataPage = () => { // call the hook to set the direction of the grid layout useGridLayoutDirection(isLoading); + if (isLoading || applicationsLoading) { + return ; + } + if (showWelcomeScreen) { return (
@@ -209,29 +213,21 @@ const MyDataPage = () => { - {isLoading || applicationsLoading ? ( -
- -
- ) : ( - <> - - {widgets} - - -
-
- - )} + + {widgets} + + +
+
); From 32db93ca28e7e1f1be2a11a32bd164a0cc71e9df Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 13 Jan 2025 07:19:41 +0000 Subject: [PATCH 111/294] chore(release): Prepare Branch for `1.6.3` --- common/pom.xml | 2 +- .../docker-compose-ingestion/docker-compose-ingestion.yml | 2 +- .../docker-compose-openmetadata.yml | 4 ++-- docker/docker-compose-quickstart/Dockerfile | 4 ++-- .../docker-compose-quickstart/docker-compose-postgres.yml | 8 ++++---- docker/docker-compose-quickstart/docker-compose.yml | 8 ++++---- ingestion/Dockerfile | 2 +- ingestion/operators/docker/Dockerfile | 2 +- ingestion/pyproject.toml | 2 +- openmetadata-airflow-apis/pyproject.toml | 2 +- openmetadata-clients/openmetadata-java-client/pom.xml | 2 +- openmetadata-clients/pom.xml | 2 +- openmetadata-dist/pom.xml | 2 +- openmetadata-service/pom.xml | 2 +- openmetadata-shaded-deps/elasticsearch-dep/pom.xml | 2 +- openmetadata-shaded-deps/opensearch-dep/pom.xml | 2 +- openmetadata-shaded-deps/pom.xml | 2 +- openmetadata-spec/pom.xml | 2 +- openmetadata-ui/pom.xml | 2 +- pom.xml | 2 +- 20 files changed, 28 insertions(+), 28 deletions(-) diff --git a/common/pom.xml b/common/pom.xml index 8571f63cc54c..68e54a80978e 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -18,7 +18,7 @@ platform org.open-metadata - 1.6.2 + 1.6.3 4.0.0 diff --git a/docker/docker-compose-ingestion/docker-compose-ingestion.yml b/docker/docker-compose-ingestion/docker-compose-ingestion.yml index 68abdf3f430f..b49d47d6462d 100644 --- a/docker/docker-compose-ingestion/docker-compose-ingestion.yml +++ b/docker/docker-compose-ingestion/docker-compose-ingestion.yml @@ -18,7 +18,7 @@ volumes: services: ingestion: container_name: openmetadata_ingestion - image: docker.getcollate.io/openmetadata/ingestion:1.6.2 + image: docker.getcollate.io/openmetadata/ingestion:1.6.3 environment: AIRFLOW__API__AUTH_BACKENDS: "airflow.api.auth.backend.basic_auth,airflow.api.auth.backend.session" AIRFLOW__CORE__EXECUTOR: LocalExecutor diff --git a/docker/docker-compose-openmetadata/docker-compose-openmetadata.yml b/docker/docker-compose-openmetadata/docker-compose-openmetadata.yml index 5546ef294c27..5c1a6102f1c4 100644 --- a/docker/docker-compose-openmetadata/docker-compose-openmetadata.yml +++ b/docker/docker-compose-openmetadata/docker-compose-openmetadata.yml @@ -14,7 +14,7 @@ services: execute-migrate-all: container_name: execute_migrate_all command: "./bootstrap/openmetadata-ops.sh migrate" - image: docker.getcollate.io/openmetadata/server:1.6.2 + image: docker.getcollate.io/openmetadata/server:1.6.3 environment: OPENMETADATA_CLUSTER_NAME: ${OPENMETADATA_CLUSTER_NAME:-openmetadata} SERVER_PORT: ${SERVER_PORT:-8585} @@ -227,7 +227,7 @@ services: openmetadata-server: container_name: openmetadata_server restart: always - image: docker.getcollate.io/openmetadata/server:1.6.2 + image: docker.getcollate.io/openmetadata/server:1.6.3 environment: OPENMETADATA_CLUSTER_NAME: ${OPENMETADATA_CLUSTER_NAME:-openmetadata} SERVER_PORT: ${SERVER_PORT:-8585} diff --git a/docker/docker-compose-quickstart/Dockerfile b/docker/docker-compose-quickstart/Dockerfile index c55fada178f4..380647e604dd 100644 --- a/docker/docker-compose-quickstart/Dockerfile +++ b/docker/docker-compose-quickstart/Dockerfile @@ -11,7 +11,7 @@ # Build stage FROM alpine:3 AS build -ARG RI_VERSION="1.6.2" +ARG RI_VERSION="1.6.3" ENV RELEASE_URL="https://github.com/open-metadata/OpenMetadata/releases/download/${RI_VERSION}-release/openmetadata-${RI_VERSION}.tar.gz" RUN mkdir -p /opt/openmetadata && \ @@ -21,7 +21,7 @@ RUN mkdir -p /opt/openmetadata && \ # Final stage FROM alpine:3 -ARG RI_VERSION="1.6.2" +ARG RI_VERSION="1.6.3" ARG BUILD_DATE ARG COMMIT_ID LABEL maintainer="OpenMetadata" diff --git a/docker/docker-compose-quickstart/docker-compose-postgres.yml b/docker/docker-compose-quickstart/docker-compose-postgres.yml index df898438ef23..2ce909d22080 100644 --- a/docker/docker-compose-quickstart/docker-compose-postgres.yml +++ b/docker/docker-compose-quickstart/docker-compose-postgres.yml @@ -18,7 +18,7 @@ volumes: services: postgresql: container_name: openmetadata_postgresql - image: docker.getcollate.io/openmetadata/postgresql:1.6.2 + image: docker.getcollate.io/openmetadata/postgresql:1.6.3 restart: always command: "--work_mem=10MB" environment: @@ -61,7 +61,7 @@ services: execute-migrate-all: container_name: execute_migrate_all - image: docker.getcollate.io/openmetadata/server:1.6.2 + image: docker.getcollate.io/openmetadata/server:1.6.3 command: "./bootstrap/openmetadata-ops.sh migrate" environment: OPENMETADATA_CLUSTER_NAME: ${OPENMETADATA_CLUSTER_NAME:-openmetadata} @@ -275,7 +275,7 @@ services: openmetadata-server: container_name: openmetadata_server restart: always - image: docker.getcollate.io/openmetadata/server:1.6.2 + image: docker.getcollate.io/openmetadata/server:1.6.3 environment: OPENMETADATA_CLUSTER_NAME: ${OPENMETADATA_CLUSTER_NAME:-openmetadata} SERVER_PORT: ${SERVER_PORT:-8585} @@ -483,7 +483,7 @@ services: ingestion: container_name: openmetadata_ingestion - image: docker.getcollate.io/openmetadata/ingestion:1.6.2 + image: docker.getcollate.io/openmetadata/ingestion:1.6.3 depends_on: elasticsearch: condition: service_started diff --git a/docker/docker-compose-quickstart/docker-compose.yml b/docker/docker-compose-quickstart/docker-compose.yml index 9ea74153d3f2..66907b10f390 100644 --- a/docker/docker-compose-quickstart/docker-compose.yml +++ b/docker/docker-compose-quickstart/docker-compose.yml @@ -18,7 +18,7 @@ volumes: services: mysql: container_name: openmetadata_mysql - image: docker.getcollate.io/openmetadata/db:1.6.2 + image: docker.getcollate.io/openmetadata/db:1.6.3 command: "--sort_buffer_size=10M" restart: always environment: @@ -59,7 +59,7 @@ services: execute-migrate-all: container_name: execute_migrate_all - image: docker.getcollate.io/openmetadata/server:1.6.2 + image: docker.getcollate.io/openmetadata/server:1.6.3 command: "./bootstrap/openmetadata-ops.sh migrate" environment: OPENMETADATA_CLUSTER_NAME: ${OPENMETADATA_CLUSTER_NAME:-openmetadata} @@ -273,7 +273,7 @@ services: openmetadata-server: container_name: openmetadata_server restart: always - image: docker.getcollate.io/openmetadata/server:1.6.2 + image: docker.getcollate.io/openmetadata/server:1.6.3 environment: OPENMETADATA_CLUSTER_NAME: ${OPENMETADATA_CLUSTER_NAME:-openmetadata} SERVER_PORT: ${SERVER_PORT:-8585} @@ -482,7 +482,7 @@ services: ingestion: container_name: openmetadata_ingestion - image: docker.getcollate.io/openmetadata/ingestion:1.6.2 + image: docker.getcollate.io/openmetadata/ingestion:1.6.3 depends_on: elasticsearch: condition: service_started diff --git a/ingestion/Dockerfile b/ingestion/Dockerfile index f220e0cba106..6b036a50c795 100644 --- a/ingestion/Dockerfile +++ b/ingestion/Dockerfile @@ -76,7 +76,7 @@ ARG INGESTION_DEPENDENCY="all" ENV PIP_NO_CACHE_DIR=1 # Make pip silent ENV PIP_QUIET=1 -ARG RI_VERSION="1.6.2.0" +ARG RI_VERSION="1.6.3.0" RUN pip install --upgrade pip RUN pip install "openmetadata-managed-apis~=${RI_VERSION}" --constraint "https://raw.githubusercontent.com/apache/airflow/constraints-2.9.1/constraints-3.10.txt" RUN pip install "openmetadata-ingestion[${INGESTION_DEPENDENCY}]~=${RI_VERSION}" diff --git a/ingestion/operators/docker/Dockerfile b/ingestion/operators/docker/Dockerfile index b197365c2a52..cec883e1628d 100644 --- a/ingestion/operators/docker/Dockerfile +++ b/ingestion/operators/docker/Dockerfile @@ -81,7 +81,7 @@ ENV PIP_QUIET=1 RUN pip install --upgrade pip ARG INGESTION_DEPENDENCY="all" -ARG RI_VERSION="1.6.2.0" +ARG RI_VERSION="1.6.3.0" RUN pip install --upgrade pip RUN pip install "openmetadata-ingestion[airflow]~=${RI_VERSION}" RUN pip install "openmetadata-ingestion[${INGESTION_DEPENDENCY}]~=${RI_VERSION}" diff --git a/ingestion/pyproject.toml b/ingestion/pyproject.toml index 5f9623b78ff6..629e1851fc05 100644 --- a/ingestion/pyproject.toml +++ b/ingestion/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" # since it helps us organize and isolate version management [project] name = "openmetadata-ingestion" -version = "1.6.2.0" +version = "1.6.3.0" dynamic = ["readme", "dependencies", "optional-dependencies"] authors = [ { name = "OpenMetadata Committers" } diff --git a/openmetadata-airflow-apis/pyproject.toml b/openmetadata-airflow-apis/pyproject.toml index 06001a474709..080d868ae164 100644 --- a/openmetadata-airflow-apis/pyproject.toml +++ b/openmetadata-airflow-apis/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" # since it helps us organize and isolate version management [project] name = "openmetadata_managed_apis" -version = "1.6.2.0" +version = "1.6.3.0" readme = "README.md" authors = [ {name = "OpenMetadata Committers"} diff --git a/openmetadata-clients/openmetadata-java-client/pom.xml b/openmetadata-clients/openmetadata-java-client/pom.xml index a52b7370c011..9c915bfb749e 100644 --- a/openmetadata-clients/openmetadata-java-client/pom.xml +++ b/openmetadata-clients/openmetadata-java-client/pom.xml @@ -5,7 +5,7 @@ openmetadata-clients org.open-metadata - 1.6.2 + 1.6.3 4.0.0 diff --git a/openmetadata-clients/pom.xml b/openmetadata-clients/pom.xml index 0b0ff6be9ac7..2f89ff0ac043 100644 --- a/openmetadata-clients/pom.xml +++ b/openmetadata-clients/pom.xml @@ -5,7 +5,7 @@ platform org.open-metadata - 1.6.2 + 1.6.3 4.0.0 diff --git a/openmetadata-dist/pom.xml b/openmetadata-dist/pom.xml index 6df8762cafde..dba51cd63ea8 100644 --- a/openmetadata-dist/pom.xml +++ b/openmetadata-dist/pom.xml @@ -20,7 +20,7 @@ platform org.open-metadata - 1.6.2 + 1.6.3 openmetadata-dist diff --git a/openmetadata-service/pom.xml b/openmetadata-service/pom.xml index 0554f03811fc..4e4a27534ef2 100644 --- a/openmetadata-service/pom.xml +++ b/openmetadata-service/pom.xml @@ -5,7 +5,7 @@ platform org.open-metadata - 1.6.2 + 1.6.3 4.0.0 openmetadata-service diff --git a/openmetadata-shaded-deps/elasticsearch-dep/pom.xml b/openmetadata-shaded-deps/elasticsearch-dep/pom.xml index 23ea37785246..8e731e8c43fc 100644 --- a/openmetadata-shaded-deps/elasticsearch-dep/pom.xml +++ b/openmetadata-shaded-deps/elasticsearch-dep/pom.xml @@ -5,7 +5,7 @@ openmetadata-shaded-deps org.open-metadata - 1.6.2 + 1.6.3 4.0.0 elasticsearch-deps diff --git a/openmetadata-shaded-deps/opensearch-dep/pom.xml b/openmetadata-shaded-deps/opensearch-dep/pom.xml index 78247a1d7bbc..99ac0eae6b44 100644 --- a/openmetadata-shaded-deps/opensearch-dep/pom.xml +++ b/openmetadata-shaded-deps/opensearch-dep/pom.xml @@ -5,7 +5,7 @@ openmetadata-shaded-deps org.open-metadata - 1.6.2 + 1.6.3 4.0.0 opensearch-deps diff --git a/openmetadata-shaded-deps/pom.xml b/openmetadata-shaded-deps/pom.xml index 1e86bb9844c9..570e906a22ab 100644 --- a/openmetadata-shaded-deps/pom.xml +++ b/openmetadata-shaded-deps/pom.xml @@ -5,7 +5,7 @@ platform org.open-metadata - 1.6.2 + 1.6.3 4.0.0 openmetadata-shaded-deps diff --git a/openmetadata-spec/pom.xml b/openmetadata-spec/pom.xml index a4c9249d1bf6..2c3aa971b3fc 100644 --- a/openmetadata-spec/pom.xml +++ b/openmetadata-spec/pom.xml @@ -5,7 +5,7 @@ platform org.open-metadata - 1.6.2 + 1.6.3 4.0.0 diff --git a/openmetadata-ui/pom.xml b/openmetadata-ui/pom.xml index da7e671c6ed9..3af76be08b96 100644 --- a/openmetadata-ui/pom.xml +++ b/openmetadata-ui/pom.xml @@ -5,7 +5,7 @@ platform org.open-metadata - 1.6.2 + 1.6.3 4.0.0 diff --git a/pom.xml b/pom.xml index 725acae72502..8aedbaabf801 100644 --- a/pom.xml +++ b/pom.xml @@ -26,7 +26,7 @@ based on Open Metadata Standards/APIs, supporting connectors to a wide range of data services, OpenMetadata enables end-to-end metadata management, giving you the freedom to unlock the value of your data assets. - 1.6.2 + 1.6.3 https://github.com/open-metadata/OpenMetadata openmetadata-spec From 4b2ff1cba9413d863b0855b3dc97002b682380ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Mu=C3=B1oz=20Vera?= Date: Mon, 13 Jan 2025 16:55:52 +0100 Subject: [PATCH 112/294] Fix(ci) downgrade ubuntu version on playwright jobs (#19339) * Fix: downgrading ubuntu version to 22.04 on playwright jobs * Fix: indentation issue in playwright skip jobs (cherry picked from commit 21dd3e4342225e559a48e74f4366e25bf9a1f3f7) --- .github/workflows/playwright-mysql-e2e-skip.yml | 8 ++++---- .github/workflows/playwright-mysql-e2e.yml | 2 +- .github/workflows/playwright-postgresql-e2e-skip.yml | 8 ++++---- .github/workflows/playwright-postgresql-e2e.yml | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/playwright-mysql-e2e-skip.yml b/.github/workflows/playwright-mysql-e2e-skip.yml index 29b27a8287ac..54298c6561d1 100644 --- a/.github/workflows/playwright-mysql-e2e-skip.yml +++ b/.github/workflows/playwright-mysql-e2e-skip.yml @@ -31,9 +31,9 @@ jobs: playwright-ci-mysql: runs-on: ubuntu-latest strategy: - fail-fast: false - matrix: - shardIndex: [1, 2] - shardTotal: [2] + fail-fast: false + matrix: + shardIndex: [1, 2] + shardTotal: [2] steps: - run: 'echo "Step is not required"' diff --git a/.github/workflows/playwright-mysql-e2e.yml b/.github/workflows/playwright-mysql-e2e.yml index dd97f72597a6..0857cc61c65e 100644 --- a/.github/workflows/playwright-mysql-e2e.yml +++ b/.github/workflows/playwright-mysql-e2e.yml @@ -36,7 +36,7 @@ concurrency: jobs: playwright-ci-mysql: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 environment: test strategy: fail-fast: false diff --git a/.github/workflows/playwright-postgresql-e2e-skip.yml b/.github/workflows/playwright-postgresql-e2e-skip.yml index 119e36fbb650..f9be1a79152c 100644 --- a/.github/workflows/playwright-postgresql-e2e-skip.yml +++ b/.github/workflows/playwright-postgresql-e2e-skip.yml @@ -31,9 +31,9 @@ jobs: playwright-ci-postgresql: runs-on: ubuntu-latest strategy: - fail-fast: false - matrix: - shardIndex: [1, 2] - shardTotal: [2] + fail-fast: false + matrix: + shardIndex: [1, 2] + shardTotal: [2] steps: - run: 'echo "Step is not required"' diff --git a/.github/workflows/playwright-postgresql-e2e.yml b/.github/workflows/playwright-postgresql-e2e.yml index b3ea6c978623..11d8d4408f9b 100644 --- a/.github/workflows/playwright-postgresql-e2e.yml +++ b/.github/workflows/playwright-postgresql-e2e.yml @@ -36,7 +36,7 @@ concurrency: jobs: playwright-ci-postgresql: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 environment: test strategy: fail-fast: false From cbbbca5472f6dd1471dce72df51c43b34113cc13 Mon Sep 17 00:00:00 2001 From: agriev Date: Tue, 14 Jan 2025 02:51:40 +0100 Subject: [PATCH 113/294] Adds percona server for postgresql support (#19322) * percona server for postgresql support The only meaningful difference is version string in percona server for postgresql. So commit propose universal and safe way to detect server version by integer string, not complicated parsing of unformatted string. * updated tests with get_server_version_num commented outdated tests --------- Co-authored-by: Sriharsha Chintalapani (cherry picked from commit dcebc41e3f845933aaa9b9de8761eeb8fc52f9ee) --- .../ingestion/source/database/postgres/queries.py | 2 +- .../ingestion/source/database/postgres/utils.py | 5 +---- .../tests/unit/topology/database/test_postgres.py | 13 +++++++------ 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/ingestion/src/metadata/ingestion/source/database/postgres/queries.py b/ingestion/src/metadata/ingestion/source/database/postgres/queries.py index e6cf7df513cf..ec50272846bc 100644 --- a/ingestion/src/metadata/ingestion/source/database/postgres/queries.py +++ b/ingestion/src/metadata/ingestion/source/database/postgres/queries.py @@ -193,7 +193,7 @@ """ POSTGRES_GET_SERVER_VERSION = """ -show server_version +show server_version_num """ POSTGRES_FETCH_FK = """ diff --git a/ingestion/src/metadata/ingestion/source/database/postgres/utils.py b/ingestion/src/metadata/ingestion/source/database/postgres/utils.py index 246519c00e26..aed0322d3156 100644 --- a/ingestion/src/metadata/ingestion/source/database/postgres/utils.py +++ b/ingestion/src/metadata/ingestion/source/database/postgres/utils.py @@ -45,7 +45,7 @@ logger = utils_logger() -OLD_POSTGRES_VERSION = "13.0" +OLD_POSTGRES_VERSION = "130000" def get_etable_owner( @@ -523,9 +523,6 @@ def get_postgres_version(engine) -> Optional[str]: results = engine.execute(POSTGRES_GET_SERVER_VERSION) for res in results: version_string = str(res[0]) - opening_parenthesis_index = version_string.find("(") - if opening_parenthesis_index != -1: - return version_string[:opening_parenthesis_index].strip() return version_string except Exception as err: logger.warning(f"Unable to fetch the Postgres Version - {err}") diff --git a/ingestion/tests/unit/topology/database/test_postgres.py b/ingestion/tests/unit/topology/database/test_postgres.py index 86da89043473..8848cc55960d 100644 --- a/ingestion/tests/unit/topology/database/test_postgres.py +++ b/ingestion/tests/unit/topology/database/test_postgres.py @@ -320,14 +320,15 @@ def test_datatype(self): @patch("sqlalchemy.engine.base.Engine") def test_get_version_info(self, engine): - engine.execute.return_value = [["15.3 (Debian 15.3-1.pgdg110+1)"]] - self.assertEqual("15.3", get_postgres_version(engine)) + # outdated with a switch to get_server_version_num instead of get_+server_version + # engine.execute.return_value = [["15.3 (Debian 15.3-1.pgdg110+1)"]] + # self.assertEqual("15.3", get_postgres_version(engine)) - engine.execute.return_value = [["11.16"]] - self.assertEqual("11.16", get_postgres_version(engine)) + engine.execute.return_value = [["110016"]] + self.assertEqual("110016", get_postgres_version(engine)) - engine.execute.return_value = [["9.6.24"]] - self.assertEqual("9.6.24", get_postgres_version(engine)) + engine.execute.return_value = [["90624"]] + self.assertEqual("90624", get_postgres_version(engine)) engine.execute.return_value = [[]] self.assertIsNone(get_postgres_version(engine)) From bde5b277f847e125adb3096e9ff764c616f4d176 Mon Sep 17 00:00:00 2001 From: Shailesh Parmar Date: Tue, 14 Jan 2025 20:21:01 +0530 Subject: [PATCH 114/294] Minor: added loading state, and error notification in testSuite. (#19350) * Minor: added loading state, and error notification in testSuite. * added the unit test (cherry picked from commit 05f68ec7761b9b441920986fa856f6abaa092e01) --- .../AddDataQualityTest/TestSuiteIngestion.tsx | 11 ++- .../AddTestCaseList.component.test.tsx | 20 ++++- .../AddTestCaseList.component.tsx | 2 +- .../AddTestCaseList.interface.ts | 2 +- .../DataQualityTab/DataQualityTab.test.tsx | 74 ++++++++++++++++++- .../DataQualityTab/DataQualityTab.tsx | 6 ++ 6 files changed, 105 insertions(+), 10 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/TestSuiteIngestion.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/TestSuiteIngestion.tsx index a8671fcfd83f..1f3605c99154 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/TestSuiteIngestion.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/TestSuiteIngestion.tsx @@ -186,11 +186,14 @@ const TestSuiteIngestion: React.FC = ({ }, }, }; + try { + const ingestion = await addIngestionPipeline(ingestionPayload); - const ingestion = await addIngestionPipeline(ingestionPayload); - - setIngestionData(ingestion); - handleIngestionDeploy(ingestion.id); + setIngestionData(ingestion); + handleIngestionDeploy(ingestion.id); + } catch (error) { + showErrorToast(error as AxiosError); + } }; const onUpdateIngestionPipeline = async ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddTestCaseList/AddTestCaseList.component.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddTestCaseList/AddTestCaseList.component.test.tsx index d7ccd5addaeb..e2500a7c4b65 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddTestCaseList/AddTestCaseList.component.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddTestCaseList/AddTestCaseList.component.test.tsx @@ -10,7 +10,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { act, fireEvent, render, screen } from '@testing-library/react'; +import { + act, + fireEvent, + queryByAttribute, + render, + screen, + waitFor, +} from '@testing-library/react'; import React from 'react'; import { EntityReference } from '../../../generated/tests/testCase'; import { AddTestCaseList } from './AddTestCaseList.component'; @@ -102,11 +109,20 @@ describe('AddTestCaseList', () => { await act(async () => { render(); }); + const submitBtn = screen.getByTestId('submit'); await act(async () => { - await fireEvent.click(screen.getByTestId('submit')); + fireEvent.click(submitBtn); + await waitFor(() => { + const loader = queryByAttribute('aria-label', submitBtn, 'loading'); + + expect(loader).toBeInTheDocument(); + }); }); expect(mockProps.onSubmit).toHaveBeenCalledWith([]); + expect( + queryByAttribute('aria-label', submitBtn, 'loading') + ).not.toBeInTheDocument(); }); it('does not render submit and cancel buttons when showButton is false', async () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddTestCaseList/AddTestCaseList.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddTestCaseList/AddTestCaseList.component.tsx index 3c409794a2b0..58148d23f633 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddTestCaseList/AddTestCaseList.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddTestCaseList/AddTestCaseList.component.tsx @@ -110,7 +110,7 @@ export const AddTestCaseList = ({ const handleSubmit = async () => { setIsLoading(true); const testCaseIds = [...(selectedItems?.values() ?? [])]; - onSubmit?.(testCaseIds); + await onSubmit?.(testCaseIds); setIsLoading(false); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddTestCaseList/AddTestCaseList.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddTestCaseList/AddTestCaseList.interface.ts index 8a792411fa85..1e804c945956 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddTestCaseList/AddTestCaseList.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddTestCaseList/AddTestCaseList.interface.ts @@ -15,7 +15,7 @@ import { ListTestCaseParamsBySearch } from '../../../rest/testAPI'; export interface AddTestCaseModalProps { onCancel?: () => void; - onSubmit?: (testCases: TestCase[]) => void; + onSubmit?: (testCases: TestCase[]) => void | Promise; onChange?: (testCases: TestCase[]) => void; existingTest?: EntityReference[]; cancelText?: string; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/DataQualityTab/DataQualityTab.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/DataQualityTab/DataQualityTab.test.tsx index 18574d0acde7..8e0dc164ecd9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/DataQualityTab/DataQualityTab.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/DataQualityTab/DataQualityTab.test.tsx @@ -18,6 +18,7 @@ import { fireEvent, render, screen, + waitFor, } from '@testing-library/react'; import React from 'react'; import { MOCK_TEST_CASE } from '../../../../mocks/TestSuite.mock'; @@ -68,7 +69,7 @@ jest.mock('../../../common/DeleteWidget/DeleteWidgetModal', () => { return ( visible && (
- DeleteWidgetModal +

DeleteWidgetModal

) @@ -80,7 +81,7 @@ jest.mock('../../../DataQuality/AddDataQualityTest/EditTestCaseModal', () => { return ( visible && (
- EditTestCaseModal +

EditTestCaseModal

@@ -88,6 +89,28 @@ jest.mock('../../../DataQuality/AddDataQualityTest/EditTestCaseModal', () => { ); }); }); +jest.mock('../../../Modals/ConfirmationModal/ConfirmationModal', () => { + return jest + .fn() + .mockImplementation(({ visible, onCancel, onConfirm, isLoading }) => { + return ( + visible && ( +
+

ConfirmationModal

+ + +
+ ) + ); + }); +}); describe('DataQualityTab test', () => { it('Component should render', async () => { @@ -216,6 +239,53 @@ describe('DataQualityTab test', () => { expect(deleteButton).toBeInTheDocument(); }); + it('Remove functionality', async () => { + const firstRowData = MOCK_TEST_CASE[0]; + await act(async () => { + render( + + ); + }); + const tableRows = await screen.findAllByRole('row'); + const firstRow = tableRows[1]; + const closeRemoveModel = screen.queryByText('ConfirmationModal'); + const removeButton = await findByTestId( + firstRow, + `remove-${firstRowData.name}` + ); + + expect(removeButton).toBeInTheDocument(); + expect(closeRemoveModel).not.toBeInTheDocument(); + + await act(async () => { + fireEvent.click(removeButton); + }); + const openRemoveModel = await screen.findByText('ConfirmationModal'); + const submitBtn = await screen.findByText('submit'); + + expect(openRemoveModel).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(submitBtn); + + await waitFor(() => { + const loader = screen.getByTestId('submit-btn-loading'); + + expect(loader).toBeInTheDocument(); + }); + }); + + expect(closeRemoveModel).not.toBeInTheDocument(); + }); + it('Edit functionality', async () => { const firstRowData = MOCK_TEST_CASE[0]; await act(async () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/DataQualityTab/DataQualityTab.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/DataQualityTab/DataQualityTab.tsx index 08d3dd98f4fe..21ec041b72fc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/DataQualityTab/DataQualityTab.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/DataQualityTab/DataQualityTab.tsx @@ -88,6 +88,8 @@ const DataQualityTab: React.FC = ({ const [testCaseStatus, setTestCaseStatus] = useState< TestCaseResolutionStatus[] >([]); + const [isTestCaseRemovalLoading, setIsTestCaseRemovalLoading] = + useState(false); const isApiSortingEnabled = useRef(false); const testCaseEditPermission = useMemo(() => { @@ -131,6 +133,7 @@ const DataQualityTab: React.FC = ({ }; const handleConfirmClick = async () => { + setIsTestCaseRemovalLoading(true); if (isUndefined(removeFromTestSuite)) { return; } @@ -143,6 +146,8 @@ const DataQualityTab: React.FC = ({ setSelectedTestCase(undefined); } catch (error) { showErrorToast(error as AxiosError); + } finally { + setIsTestCaseRemovalLoading(false); } }; @@ -514,6 +519,7 @@ const DataQualityTab: React.FC = ({ cancelText={t('label.cancel')} confirmText={t('label.remove')} header={t('label.remove-entity', { entity: t('label.test-case') })} + isLoading={isTestCaseRemovalLoading} visible={selectedTestCase?.action === 'DELETE'} onCancel={handleCancel} onConfirm={handleConfirmClick} From 2864e179765d3def59b8eca487f11c68e66aa1ea Mon Sep 17 00:00:00 2001 From: Ashish Gupta Date: Tue, 14 Jan 2025 20:36:14 +0530 Subject: [PATCH 115/294] MINOR: fix the favicon not being updated (#19357) * fix the favicon not being updated * minor condition check added (cherry picked from commit a28c30ec7f4561af450482a8d074eabef78fc616) --- openmetadata-ui/src/main/resources/ui/src/App.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/App.tsx b/openmetadata-ui/src/main/resources/ui/src/App.tsx index cbcc1eaef496..a038ba039a38 100644 --- a/openmetadata-ui/src/main/resources/ui/src/App.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/App.tsx @@ -63,10 +63,12 @@ const App: FC = () => { ? '/favicon.png' : applicationConfig?.customLogoConfig?.customFaviconUrlPath ?? '/favicon.png'; - const link = document.querySelector('link[rel~="icon"]'); + const link = document.querySelectorAll('link[rel~="icon"]'); - if (link) { - link.setAttribute('href', faviconHref); + if (!isEmpty(link)) { + link.forEach((item) => { + item.setAttribute('href', faviconHref); + }); } }, [applicationConfig]); From 25b0fb39e920649dce141b3a01e56faddffe93f6 Mon Sep 17 00:00:00 2001 From: Karan Hotchandani <33024356+karanh37@users.noreply.github.com> Date: Tue, 14 Jan 2025 21:44:40 +0530 Subject: [PATCH 116/294] fix glossary tests (#19352) (cherry picked from commit 94cbadb772a0f118fcc0621b2ad94d7e8e295709) --- .../resources/ui/playwright/e2e/Pages/Glossary.spec.ts | 8 ++++++-- .../src/main/resources/ui/playwright/utils/glossary.ts | 5 ++++- 2 files changed, 10 insertions(+), 3 deletions(-) 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 2e4e0da8ce1f..eb734d2a8156 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 @@ -1216,13 +1216,17 @@ test.describe('Glossary tests', () => { const firstGlossaryName = json.data[0].displayName; await expect( - page.getByRole('menuitem', { name: firstGlossaryName }) + page + .getByTestId('glossary-left-panel') + .getByRole('menuitem', { name: firstGlossaryName }) ).toBeVisible(); const lastGlossaryName = json.data[json.data.length - 1].displayName; await expect( - page.getByRole('menuitem', { name: lastGlossaryName }) + page + .getByTestId('glossary-left-panel') + .getByRole('menuitem', { name: lastGlossaryName }) ).toBeVisible(); } finally { for (const glossary of glossaries) { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts index 4ee37ae5ca0c..339fadbd240f 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts @@ -62,11 +62,14 @@ export const selectActiveGlossary = async ( const isSelected = await menuItem.evaluate((element) => { return element.classList.contains('ant-menu-item-selected'); }); - if (!isSelected) { const glossaryResponse = page.waitForResponse('/api/v1/glossaryTerms*'); await menuItem.click(); await glossaryResponse; + } else { + await page.waitForSelector('[data-testid="loader"]', { + state: 'detached', + }); } }; From 12a9da0864f4e1cc0255e698d8f8f30558902dbc Mon Sep 17 00:00:00 2001 From: sonika-shah <58761340+sonika-shah@users.noreply.github.com> Date: Tue, 14 Jan 2025 22:16:23 +0530 Subject: [PATCH 117/294] dataProduct : get inherited fields from corresponding Domain (#19343) * dataProduct : get inherited fields from corresponding Domain * add tests --------- Co-authored-by: karanh37 (cherry picked from commit 788dfc331f0dae238c0c27a226e6208cc5031b17) --- .../service/jdbi3/DataProductRepository.java | 19 +++++ .../domains/DataProductResourceTest.java | 79 +++++++++++++++++++ .../resources/domains/DomainResourceTest.java | 8 ++ .../ui/playwright/e2e/Pages/Domains.spec.ts | 55 +++++++++++++ .../ui/playwright/support/domain/Domain.ts | 4 +- .../ui/playwright/support/domain/SubDomain.ts | 4 +- 6 files changed, 167 insertions(+), 2 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DataProductRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DataProductRepository.java index ff9f463d98a1..5af70aa6e4e5 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DataProductRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DataProductRepository.java @@ -14,7 +14,9 @@ package org.openmetadata.service.jdbi3; import static org.openmetadata.common.utils.CommonUtil.listOrEmpty; +import static org.openmetadata.schema.type.Include.ALL; import static org.openmetadata.service.Entity.DATA_PRODUCT; +import static org.openmetadata.service.Entity.DOMAIN; import static org.openmetadata.service.Entity.FIELD_ASSETS; import static org.openmetadata.service.util.EntityUtil.entityReferenceMatch; @@ -26,6 +28,7 @@ import org.jdbi.v3.sqlobject.transaction.Transaction; import org.openmetadata.schema.EntityInterface; import org.openmetadata.schema.entity.domains.DataProduct; +import org.openmetadata.schema.entity.domains.Domain; import org.openmetadata.schema.type.EntityReference; import org.openmetadata.schema.type.Include; import org.openmetadata.schema.type.Relationship; @@ -93,6 +96,22 @@ public void storeRelationships(DataProduct entity) { } } + public final EntityReference getDomain(Domain domain) { + return getFromEntityRef(domain.getId(), Relationship.CONTAINS, DOMAIN, false); + } + + @Override + public void setInheritedFields(DataProduct dataProduct, Fields fields) { + // If dataProduct does not have owners and experts, inherit them from its domain + EntityReference parentRef = + dataProduct.getDomain() != null ? dataProduct.getDomain() : getDomain(dataProduct); + if (parentRef != null) { + Domain parent = Entity.getEntity(DOMAIN, parentRef.getId(), "owners,experts", ALL); + inheritOwners(dataProduct, fields, parent); + inheritExperts(dataProduct, fields, parent); + } + } + @Override public EntityUpdater getUpdater(DataProduct original, DataProduct updated, Operation operation) { return new DataProductUpdater(original, updated, operation); diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/domains/DataProductResourceTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/domains/DataProductResourceTest.java index 4069fbc5a291..a83a5bf224c3 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/resources/domains/DataProductResourceTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/domains/DataProductResourceTest.java @@ -23,8 +23,10 @@ import org.junit.jupiter.api.TestInfo; import org.openmetadata.schema.EntityInterface; import org.openmetadata.schema.api.domains.CreateDataProduct; +import org.openmetadata.schema.api.domains.CreateDomain; import org.openmetadata.schema.entity.data.Topic; import org.openmetadata.schema.entity.domains.DataProduct; +import org.openmetadata.schema.entity.domains.Domain; import org.openmetadata.schema.entity.type.Style; import org.openmetadata.schema.type.ChangeDescription; import org.openmetadata.schema.type.EntityReference; @@ -177,6 +179,74 @@ void testValidateDataProducts() { .hasMessage(String.format("dataProduct instance for %s not found", rdnUUID)); } + @Test + void test_inheritOwnerExpertsFromDomain(TestInfo test) throws IOException { + DomainResourceTest domainResourceTest = new DomainResourceTest(); + + // Create parent domain + CreateDomain parentDomainReq = + domainResourceTest + .createRequest(test, 1) + .withOwners(List.of(USER1_REF)) + .withExperts(List.of(USER2.getFullyQualifiedName())); + Domain parentDomain = domainResourceTest.createEntity(parentDomainReq, ADMIN_AUTH_HEADERS); + parentDomain = domainResourceTest.getEntity(parentDomain.getId(), "*", ADMIN_AUTH_HEADERS); + + // Create data product corresponding to parent domain + CreateDataProduct create = + createRequestWithoutExpertsOwners(getEntityName(test, 1)) + .withDomain(parentDomain.getFullyQualifiedName()); + DataProduct dataProduct = createAndCheckEntity(create, ADMIN_AUTH_HEADERS); + assertOwners(dataProduct.getOwners(), parentDomain.getOwners()); + assertEntityReferences(dataProduct.getExperts(), parentDomain.getExperts()); + + // Create subdomain with no owners and experts + CreateDomain subDomainReq = + domainResourceTest + .createRequestWithoutOwnersExperts(getEntityName(test, 2)) + .withDomain(parentDomain.getFullyQualifiedName()); + Domain subDomain = domainResourceTest.createEntity(subDomainReq, ADMIN_AUTH_HEADERS); + subDomain = domainResourceTest.getEntity(subDomain.getId(), "*", ADMIN_AUTH_HEADERS); + + // Create data product corresponding to subdomain + CreateDataProduct subDomainDataProductCreate = + createRequestWithoutExpertsOwners(getEntityName(test, 2)) + .withDomain(subDomain.getFullyQualifiedName()); + DataProduct subDomainDataProduct = + createAndCheckEntity(subDomainDataProductCreate, ADMIN_AUTH_HEADERS); + + // Subdomain and its data product should inherit owners and experts from parent domain + assertOwners(subDomain.getOwners(), parentDomain.getOwners()); + assertEntityReferences(subDomain.getExperts(), parentDomain.getExperts()); + assertOwners(subDomainDataProduct.getOwners(), parentDomain.getOwners()); + assertEntityReferences(subDomainDataProduct.getExperts(), parentDomain.getExperts()); + + // Add owner and expert to subdomain + Domain updateSubDomainOwner = + JsonUtils.readValue(JsonUtils.pojoToJson(subDomain), Domain.class); + updateSubDomainOwner.setOwners(List.of(TEAM11_REF)); + domainResourceTest.patchEntity( + subDomain.getId(), + JsonUtils.pojoToJson(subDomain), + updateSubDomainOwner, + ADMIN_AUTH_HEADERS); + subDomain = domainResourceTest.getEntity(subDomain.getId(), "*", ADMIN_AUTH_HEADERS); + + Domain updateSubDomainExpert = + JsonUtils.readValue(JsonUtils.pojoToJson(subDomain), Domain.class); + updateSubDomainExpert.setExperts(List.of(USER1_REF)); + domainResourceTest.patchEntity( + subDomain.getId(), + JsonUtils.pojoToJson(subDomain), + updateSubDomainExpert, + ADMIN_AUTH_HEADERS); + subDomain = domainResourceTest.getEntity(subDomain.getId(), "*", ADMIN_AUTH_HEADERS); + + // Data product of subdomain should also have the same changes as its corresponding domain + assertOwners(subDomainDataProduct.getOwners(), subDomain.getOwners()); + assertEntityReferences(subDomainDataProduct.getExperts(), subDomain.getExperts()); + } + private void entityInDataProduct( EntityInterface entity, EntityInterface product, boolean inDataProduct) throws HttpResponseException { @@ -200,6 +270,15 @@ public CreateDataProduct createRequest(String name) { .withAssets(TEST_TABLE1 != null ? listOf(TEST_TABLE1.getEntityReference()) : null); } + public CreateDataProduct createRequestWithoutExpertsOwners(String name) { + return new CreateDataProduct() + .withName(name) + .withDescription(name) + .withDomain(DOMAIN.getFullyQualifiedName()) + .withStyle(new Style().withColor("#40E0D0").withIconURL("https://dataProductIcon")) + .withAssets(TEST_TABLE1 != null ? listOf(TEST_TABLE1.getEntityReference()) : null); + } + @Override public void validateCreatedEntity( DataProduct createdEntity, CreateDataProduct request, Map authHeaders) { diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/domains/DomainResourceTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/domains/DomainResourceTest.java index 7054353f2f6c..f2d1e8540309 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/resources/domains/DomainResourceTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/domains/DomainResourceTest.java @@ -153,6 +153,14 @@ public CreateDomain createRequest(String name) { .withExperts(listOf(USER1.getFullyQualifiedName())); } + public CreateDomain createRequestWithoutOwnersExperts(String name) { + return new CreateDomain() + .withName(name) + .withDomainType(DomainType.AGGREGATE) + .withDescription("name") + .withStyle(new Style().withColor("#FFA07A").withIconURL("https://domainIcon")); + } + @Override public void validateCreatedEntity( Domain createdEntity, CreateDomain request, Map authHeaders) { 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 528eda7b740c..c53c42e60b83 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 @@ -334,6 +334,61 @@ test.describe('Domains', () => { await afterAction(); } }); + + test('Should inherit owners and experts from parent domain', async ({ + page, + }) => { + const { afterAction, apiContext } = await getApiContext(page); + const user1 = new UserClass(); + const user2 = new UserClass(); + let domain; + let dataProduct; + try { + await user1.create(apiContext); + await user2.create(apiContext); + + domain = new Domain({ + name: 'PW_Domain_Inherit_Testing', + displayName: 'PW_Domain_Inherit_Testing', + description: 'playwright domain description', + domainType: 'Aggregate', + fullyQualifiedName: 'PW_Domain_Inherit_Testing', + owners: [ + { + name: user1.responseData.name, + type: 'user', + fullyQualifiedName: user1.responseData.fullyQualifiedName ?? '', + id: user1.responseData.id, + }, + ], + experts: [user2.responseData.name], + }); + dataProduct = new DataProduct(domain); + await domain.create(apiContext); + await dataProduct.create(apiContext); + + await page.reload(); + await redirectToHomePage(page); + + await sidebarClick(page, SidebarItem.DOMAIN); + await selectDomain(page, domain.data); + await selectDataProduct(page, domain.data, dataProduct.data); + + await expect( + page.getByTestId('domain-owner-name').getByTestId('owner-label') + ).toContainText(user1.responseData.displayName); + + await expect( + page.getByTestId('domain-expert-name').getByTestId('owner-label') + ).toContainText(user2.responseData.displayName); + } finally { + await dataProduct?.delete(apiContext); + await domain?.delete(apiContext); + await user1.delete(apiContext); + await user2.delete(apiContext); + await afterAction(); + } + }); }); test.describe('Domains Rbac', () => { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/domain/Domain.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/domain/Domain.ts index d5e760a47a85..e3cbee7ed962 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/domain/Domain.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/domain/Domain.ts @@ -16,6 +16,8 @@ import { uuid } from '../../utils/common'; type UserTeamRef = { name: string; type: string; + fullyQualifiedName?: string; + id?: string; }; type ResponseDataType = { @@ -26,7 +28,7 @@ type ResponseDataType = { id?: string; fullyQualifiedName?: string; owners?: UserTeamRef[]; - experts?: UserTeamRef[]; + experts?: string[]; }; export class Domain { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/domain/SubDomain.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/domain/SubDomain.ts index fb7c3c1fbd55..dabe1cafb841 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/domain/SubDomain.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/domain/SubDomain.ts @@ -17,6 +17,8 @@ import { Domain } from './Domain'; type UserTeamRef = { name: string; type: string; + fullyQualifiedName?: string; + id?: string; }; type ResponseDataType = { @@ -27,7 +29,7 @@ type ResponseDataType = { id?: string; fullyQualifiedName?: string; owners?: UserTeamRef[]; - experts?: UserTeamRef[]; + experts?: string[]; parent?: string; }; From fdad1fcf5272dd5685ac989a7c43667f256aabc3 Mon Sep 17 00:00:00 2001 From: Mohit Yadav <105265192+mohityadav766@users.noreply.github.com> Date: Tue, 14 Jan 2025 19:47:48 +0530 Subject: [PATCH 118/294] Fix Search Index for ER Model (#19349) --- .../openmetadata/service/search/indexes/SearchIndex.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/indexes/SearchIndex.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/indexes/SearchIndex.java index e75cc7262b61..250d0aff53dc 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/indexes/SearchIndex.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/indexes/SearchIndex.java @@ -1,7 +1,7 @@ package org.openmetadata.service.search.indexes; import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; -import static org.openmetadata.schema.type.Include.NON_DELETED; +import static org.openmetadata.schema.type.Include.ALL; import static org.openmetadata.service.Entity.FIELD_DESCRIPTION; import static org.openmetadata.service.Entity.FIELD_DISPLAY_NAME; import static org.openmetadata.service.Entity.getEntityByName; @@ -196,7 +196,7 @@ private static void processConstraints( String destinationIndexName = null; try { if (updateForeignTableIndex) { - relatedEntity = getEntityByName(Entity.TABLE, relatedEntityFQN, "*", NON_DELETED); + relatedEntity = getEntityByName(Entity.TABLE, relatedEntityFQN, "*", ALL); IndexMapping destinationIndexMapping = Entity.getSearchRepository() .getIndexMapping(relatedEntity.getEntityReference().getType()); @@ -253,8 +253,7 @@ static List> populateEntityRelationshipData(Table entity) { .findFrom(entity.getId(), Entity.TABLE, Relationship.RELATED_TO.ordinal()); for (CollectionDAO.EntityRelationshipRecord table : relatedTables) { - Table foreignTable = - Entity.getEntity(Entity.TABLE, table.getId(), "tableConstraints", NON_DELETED); + Table foreignTable = Entity.getEntity(Entity.TABLE, table.getId(), "tableConstraints", ALL); processConstraints(foreignTable, entity, constraints, false); } return constraints; From 044de743a1139a71eb61df786f386528a4b25a95 Mon Sep 17 00:00:00 2001 From: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> Date: Sat, 11 Jan 2025 11:15:16 +0530 Subject: [PATCH 119/294] test(e2e): fix flaky searchIndexApplication.spec.ts (#19318) (cherry picked from commit 9f19b42668d02e269b0985c336cb6820546d8417) --- .../ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts index b4a0e491b5a0..62502e889f22 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts @@ -113,6 +113,12 @@ test('Search Index Application', async ({ page }) => { await page.getByTestId('tree-select-widget').click(); + // Bring table option to view in dropdown via searching for it + await page + .getByTestId('tree-select-widget') + .getByRole('combobox') + .fill('Table'); + // uncheck the entity await page.getByRole('tree').getByTitle('Table').click(); From 4b80b4a43387141d6050aaeb6834a56d7890168e Mon Sep 17 00:00:00 2001 From: Ashish Gupta Date: Wed, 15 Jan 2025 12:28:54 +0530 Subject: [PATCH 120/294] MINOR: fix the glossary description column width on term page (#19332) * fix the glossary description column width on term page * decrease the width so for smaller screen, other column should not overflow (cherry picked from commit b85883bfe772da6848b76a17f4235930c761e69f) --- .../Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx index eb7d4538229b..f45e2b3fdd18 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx @@ -184,7 +184,7 @@ const GlossaryTermTab = ({ title: t('label.description'), dataIndex: 'description', key: 'description', - width: 250, + width: isGlossary ? 250 : 650, render: (description: string) => description.trim() ? ( From f4da0306fdfc822608561a2540b555401bd3f949 Mon Sep 17 00:00:00 2001 From: Mohit Yadav <105265192+mohityadav766@users.noreply.github.com> Date: Tue, 14 Jan 2025 19:47:48 +0530 Subject: [PATCH 121/294] Fix Search Index for ER Model (#19349) (cherry picked from commit 3c2f037534475e13459f04f70e42d0b9456a7652) --- .../openmetadata/service/search/indexes/SearchIndex.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/indexes/SearchIndex.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/indexes/SearchIndex.java index e75cc7262b61..250d0aff53dc 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/indexes/SearchIndex.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/indexes/SearchIndex.java @@ -1,7 +1,7 @@ package org.openmetadata.service.search.indexes; import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; -import static org.openmetadata.schema.type.Include.NON_DELETED; +import static org.openmetadata.schema.type.Include.ALL; import static org.openmetadata.service.Entity.FIELD_DESCRIPTION; import static org.openmetadata.service.Entity.FIELD_DISPLAY_NAME; import static org.openmetadata.service.Entity.getEntityByName; @@ -196,7 +196,7 @@ private static void processConstraints( String destinationIndexName = null; try { if (updateForeignTableIndex) { - relatedEntity = getEntityByName(Entity.TABLE, relatedEntityFQN, "*", NON_DELETED); + relatedEntity = getEntityByName(Entity.TABLE, relatedEntityFQN, "*", ALL); IndexMapping destinationIndexMapping = Entity.getSearchRepository() .getIndexMapping(relatedEntity.getEntityReference().getType()); @@ -253,8 +253,7 @@ static List> populateEntityRelationshipData(Table entity) { .findFrom(entity.getId(), Entity.TABLE, Relationship.RELATED_TO.ordinal()); for (CollectionDAO.EntityRelationshipRecord table : relatedTables) { - Table foreignTable = - Entity.getEntity(Entity.TABLE, table.getId(), "tableConstraints", NON_DELETED); + Table foreignTable = Entity.getEntity(Entity.TABLE, table.getId(), "tableConstraints", ALL); processConstraints(foreignTable, entity, constraints, false); } return constraints; From 6224b54900a8eaab224c42f3b0dd13e3542f4d1a Mon Sep 17 00:00:00 2001 From: Suman Maharana Date: Wed, 15 Jan 2025 11:45:32 +0530 Subject: [PATCH 122/294] Fix #19313 - dbt ingestion picks up wrong service to patch metadata (#19337) * Fix #19313 - dbt ingestion picks up wrong service to patch metadata * py format and linting * Added a flag for searching across databases and docs * py format --- .../ingestion/source/database/dbt/metadata.py | 158 ++++++++++-------- .../schemas/metadataIngestion/dbtPipeline.md | 1 + .../schemas/metadataIngestion/dbtPipeline.md | 1 + .../schema/metadataIngestion/dbtPipeline.json | 5 + .../locales/en-US/Database/workflows/dbt.md | 10 ++ .../metadataIngestion/dbtPipeline.ts | 4 + 6 files changed, 107 insertions(+), 72 deletions(-) diff --git a/ingestion/src/metadata/ingestion/source/database/dbt/metadata.py b/ingestion/src/metadata/ingestion/source/database/dbt/metadata.py index e74abafff914..d0d1542b2b27 100644 --- a/ingestion/src/metadata/ingestion/source/database/dbt/metadata.py +++ b/ingestion/src/metadata/ingestion/source/database/dbt/metadata.py @@ -15,7 +15,7 @@ import traceback from copy import deepcopy from datetime import datetime -from typing import Any, Iterable, List, Optional, Union +from typing import Any, Iterable, List, Optional from metadata.generated.schema.api.lineage.addLineage import AddLineageRequest from metadata.generated.schema.api.tests.createTestCase import CreateTestCaseRequest @@ -63,6 +63,7 @@ from metadata.ingestion.models.ometa_classification import OMetaTagAndClassification from metadata.ingestion.models.patch_request import PatchedEntity, PatchRequest from metadata.ingestion.models.table_metadata import ColumnDescription +from metadata.ingestion.ometa.client import APIError from metadata.ingestion.ometa.ometa_api import OpenMetadata from metadata.ingestion.source.database.column_type_parser import ColumnTypeParser from metadata.ingestion.source.database.database_service import DataModelLink @@ -98,7 +99,7 @@ from metadata.utils.entity_link import get_table_fqn from metadata.utils.logger import ingestion_logger from metadata.utils.tag_utils import get_ometa_tag_and_classification, get_tag_labels -from metadata.utils.time_utils import convert_timestamp_to_milliseconds +from metadata.utils.time_utils import datetime_to_timestamp logger = ingestion_logger() @@ -326,11 +327,14 @@ def add_dbt_tests( None, ) - def _add_dbt_freshness_test_from_sources( + def add_dbt_sources( self, key: str, manifest_node, manifest_entities, dbt_objects: DbtObjects - ): - # in dbt manifest sources node name is table/view name (not test name like with test nodes) - # so in order for the test creation to be named precisely I am amending manifest node name within it's deepcopy + ) -> None: + """ + Method to append dbt test cases based on sources file for later processing + In dbt manifest sources node name is table/view name (not test name like with test nodes) + So in order for the test creation to be named precisely I am amending manifest node name within it's deepcopy + """ manifest_node_new = deepcopy(manifest_node) manifest_node_new.name = manifest_node_new.name + "_freshness" @@ -350,17 +354,57 @@ def _add_dbt_freshness_test_from_sources( DbtCommonEnum.RESULTS.value ] = freshness_test_result - def add_dbt_sources( - self, key: str, manifest_node, manifest_entities, dbt_objects: DbtObjects - ) -> None: - """ - Method to append dbt test cases based on sources file for later processing - """ - self._add_dbt_freshness_test_from_sources( - key, manifest_node, manifest_entities, dbt_objects - ) + def _get_table_entity(self, table_fqn) -> Optional[Table]: + def search_table(fqn_search_string: str) -> Optional[Table]: + table_entities = get_entity_from_es_result( + entity_list=self.metadata.es_search_from_fqn( + entity_type=Table, + fqn_search_string=fqn_search_string, + fields="sourceHash", + ), + fetch_multiple_entities=True, + ) + logger.debug( + f"Found table entities from {fqn_search_string}: {table_entities}" + ) + return next(iter(filter(None, table_entities)), None) + + try: + table_entity = search_table(table_fqn) + if table_entity: + return table_entity + + if self.source_config.searchAcrossDatabases: + logger.warning( + f"Table {table_fqn} not found under service: {self.config.serviceName}." + "Trying to find table across services" + ) + _, database_name, schema_name, table_name = fqn.split(table_fqn) + table_fqn = fqn.build( + self.metadata, + entity_type=Table, + service_name="*", + database_name=database_name, + schema_name=schema_name, + table_name=table_name, + ) + table_entity = search_table(table_fqn) + if table_entity: + return table_entity + + logger.warning( + f"Unable to find the table '{table_fqn}' in OpenMetadata. " + "Please check if the table exists and is ingested in OpenMetadata. " + "Also, ensure the name, database, and schema of the manifest node" + "match the table present in OpenMetadata." + ) + except Exception as exc: + logger.debug(traceback.format_exc()) + logger.warning(f"Failed to get table entity from OpenMetadata: {exc}") - # pylint: disable=too-many-locals, too-many-branches, too-many-statements + return None + + # pylint: disable=too-many-locals, too-many-branches def yield_data_models( self, dbt_objects: DbtObjects ) -> Iterable[Either[DataModelLink]]: @@ -471,28 +515,19 @@ def yield_data_models( dbt_compiled_query = get_dbt_compiled_query(manifest_node) dbt_raw_query = get_dbt_raw_query(manifest_node) - # Get the table entity from ES table_fqn = fqn.build( self.metadata, entity_type=Table, - service_name="*", + service_name=self.config.serviceName, database_name=get_corrected_name(manifest_node.database), schema_name=get_corrected_name(manifest_node.schema_), table_name=model_name, ) - table_entity: Optional[ - Union[Table, List[Table]] - ] = get_entity_from_es_result( - entity_list=self.metadata.es_search_from_fqn( - entity_type=Table, - fqn_search_string=table_fqn, - fields="sourceHash", - ), - fetch_multiple_entities=False, - ) - - if table_entity: + if table_entity := self._get_table_entity(table_fqn=table_fqn): + logger.debug( + f"Using Table Entity for datamodel: {table_entity}" + ) data_model_link = DataModelLink( table_entity=table_entity, datamodel=DataModel( @@ -523,13 +558,7 @@ def yield_data_models( ) yield Either(right=data_model_link) self.context.get().data_model_links.append(data_model_link) - else: - logger.warning( - f"Unable to find the table '{table_fqn}' in OpenMetadata" - "Please check if the table exists and is ingested in OpenMetadata" - "Also name, database, schema of the manifest node matches with the table present " - "in OpenMetadata" - ) + except Exception as exc: yield Either( left=StackTraceError( @@ -573,22 +602,14 @@ def parse_upstream_nodes(self, manifest_entities, dbt_node): parent_fqn = fqn.build( self.metadata, entity_type=Table, - service_name="*", + service_name=self.config.serviceName, database_name=get_corrected_name(parent_node.database), schema_name=get_corrected_name(parent_node.schema_), table_name=table_name, ) # check if the parent table exists in OM before adding it to the upstream list - parent_table_entity: Optional[ - Union[Table, List[Table]] - ] = get_entity_from_es_result( - entity_list=self.metadata.es_search_from_fqn( - entity_type=Table, fqn_search_string=parent_fqn - ), - fetch_multiple_entities=False, - ) - if parent_table_entity: + if self._get_table_entity(table_fqn=parent_fqn): upstream_nodes.append(parent_fqn) except Exception as exc: # pylint: disable=broad-except logger.debug(traceback.format_exc()) @@ -601,22 +622,14 @@ def parse_upstream_nodes(self, manifest_entities, dbt_node): parent_fqn = fqn.build( self.metadata, entity_type=Table, - service_name="*", + service_name=self.config.serviceName, database_name=get_corrected_name(dbt_node.database), schema_name=get_corrected_name(dbt_node.schema_), table_name=dbt_node.name, ) # check if the parent table exists in OM before adding it to the upstream list - parent_table_entity: Optional[ - Union[Table, List[Table]] - ] = get_entity_from_es_result( - entity_list=self.metadata.es_search_from_fqn( - entity_type=Table, fqn_search_string=parent_fqn - ), - fetch_multiple_entities=False, - ) - if parent_table_entity: + if self._get_table_entity(table_fqn=parent_fqn): upstream_nodes.append(parent_fqn) return upstream_nodes @@ -708,14 +721,8 @@ def create_dbt_lineage( for upstream_node in data_model_link.datamodel.upstream: try: - from_es_result = self.metadata.es_search_from_fqn( - entity_type=Table, - fqn_search_string=upstream_node, - ) - from_entity: Optional[ - Union[Table, List[Table]] - ] = get_entity_from_es_result( - entity_list=from_es_result, fetch_multiple_entities=False + from_entity: Optional[Table] = self._get_table_entity( + table_fqn=upstream_node ) if from_entity and to_entity: yield Either( @@ -989,6 +996,7 @@ def create_dbt_test_case( self.metadata, entity_link_str ) table_fqn = get_table_fqn(entity_link_str) + logger.debug(f"Table fqn found: {table_fqn}") source_elements = table_fqn.split(fqn.FQN_SEPARATOR) test_case_fqn = fqn.build( self.metadata, @@ -1024,6 +1032,7 @@ def create_dbt_test_case( owners=None, ) ) + logger.debug(f"Test case Already Exists: {test_case_fqn}") except Exception as err: # pylint: disable=broad-except yield Either( left=StackTraceError( @@ -1084,9 +1093,7 @@ def add_dbt_test_result(self, dbt_test: dict): # Create the test case result object test_case_result = TestCaseResult( - timestamp=Timestamp( - convert_timestamp_to_milliseconds(dbt_timestamp.timestamp()) - ), + timestamp=Timestamp(datetime_to_timestamp(dbt_timestamp)), testCaseStatus=test_case_status, testResultValue=[ TestResultValue( @@ -1113,10 +1120,17 @@ def add_dbt_test_result(self, dbt_test: dict): else None, test_case_name=manifest_node.name, ) - self.metadata.add_test_case_results( - test_results=test_case_result, - test_case_fqn=test_case_fqn, - ) + + logger.debug(f"Adding test case results to {test_case_fqn} ") + try: + self.metadata.add_test_case_results( + test_results=test_case_result, + test_case_fqn=test_case_fqn, + ) + except APIError as err: + if err.code != 409: + raise APIError(err) from err + except Exception as err: # pylint: disable=broad-except logger.debug(traceback.format_exc()) logger.error( diff --git a/openmetadata-docs/content/v1.4.x/main-concepts/metadata-standard/schemas/metadataIngestion/dbtPipeline.md b/openmetadata-docs/content/v1.4.x/main-concepts/metadata-standard/schemas/metadataIngestion/dbtPipeline.md index 0b27c51b5ddc..ab655037461f 100644 --- a/openmetadata-docs/content/v1.4.x/main-concepts/metadata-standard/schemas/metadataIngestion/dbtPipeline.md +++ b/openmetadata-docs/content/v1.4.x/main-concepts/metadata-standard/schemas/metadataIngestion/dbtPipeline.md @@ -11,6 +11,7 @@ slug: /main-concepts/metadata-standard/schemas/metadataingestion/dbtpipeline - **`type`**: Pipeline type. Refer to *#/definitions/dbtConfigType*. Default: `DBT`. - **`dbtConfigSource`**: Available sources to fetch DBT catalog and manifest files. +- **`searchAcrossDatabases`** *(boolean)*: Optional configuration to search across databases for tables or not. Default: `False`. - **`dbtUpdateDescriptions`** *(boolean)*: Optional configuration to update the description from DBT or not. Default: `False`. - **`dbtUpdateOwners`** *(boolean)*: Optional configuration to update the owner from DBT or not. Default: `False`. - **`includeTags`** *(boolean)*: Optional configuration to toggle the tags ingestion. Default: `True`. diff --git a/openmetadata-docs/content/v1.6.x-SNAPSHOT/main-concepts/metadata-standard/schemas/metadataIngestion/dbtPipeline.md b/openmetadata-docs/content/v1.6.x-SNAPSHOT/main-concepts/metadata-standard/schemas/metadataIngestion/dbtPipeline.md index fff8d454a043..d98baf90b002 100644 --- a/openmetadata-docs/content/v1.6.x-SNAPSHOT/main-concepts/metadata-standard/schemas/metadataIngestion/dbtPipeline.md +++ b/openmetadata-docs/content/v1.6.x-SNAPSHOT/main-concepts/metadata-standard/schemas/metadataIngestion/dbtPipeline.md @@ -11,6 +11,7 @@ slug: /main-concepts/metadata-standard/schemas/metadataingestion/dbtpipeline - **`type`**: Pipeline type. Refer to *#/definitions/dbtConfigType*. Default: `DBT`. - **`dbtConfigSource`**: Available sources to fetch DBT catalog and manifest files. +- **`searchAcrossDatabases`** *(boolean)*: Optional configuration to search across databases for tables or not. Default: `False`. - **`dbtUpdateDescriptions`** *(boolean)*: Optional configuration to update the description from DBT or not. Default: `False`. - **`dbtUpdateOwners`** *(boolean)*: Optional configuration to update the owners from DBT or not. Default: `False`. - **`includeTags`** *(boolean)*: Optional configuration to toggle the tags ingestion. Default: `True`. diff --git a/openmetadata-spec/src/main/resources/json/schema/metadataIngestion/dbtPipeline.json b/openmetadata-spec/src/main/resources/json/schema/metadataIngestion/dbtPipeline.json index 47ee12e84408..55b9a9c56c7b 100644 --- a/openmetadata-spec/src/main/resources/json/schema/metadataIngestion/dbtPipeline.json +++ b/openmetadata-spec/src/main/resources/json/schema/metadataIngestion/dbtPipeline.json @@ -43,6 +43,11 @@ } ] }, + "searchAcrossDatabases": { + "description": "Optional configuration to search across databases for tables or not", + "type": "boolean", + "default": false + }, "dbtUpdateDescriptions": { "description": "Optional configuration to update the description from DBT or not", "type": "boolean", diff --git a/openmetadata-ui/src/main/resources/ui/public/locales/en-US/Database/workflows/dbt.md b/openmetadata-ui/src/main/resources/ui/public/locales/en-US/Database/workflows/dbt.md index 0185b6dab7c0..c03a86d9049f 100644 --- a/openmetadata-ui/src/main/resources/ui/public/locales/en-US/Database/workflows/dbt.md +++ b/openmetadata-ui/src/main/resources/ui/public/locales/en-US/Database/workflows/dbt.md @@ -336,6 +336,16 @@ $$section Set the `Enable Debug Log` toggle to set the logging level of the process to debug. You can check these logs in the Ingestion tab of the service and dig deeper into any errors you might find. $$ + +$$section +### Search Tables Across Databases $(id="searchAcrossDatabases") + +Option to search across database services for tables or not for processing dbt metadata ingestion. +If this option is enabled, OpenMetadata will first search for tables within the same database service if tables are not found it will search across all database services. + +If the option is disabled, the search will be limited to the tables within the same database service. +$$ + $$section ### Update Descriptions $(id="dbtUpdateDescriptions") diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/metadataIngestion/dbtPipeline.ts b/openmetadata-ui/src/main/resources/ui/src/generated/metadataIngestion/dbtPipeline.ts index 9c2a4144a2a9..eca19940b8c2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/metadataIngestion/dbtPipeline.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/metadataIngestion/dbtPipeline.ts @@ -46,6 +46,10 @@ export interface DbtPipeline { * Regex to only fetch tables or databases that matches the pattern. */ schemaFilterPattern?: FilterPattern; + /** + * Optional configuration to search across databases for tables or not + */ + searchAcrossDatabases?: boolean; /** * Regex exclude tables or databases that matches the pattern. */ From b33fb9b22f95e543f405951503933d8914a72f5e Mon Sep 17 00:00:00 2001 From: Ashish Gupta Date: Wed, 15 Jan 2025 17:47:53 +0530 Subject: [PATCH 123/294] MINOR: fix the wrong team count displayed on team tab (#19368) * fix the wrong team count displayed on team tab * added playwright test for checking counts (cherry picked from commit 58f95e9e98fec96e05d9853969706a8980b92e8d) --- .../ui/playwright/e2e/Pages/Teams.spec.ts | 3 +++ .../src/main/resources/ui/playwright/utils/team.ts | 14 ++++++++++++++ .../Settings/Team/TeamDetails/TeamDetailsV1.tsx | 7 ++----- .../resources/ui/src/pages/TeamsPage/TeamsPage.tsx | 2 +- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Teams.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Teams.spec.ts index b4ce23d496ae..f5c2650b8fa4 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Teams.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Teams.spec.ts @@ -34,6 +34,7 @@ import { settingClick } from '../../utils/sidebar'; import { addTeamOwnerToEntity, addUserInTeam, + checkTeamTabCount, createTeam, hardDeleteTeam, searchTeam, @@ -114,6 +115,8 @@ test.describe('Teams Page', () => { test('Teams Page Flow', async ({ page }) => { await test.step('Create a new team', async () => { await settingClick(page, GlobalSettingOptions.TEAMS); + + await checkTeamTabCount(page); await page.waitForLoadState('networkidle'); await page.waitForSelector('[data-testid="add-team"]'); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/team.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/team.ts index 503984e340bb..7229cd3c101a 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/team.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/team.ts @@ -349,3 +349,17 @@ export const addUserInTeam = async (page: Page, user: UserClass) => { page.locator(`[data-testid="${userName.toLowerCase()}"]`) ).toBeVisible(); }; + +export const checkTeamTabCount = async (page: Page) => { + const fetchResponse = page.waitForResponse( + '/api/v1/teams/name/*?fields=*childrenCount*include=all' + ); + const response = await fetchResponse; + const jsonRes = await response.json(); + + await expect( + page.locator( + '[data-testid="teams"] [data-testid="count"] [data-testid="filter-count"]' + ) + ).toContainText(jsonRes.childrenCount.toString()); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamDetailsV1.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamDetailsV1.tsx index ba4dee350f5e..680d6e732227 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamDetailsV1.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamDetails/TeamDetailsV1.tsx @@ -210,11 +210,8 @@ const TeamDetailsV1 = ({ ); const teamCount = useMemo( - () => - isOrganization && currentTeam && currentTeam.childrenCount - ? currentTeam.childrenCount + 1 - : childTeamList.length, - [childTeamList, isOrganization, currentTeam.childrenCount] + () => currentTeam.childrenCount ?? childTeamList.length, + [childTeamList, currentTeam.childrenCount] ); const updateActiveTab = (key: string) => { history.push({ search: Qs.stringify({ activeTab: key }) }); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TeamsPage/TeamsPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TeamsPage/TeamsPage.tsx index 7cde8c3d8cc5..dde3fe706b2d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TeamsPage/TeamsPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TeamsPage/TeamsPage.tsx @@ -288,8 +288,8 @@ const TeamsPage = () => { const res = await createTeam(teamData); if (res) { - fetchTeamBasicDetails(selectedTeam.name, true); handleAddTeam(false); + await fetchTeamBasicDetails(selectedTeam.name, true); loadAdvancedDetails(); } } catch (error) { From ab15ef7424286283b9aec379e3ecf96d53f1ac89 Mon Sep 17 00:00:00 2001 From: Ashish Gupta Date: Thu, 16 Jan 2025 10:45:57 +0530 Subject: [PATCH 124/294] MINOR: fix the tracing highlighter after edge clicked (#19346) * fix the tracing highlighter after edge clicked * close the drawer appeariance after switching from edge click to node * added test to validate (cherry picked from commit 79153272e0a615fba89bc8d097ece1660107e189) --- .../LineageProvider/LineageProvider.test.tsx | 28 +++++++++++++++++++ .../LineageProvider/LineageProvider.tsx | 4 +++ 2 files changed, 32 insertions(+) diff --git a/openmetadata-ui/src/main/resources/ui/src/context/LineageProvider/LineageProvider.test.tsx b/openmetadata-ui/src/main/resources/ui/src/context/LineageProvider/LineageProvider.test.tsx index ac1ed7562f06..de1b217d872d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/context/LineageProvider/LineageProvider.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/context/LineageProvider/LineageProvider.test.tsx @@ -45,6 +45,7 @@ const DummyChildrenComponent = () => { const { loadChildNodesHandler, onEdgeClick, + onColumnClick, updateEntityType, onLineageEditClick, } = useLineageProvider(); @@ -94,6 +95,11 @@ const DummyChildrenComponent = () => { onClick={() => onEdgeClick(MOCK_EDGE as Edge)}> On Edge Click + @@ -245,4 +251,26 @@ describe('LineageProvider', () => { expect(edgeDrawer).toBeInTheDocument(); }); + + it('should close the drawer if open, on column click', async () => { + await act(async () => { + render( + + + + ); + }); + + const edgeClick = screen.getByTestId('edge-click'); + fireEvent.click(edgeClick); + + const edgeDrawer = screen.getByText('Edge Info Drawer'); + + expect(edgeDrawer).toBeInTheDocument(); + + const columnClick = screen.getByTestId('column-click'); + fireEvent.click(columnClick); + + expect(edgeDrawer).not.toBeInTheDocument(); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/context/LineageProvider/LineageProvider.tsx b/openmetadata-ui/src/main/resources/ui/src/context/LineageProvider/LineageProvider.tsx index b966f3fcfea0..838c358be0a7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/context/LineageProvider/LineageProvider.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/context/LineageProvider/LineageProvider.tsx @@ -434,6 +434,8 @@ const LineageProvider = ({ children }: LineageProviderProps) => { setTracedColumns(connectedColumnEdges); setTracedNodes([]); + setSelectedEdge(undefined); + setIsDrawerOpen(false); }, [nodes, edges] ); @@ -710,6 +712,8 @@ const LineageProvider = ({ children }: LineageProviderProps) => { setActiveNode(undefined); setSelectedNode({} as SourceType); setIsDrawerOpen(true); + setTracedNodes([]); + setTracedColumns([]); }, []); const onLineageEditClick = useCallback(() => { From 39f595d83716286025e6499b9ad13ca0c25c3d5d Mon Sep 17 00:00:00 2001 From: Ashish Gupta Date: Thu, 16 Jan 2025 11:08:10 +0530 Subject: [PATCH 125/294] fix the api should not called after time out in Test connection (#19376) * fix the api should not called after time out in Test connection * minor addition around test (cherry picked from commit 75e0c985e25b5b564ca2695094d2e6608cb1616e) --- .../TestConnection/TestConnection.test.tsx | 4 ++++ .../common/TestConnection/TestConnection.tsx | 18 +++++++++++------- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/TestConnection/TestConnection.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/TestConnection/TestConnection.test.tsx index 2baaf3abef2d..c413ad877bff 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/TestConnection/TestConnection.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/TestConnection/TestConnection.test.tsx @@ -331,6 +331,10 @@ describe('Test Connection Component', () => { expect( screen.getByText('message.test-connection-taking-too-long') ).toBeInTheDocument(); + + // 59 since it will make this amount of call, and after timeout it should not make more api calls + expect(getWorkflowById).toHaveBeenCalledTimes(59); + expect(getWorkflowById).not.toHaveBeenCalledTimes(60); }); it('Should not show the connection status modal if test connection definition API fails', async () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/TestConnection/TestConnection.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/TestConnection/TestConnection.tsx index cab55452ab7c..a3b6b8e9076d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/TestConnection/TestConnection.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/TestConnection/TestConnection.tsx @@ -204,7 +204,9 @@ const TestConnection: FC = ({ const handleWorkflowPolling = async ( response: Workflow, - intervalId: number | undefined + intervalObject: { + intervalId?: number; + } ) => { // return a promise that wraps the interval and handles errors inside it return new Promise((resolve, reject) => { @@ -212,7 +214,7 @@ const TestConnection: FC = ({ * fetch workflow repeatedly with 2s interval * until status is either Failed or Successful */ - intervalId = toNumber( + intervalObject.intervalId = toNumber( setInterval(async () => { setProgress((prev) => prev + TEST_CONNECTION_PROGRESS_PERCENTAGE.ONE); try { @@ -239,7 +241,7 @@ const TestConnection: FC = ({ await handleCompletionStatus(isTestConnectionSuccess, steps); // clear the current interval - clearInterval(intervalId); + clearInterval(intervalObject.intervalId); // set testing connection to false setIsTestingConnection(false); @@ -265,7 +267,9 @@ const TestConnection: FC = ({ const updatedFormData = formatFormDataForSubmit(getData()); // current interval id - let intervalId: number | undefined; + const intervalObject: { + intervalId?: number; + } = {}; try { const createWorkflowData: CreateWorkflow = { @@ -310,7 +314,7 @@ const TestConnection: FC = ({ // stop fetching the workflow after 2 minutes setTimeout(() => { // clear the current interval - clearInterval(intervalId); + clearInterval(intervalObject.intervalId); // using reference to ensure call back should have latest value const currentWorkflowStatus = currentWorkflowRef.current @@ -330,10 +334,10 @@ const TestConnection: FC = ({ }, FETCHING_EXPIRY_TIME); // Handle workflow polling and completion - await handleWorkflowPolling(response, intervalId); + await handleWorkflowPolling(response, intervalObject); } catch (error) { setProgress(TEST_CONNECTION_PROGRESS_PERCENTAGE.HUNDRED); - clearInterval(intervalId); + clearInterval(intervalObject.intervalId); setIsTestingConnection(false); setMessage(TEST_CONNECTION_FAILURE_MESSAGE); setTestStatus(StatusType.Failed); From a5dc33e58613e7c4f9fcfa144594578e34161484 Mon Sep 17 00:00:00 2001 From: Ashish Gupta Date: Fri, 10 Jan 2025 17:23:23 +0530 Subject: [PATCH 126/294] PLAYWRIGHT: fix flaky playwright test (#19309) * fix user detail flaky playwright test * fix the glossary flaky by adding await due to timeout (cherry picked from commit 0e9c32cdb8bbeac3faaf6354a800bcd702d9780c) --- .../main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts | 2 ++ .../resources/ui/playwright/e2e/Pages/UserDetails.spec.ts | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) 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 eb734d2a8156..a901513fb067 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 @@ -805,6 +805,8 @@ test.describe('Glossary tests', () => { test('Assign Glossary Term to entity and check assets', async ({ browser, }) => { + test.slow(true); + const { page, afterAction, apiContext } = await performAdminLogin(browser); const table = new TableClass(); const glossary1 = new Glossary(); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/UserDetails.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/UserDetails.spec.ts index cb96de489273..b89c2298347a 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/UserDetails.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/UserDetails.spec.ts @@ -163,7 +163,9 @@ test.describe('User with different Roles', () => { state: 'visible', }); - await userPage.locator(descriptionBox).clear(); + await userPage.click(descriptionBox); + await userPage.keyboard.press('ControlOrMeta+A'); + await userPage.keyboard.press('Backspace'); await expect(userPage.locator(descriptionBox)).not.toContainText( 'Name of the User' From ba95ccb6ec5ae80d6829d948af9c238a52fe693b Mon Sep 17 00:00:00 2001 From: Teddy Date: Thu, 16 Jan 2025 16:24:20 +0100 Subject: [PATCH 127/294] MINOR - Async test case result deletion (#19390) * fix: async test case result deletion * move async call to calling method (cherry picked from commit d8e6219200fe94c1f00111a2c836e844c22b0992) --- .../service/jdbi3/TestCaseRepository.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseRepository.java index c1015ddf9409..ec76915cf0a3 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TestCaseRepository.java @@ -23,6 +23,8 @@ import java.util.Locale; import java.util.Map; import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import javax.json.JsonPatch; import javax.ws.rs.core.Response; import javax.ws.rs.core.UriInfo; @@ -77,6 +79,7 @@ public class TestCaseRepository extends EntityRepository { private static final String PATCH_FIELDS = "owners,entityLink,testSuite,testSuites,testDefinition,computePassedFailedRowCount,useDynamicAssertion"; public static final String FAILED_ROWS_SAMPLE_EXTENSION = "testCase.failedRowsSample"; + private final ExecutorService asyncExecutor = Executors.newFixedThreadPool(1); public TestCaseRepository() { super( @@ -352,10 +355,17 @@ public RestUtil.PutResponse deleteTestCaseResult( } private void deleteAllTestCaseResults(String fqn) { - // Delete all the test case results TestCaseResultRepository testCaseResultRepository = (TestCaseResultRepository) Entity.getEntityTimeSeriesRepository(TEST_CASE_RESULT); testCaseResultRepository.deleteAllTestCaseResults(fqn); + asyncExecutor.submit( + () -> { + try { + testCaseResultRepository.deleteAllTestCaseResults(fqn); + } catch (Exception e) { + LOG.error("Error deleting test case results for test case {}", fqn, e); + } + }); } @SneakyThrows From 744fe65e02ec412739c8f872633f8ef8134979eb Mon Sep 17 00:00:00 2001 From: Aniket Katkar Date: Fri, 17 Jan 2025 09:08:54 +0530 Subject: [PATCH 128/294] Tests #19066: Playwright coverage for ViewAll rule with matchAnyTag() and isOwner() condition (#19374) * Modify the setup for the tests and add teardown to reset the organization policies * Fix the loader shown in case of no permission * Add playwright tests to cover the viewAll permission with conditions * Add description for the commented code (cherry picked from commit 7fea955338b83bb5a9fac9e55a087ac54fc25fe4) --- .../main/resources/ui/playwright.config.ts | 5 + .../constant/conditionalPermissions.ts | 175 ++++++++++ .../ui/playwright/constant/permission.ts | 37 ++- .../ui/playwright/constant/service.ts | 29 +- .../ui/playwright/constant/settings.ts | 16 + .../e2e/Flow/ConditionalPermissions.spec.ts | 113 +++++++ .../ui/playwright/e2e/auth.teardown.ts | 22 ++ .../support/entity/ApiCollectionClass.ts | 3 + .../support/entity/ApiEndpointClass.ts | 2 + .../support/entity/ContainerClass.ts | 23 ++ .../support/entity/DashboardClass.ts | 28 ++ .../support/entity/DashboardDataModelClass.ts | 2 + .../support/entity/DatabaseClass.ts | 2 + .../support/entity/DatabaseSchemaClass.ts | 2 + .../playwright/support/entity/EntityClass.ts | 3 +- .../playwright/support/entity/MlModelClass.ts | 2 + .../support/entity/PipelineClass.ts | 2 + .../support/entity/SearchIndexClass.ts | 2 + .../support/entity/StoredProcedureClass.ts | 2 + .../playwright/support/entity/TableClass.ts | 2 + .../playwright/support/entity/TopicClass.ts | 2 + .../ConditionalPermissions.interface.ts | 61 ++++ .../ui/playwright/utils/authTeardown.ts | 88 +++++ .../utils/conditionalPermissions.ts | 304 ++++++++++++++++++ .../DatabaseDetailsPage.tsx | 2 + 25 files changed, 901 insertions(+), 28 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/playwright/constant/conditionalPermissions.ts create mode 100644 openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ConditionalPermissions.spec.ts create mode 100644 openmetadata-ui/src/main/resources/ui/playwright/e2e/auth.teardown.ts create mode 100644 openmetadata-ui/src/main/resources/ui/playwright/support/interfaces/ConditionalPermissions.interface.ts create mode 100644 openmetadata-ui/src/main/resources/ui/playwright/utils/authTeardown.ts create mode 100644 openmetadata-ui/src/main/resources/ui/playwright/utils/conditionalPermissions.ts diff --git a/openmetadata-ui/src/main/resources/ui/playwright.config.ts b/openmetadata-ui/src/main/resources/ui/playwright.config.ts index 4108f316d4af..c47a3699213b 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright.config.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright.config.ts @@ -63,6 +63,11 @@ export default defineConfig({ { name: 'setup', testMatch: '**/*.setup.ts', + teardown: 'restore-policies', + }, + { + name: 'restore-policies', + testMatch: '**/auth.teardown.ts', }, { name: 'chromium', diff --git a/openmetadata-ui/src/main/resources/ui/playwright/constant/conditionalPermissions.ts b/openmetadata-ui/src/main/resources/ui/playwright/constant/conditionalPermissions.ts new file mode 100644 index 000000000000..e760f9884790 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/constant/conditionalPermissions.ts @@ -0,0 +1,175 @@ +/* + * Copyright 2025 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 { PolicyClass } from '../support/access-control/PoliciesClass'; +import { RolesClass } from '../support/access-control/RolesClass'; +import { ApiCollectionClass } from '../support/entity/ApiCollectionClass'; +import { ContainerClass } from '../support/entity/ContainerClass'; +import { DashboardClass } from '../support/entity/DashboardClass'; +import { MlModelClass } from '../support/entity/MlModelClass'; +import { PipelineClass } from '../support/entity/PipelineClass'; +import { SearchIndexClass } from '../support/entity/SearchIndexClass'; +import { TableClass } from '../support/entity/TableClass'; +import { TopicClass } from '../support/entity/TopicClass'; +import { EntityData } from '../support/interfaces/ConditionalPermissions.interface'; +import { UserClass } from '../support/user/UserClass'; +import { ServiceTypes } from './settings'; + +export const isOwnerPolicy = new PolicyClass(); +export const matchAnyTagPolicy = new PolicyClass(); +export const isOwnerRole = new RolesClass(); +export const matchAnyTagRole = new RolesClass(); +export const userWithOwnerPermission = new UserClass(); +export const userWithTagPermission = new UserClass(); +export const apiCollectionWithOwner = new ApiCollectionClass(); +export const apiCollectionWithTag = new ApiCollectionClass(); +export const containerWithOwner = new ContainerClass(); +export const containerWithTag = new ContainerClass(); +export const dashboardWithOwner = new DashboardClass(); +export const dashboardWithTag = new DashboardClass(); +export const mlModelWithOwner = new MlModelClass(); +export const mlModelWithTag = new MlModelClass(); +export const pipelineWithOwner = new PipelineClass(); +export const pipelineWithTag = new PipelineClass(); +export const searchIndexWithOwner = new SearchIndexClass(); +export const searchIndexWithTag = new SearchIndexClass(); +export const tableWithOwner = new TableClass(); +export const tableWithTag = new TableClass(); +export const topicWithOwner = new TopicClass(); +export const topicWithTag = new TopicClass(); + +const withOwner = { + apiCollectionWithOwner, + containerWithOwner, + dashboardWithOwner, + mlModelWithOwner, + pipelineWithOwner, + searchIndexWithOwner, + tableWithOwner, + topicWithOwner, +}; + +const withTag = { + apiCollectionWithTag, + containerWithTag, + dashboardWithTag, + mlModelWithTag, + pipelineWithTag, + searchIndexWithTag, + tableWithTag, + topicWithTag, +}; + +export const assetsData = [ + { + asset: ServiceTypes.API_SERVICES, + withOwner: apiCollectionWithOwner, + withTag: apiCollectionWithTag, + childTabId: 'collections', + assetOwnerUrl: `/service/${apiCollectionWithOwner.serviceType}`, + assetTagUrl: `/service/${apiCollectionWithTag.serviceType}`, + }, + { + asset: ServiceTypes.STORAGE_SERVICES, + withOwner: containerWithOwner, + withTag: containerWithTag, + childTabId: 'containers', + assetOwnerUrl: `/service/${containerWithOwner.serviceType}`, + assetTagUrl: `/service/${containerWithTag.serviceType}`, + }, + { + asset: ServiceTypes.DASHBOARD_SERVICES, + withOwner: dashboardWithOwner, + withTag: dashboardWithTag, + childTabId: 'dashboards', + childTabId2: 'data-model', + childTableId2: 'data-models-table', + assetOwnerUrl: `/service/${dashboardWithOwner.serviceType}`, + assetTagUrl: `/service/${dashboardWithTag.serviceType}`, + }, + { + asset: ServiceTypes.ML_MODEL_SERVICES, + withOwner: mlModelWithOwner, + withTag: mlModelWithTag, + childTabId: 'ml models', + assetOwnerUrl: `/service/${mlModelWithOwner.serviceType}`, + assetTagUrl: `/service/${mlModelWithTag.serviceType}`, + }, + { + asset: ServiceTypes.PIPELINE_SERVICES, + withOwner: pipelineWithOwner, + withTag: pipelineWithTag, + childTabId: 'pipelines', + assetOwnerUrl: `/service/${pipelineWithOwner.serviceType}`, + assetTagUrl: `/service/${pipelineWithTag.serviceType}`, + }, + // TODO: Uncomment when search index permission issue is fixed + // { + // asset: ServiceTypes.SEARCH_SERVICES, + // withOwner: searchIndexWithOwner, + // withTag: searchIndexWithTag, + // childTabId: 'search indexes', + // assetOwnerUrl: `/service/${searchIndexWithOwner.serviceType}`, + // assetTagUrl: `/service/${searchIndexWithTag.serviceType}`, + // }, + { + asset: ServiceTypes.DATABASE_SERVICES, + withOwner: tableWithOwner, + withTag: tableWithTag, + childTabId: 'databases', + assetOwnerUrl: `/service/${tableWithOwner.serviceType}`, + assetTagUrl: `/service/${tableWithTag.serviceType}`, + }, + { + asset: ServiceTypes.MESSAGING_SERVICES, + withOwner: topicWithOwner, + withTag: topicWithTag, + childTabId: 'topics', + assetOwnerUrl: `/service/${topicWithOwner.serviceType}`, + assetTagUrl: `/service/${topicWithTag.serviceType}`, + }, + { + asset: 'database', + withOwner: tableWithOwner, + withTag: tableWithTag, + childTabId: 'schema', + assetOwnerUrl: `/database`, + assetTagUrl: `/database`, + }, + { + asset: 'databaseSchema', + withOwner: tableWithOwner, + withTag: tableWithTag, + childTabId: 'table', + assetOwnerUrl: `/databaseSchema`, + assetTagUrl: `/databaseSchema`, + }, + { + asset: 'container', + withOwner: containerWithOwner, + withTag: containerWithTag, + childTabId: 'children', + assetOwnerUrl: `/container`, + assetTagUrl: `/container`, + }, +]; + +export const conditionalPermissionsEntityData: EntityData = { + isOwnerPolicy, + matchAnyTagPolicy, + isOwnerRole, + matchAnyTagRole, + userWithOwnerPermission, + userWithTagPermission, + withOwner, + withTag, +}; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/constant/permission.ts b/openmetadata-ui/src/main/resources/ui/playwright/constant/permission.ts index 7c810b833c70..66e87f4079a4 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/constant/permission.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/constant/permission.ts @@ -77,6 +77,36 @@ export const DATA_CONSUMER_RULES: PolicyRulesType[] = [ }, ]; +export const VIEW_ALL_RULE: PolicyRulesType[] = [ + { + name: 'OrganizationPolicy-ViewAll-Rule', + description: 'Allow all users to view all metadata', + resources: ['All'], + operations: ['ViewAll'], + effect: 'allow', + }, +]; + +export const VIEW_ALL_WITH_IS_OWNER: PolicyRulesType[] = [ + { + name: 'viewAll-IsOwner', + resources: ['All'], + operations: ['ViewAll'], + effect: 'allow', + condition: 'isOwner()', + }, +]; + +export const VIEW_ALL_WITH_MATCH_TAG_CONDITION: PolicyRulesType[] = [ + { + name: 'viewAll-MatchTag', + resources: ['All'], + operations: ['ViewAll'], + effect: 'allow', + condition: "matchAnyTag('PersonalData.Personal')", + }, +]; + export const EDIT_USER_FOR_TEAM_RULES: PolicyRulesType[] = [ { name: 'EditUserTeams-EditRule', @@ -104,13 +134,6 @@ export const ORGANIZATION_POLICY_RULES: PolicyRulesType[] = [ resources: ['All'], condition: 'isOwner()', }, - { - name: 'OrganizationPolicy-ViewAll-Rule', - description: 'Allow all users to discover data assets.', - effect: 'allow', - operations: ['ViewAll'], - resources: ['All'], - }, ]; export const GLOBAL_SETTING_PERMISSIONS: Record< diff --git a/openmetadata-ui/src/main/resources/ui/playwright/constant/service.ts b/openmetadata-ui/src/main/resources/ui/playwright/constant/service.ts index 3d5bc91321c4..6f40d87c1e6d 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/constant/service.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/constant/service.ts @@ -11,7 +11,7 @@ * limitations under the License. */ import { uuid } from '../utils/common'; -import { GlobalSettingOptions } from './settings'; +import { GlobalSettingOptions, ServiceTypes } from './settings'; export const SERVICE_TYPE = { Database: GlobalSettingOptions.DATABASES, @@ -26,49 +26,38 @@ export const SERVICE_TYPE = { ApiService: GlobalSettingOptions.APIS, }; -export const SERVICE_CATEGORIES = { - DATABASE_SERVICES: 'databaseServices', - MESSAGING_SERVICES: 'messagingServices', - PIPELINE_SERVICES: 'pipelineServices', - DASHBOARD_SERVICES: 'dashboardServices', - ML_MODEL_SERVICES: 'mlmodelServices', - STORAGE_SERVICES: 'storageServices', - METADATA_SERVICES: 'metadataServices', - SEARCH_SERVICES: 'searchServices', -}; - export const VISIT_SERVICE_PAGE_DETAILS = { [SERVICE_TYPE.Database]: { settingsMenuId: GlobalSettingOptions.DATABASES, - serviceCategory: SERVICE_CATEGORIES.DATABASE_SERVICES, + serviceCategory: ServiceTypes.DATABASE_SERVICES, }, [SERVICE_TYPE.Messaging]: { settingsMenuId: GlobalSettingOptions.MESSAGING, - serviceCategory: SERVICE_CATEGORIES.MESSAGING_SERVICES, + serviceCategory: ServiceTypes.MESSAGING_SERVICES, }, [SERVICE_TYPE.Dashboard]: { settingsMenuId: GlobalSettingOptions.DASHBOARDS, - serviceCategory: SERVICE_CATEGORIES.DASHBOARD_SERVICES, + serviceCategory: ServiceTypes.DASHBOARD_SERVICES, }, [SERVICE_TYPE.Pipeline]: { settingsMenuId: GlobalSettingOptions.PIPELINES, - serviceCategory: SERVICE_CATEGORIES.PIPELINE_SERVICES, + serviceCategory: ServiceTypes.PIPELINE_SERVICES, }, [SERVICE_TYPE.MLModels]: { settingsMenuId: GlobalSettingOptions.MLMODELS, - serviceCategory: SERVICE_CATEGORIES.ML_MODEL_SERVICES, + serviceCategory: ServiceTypes.ML_MODEL_SERVICES, }, [SERVICE_TYPE.Storage]: { settingsMenuId: GlobalSettingOptions.STORAGES, - serviceCategory: SERVICE_CATEGORIES.STORAGE_SERVICES, + serviceCategory: ServiceTypes.STORAGE_SERVICES, }, [SERVICE_TYPE.Search]: { settingsMenuId: GlobalSettingOptions.SEARCH, - serviceCategory: SERVICE_CATEGORIES.SEARCH_SERVICES, + serviceCategory: ServiceTypes.SEARCH_SERVICES, }, [SERVICE_TYPE.Metadata]: { settingsMenuId: GlobalSettingOptions.METADATA, - serviceCategory: SERVICE_CATEGORIES.METADATA_SERVICES, + serviceCategory: ServiceTypes.METADATA_SERVICES, }, }; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/constant/settings.ts b/openmetadata-ui/src/main/resources/ui/playwright/constant/settings.ts index d9dd08c01538..2025a11dc015 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/constant/settings.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/constant/settings.ts @@ -21,6 +21,18 @@ export enum GlobalSettingsMenuCategory { APPLICATIONS = 'apps', } +export enum ServiceTypes { + API_SERVICES = 'apiServices', + DATABASE_SERVICES = 'databaseServices', + MESSAGING_SERVICES = 'messagingServices', + PIPELINE_SERVICES = 'pipelineServices', + DASHBOARD_SERVICES = 'dashboardServices', + ML_MODEL_SERVICES = 'mlmodelServices', + STORAGE_SERVICES = 'storageServices', + METADATA_SERVICES = 'metadataServices', + SEARCH_SERVICES = 'searchServices', +} + export enum GlobalSettingOptions { USERS = 'users', ADMINS = 'admins', @@ -80,6 +92,10 @@ export enum GlobalSettingOptions { export const SETTINGS_OPTIONS_PATH = { // Services + [GlobalSettingOptions.API_COLLECTIONS]: [ + GlobalSettingsMenuCategory.SERVICES, + `${GlobalSettingsMenuCategory.SERVICES}.${GlobalSettingOptions.API_COLLECTIONS}`, + ], [GlobalSettingOptions.DATABASES]: [ GlobalSettingsMenuCategory.SERVICES, `${GlobalSettingsMenuCategory.SERVICES}.${GlobalSettingOptions.DATABASES}`, diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ConditionalPermissions.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ConditionalPermissions.spec.ts new file mode 100644 index 000000000000..15965848cfe0 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ConditionalPermissions.spec.ts @@ -0,0 +1,113 @@ +/* + * Copyright 2025 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 { Page, test as base } from '@playwright/test'; +import { startCase } from 'lodash'; +import { + assetsData, + userWithOwnerPermission, + userWithTagPermission, +} from '../../constant/conditionalPermissions'; +import { performAdminLogin } from '../../utils/admin'; +import { + checkViewAllPermission, + conditionalPermissionsCleanup, + conditionalPermissionsPrerequisites, + getEntityFQN, +} from '../../utils/conditionalPermissions'; + +const test = base.extend<{ + user1Page: Page; + user2Page: Page; +}>({ + user1Page: async ({ browser }, use) => { + const page = await browser.newPage(); + await userWithOwnerPermission.login(page); + await use(page); + await page.close(); + }, + user2Page: async ({ browser }, use) => { + const page = await browser.newPage(); + await userWithTagPermission.login(page); + await use(page); + await page.close(); + }, +}); + +test.beforeAll(async ({ browser }) => { + test.slow(); + + const { apiContext, afterAction } = await performAdminLogin(browser); + await conditionalPermissionsPrerequisites(apiContext); + await afterAction(); +}); + +test.afterAll(async ({ browser }) => { + test.slow(); + + const { apiContext, afterAction } = await performAdminLogin(browser); + await conditionalPermissionsCleanup(apiContext); + await afterAction(); +}); + +for (const serviceData of assetsData) { + const { + asset, + withOwner, + withTag, + assetOwnerUrl, + assetTagUrl, + childTabId, + childTabId2, + childTableId2, + } = serviceData; + + test(`User with owner permission can only view owned ${startCase( + asset + )}`, async ({ user1Page: page }) => { + // Get the FQNs of both assets + const ownerAssetName = getEntityFQN(asset, withOwner); + const tagAssetName = getEntityFQN(asset, withTag); + + const ownerAssetURL = `${assetOwnerUrl}/${ownerAssetName}`; + const tagAssetURL = `${assetTagUrl}/${tagAssetName}`; + + await checkViewAllPermission({ + page, + url1: ownerAssetURL, + url2: tagAssetURL, + childTabId, + childTabId2, + childTableId2, + }); + }); + + test(`User with matchAnyTag permission can only view ${startCase( + asset + )} with the tag`, async ({ user2Page: page }) => { + // Get the FQNs of both assets + const ownerAssetName = getEntityFQN(asset, withOwner); + const tagAssetName = getEntityFQN(asset, withTag); + + const ownerAssetURL = `${assetOwnerUrl}/${ownerAssetName}`; + const tagAssetURL = `${assetTagUrl}/${tagAssetName}`; + + await checkViewAllPermission({ + page, + url1: tagAssetURL, + url2: ownerAssetURL, + childTabId, + childTabId2, + childTableId2, + }); + }); +} diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/auth.teardown.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/auth.teardown.ts new file mode 100644 index 000000000000..bba61a4ba67e --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/auth.teardown.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2025 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 { test as teardown } from '@playwright/test'; +import { AdminClass } from '../support/user/AdminClass'; +import { resetPolicyChanges } from '../utils/authTeardown'; + +teardown('restore the organization roles and policies', async ({ page }) => { + const admin = new AdminClass(); + + // Reset the default organization roles and policies + await resetPolicyChanges(page, admin); +}); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ApiCollectionClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ApiCollectionClass.ts index f2a281a808c6..27edd96029f0 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ApiCollectionClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ApiCollectionClass.ts @@ -13,6 +13,7 @@ import { APIRequestContext, Page } from '@playwright/test'; import { Operation } from 'fast-json-patch'; import { SERVICE_TYPE } from '../../constant/service'; +import { ServiceTypes } from '../../constant/settings'; import { uuid } from '../../utils/common'; import { visitEntityPage } from '../../utils/entity'; import { visitServiceDetailsPage } from '../../utils/service'; @@ -149,6 +150,8 @@ export class ApiCollectionClass extends EntityClass { constructor(name?: string) { super(EntityTypeEndpoint.API_COLLECTION); + this.serviceCategory = SERVICE_TYPE.ApiService; + this.serviceType = ServiceTypes.API_SERVICES; this.service.name = name ?? this.service.name; this.type = 'Api Collection'; } diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ApiEndpointClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ApiEndpointClass.ts index 0eaa969a7746..9a0b9efa91c8 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ApiEndpointClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ApiEndpointClass.ts @@ -13,6 +13,7 @@ import { APIRequestContext, Page } from '@playwright/test'; import { Operation } from 'fast-json-patch'; import { SERVICE_TYPE } from '../../constant/service'; +import { ServiceTypes } from '../../constant/settings'; import { uuid } from '../../utils/common'; import { visitEntityPage } from '../../utils/entity'; import { @@ -153,6 +154,7 @@ export class ApiEndpointClass extends EntityClass { super(EntityTypeEndpoint.API_ENDPOINT); this.service.name = name ?? this.service.name; this.serviceCategory = SERVICE_TYPE.ApiService; + this.serviceType = ServiceTypes.API_SERVICES; this.type = 'ApiEndpoint'; this.childrenTabId = 'schema'; this.childrenSelectorId = this.children[0].name; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ContainerClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ContainerClass.ts index e3559452b0b6..1ad4c8d4d575 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ContainerClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ContainerClass.ts @@ -13,6 +13,7 @@ import { APIRequestContext, Page } from '@playwright/test'; import { Operation } from 'fast-json-patch'; import { SERVICE_TYPE } from '../../constant/service'; +import { ServiceTypes } from '../../constant/settings'; import { uuid } from '../../utils/common'; import { visitEntityPage } from '../../utils/entity'; import { @@ -24,6 +25,7 @@ import { EntityClass } from './EntityClass'; export class ContainerClass extends EntityClass { private containerName = `pw-container-${uuid()}`; + private childContainerName = `pw-container-${uuid()}`; service = { name: `pw-storage-service-${uuid()}`, serviceType: 'S3', @@ -45,14 +47,21 @@ export class ContainerClass extends EntityClass { displayName: this.containerName, service: this.service.name, }; + childContainer = { + name: this.childContainerName, + displayName: this.childContainerName, + service: this.service.name, + }; serviceResponseData: ResponseDataType = {} as ResponseDataType; entityResponseData: ResponseDataWithServiceType = {} as ResponseDataWithServiceType; + childResponseData: ResponseDataType = {} as ResponseDataType; constructor(name?: string) { super(EntityTypeEndpoint.Container); this.service.name = name ?? this.service.name; + this.serviceType = ServiceTypes.STORAGE_SERVICES; this.type = 'Container'; this.serviceCategory = SERVICE_TYPE.Storage; } @@ -71,6 +80,20 @@ export class ContainerClass extends EntityClass { this.serviceResponseData = await serviceResponse.json(); this.entityResponseData = await entityResponse.json(); + const childContainer = { + ...this.childContainer, + parent: { + id: this.entityResponseData.id, + type: 'container', + }, + }; + + const childResponse = await apiContext.post('/api/v1/containers', { + data: childContainer, + }); + + this.childResponseData = await childResponse.json(); + return { service: serviceResponse.body, entity: entityResponse.body, diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DashboardClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DashboardClass.ts index bea3c4579a35..14777bda65ae 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DashboardClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DashboardClass.ts @@ -13,6 +13,7 @@ import { APIRequestContext, Page } from '@playwright/test'; import { Operation } from 'fast-json-patch'; import { SERVICE_TYPE } from '../../constant/service'; +import { ServiceTypes } from '../../constant/settings'; import { uuid } from '../../utils/common'; import { visitEntityPage } from '../../utils/entity'; import { @@ -24,6 +25,7 @@ import { EntityClass } from './EntityClass'; export class DashboardClass extends EntityClass { private dashboardName = `pw-dashboard-${uuid()}`; + private dashboardDataModelName = `pw-dashboard-data-model-${uuid()}`; service = { name: `pw-dashboard-service-${uuid()}`, serviceType: 'Superset', @@ -50,10 +52,28 @@ export class DashboardClass extends EntityClass { displayName: this.dashboardName, service: this.service.name, }; + children = [ + { + name: 'country_name', + dataType: 'VARCHAR', + dataLength: 256, + dataTypeDisplay: 'varchar', + description: 'Name of the country.', + }, + ]; + dataModel = { + name: this.dashboardDataModelName, + displayName: this.dashboardDataModelName, + service: this.service.name, + columns: this.children, + dataModelType: 'SupersetDataModel', + }; serviceResponseData: ResponseDataType = {} as ResponseDataType; entityResponseData: ResponseDataWithServiceType = {} as ResponseDataWithServiceType; + dataModelResponseData: ResponseDataWithServiceType = + {} as ResponseDataWithServiceType; chartsResponseData: ResponseDataType = {} as ResponseDataType; constructor(name?: string) { @@ -61,6 +81,7 @@ export class DashboardClass extends EntityClass { this.service.name = name ?? this.service.name; this.type = 'Dashboard'; this.serviceCategory = SERVICE_TYPE.Dashboard; + this.serviceType = ServiceTypes.DASHBOARD_SERVICES; } async create(apiContext: APIRequestContext) { @@ -80,9 +101,16 @@ export class DashboardClass extends EntityClass { charts: [`${this.service.name}.${this.charts.name}`], }, }); + const dataModelResponse = await apiContext.post( + '/api/v1/dashboard/datamodels', + { + data: this.dataModel, + } + ); this.serviceResponseData = await serviceResponse.json(); this.chartsResponseData = await chartsResponse.json(); + this.dataModelResponseData = await dataModelResponse.json(); this.entityResponseData = await entityResponse.json(); return { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DashboardDataModelClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DashboardDataModelClass.ts index 24d824720cb6..738460fdb742 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DashboardDataModelClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DashboardDataModelClass.ts @@ -13,6 +13,7 @@ import { APIRequestContext, Page } from '@playwright/test'; import { Operation } from 'fast-json-patch'; import { SERVICE_TYPE } from '../../constant/service'; +import { ServiceTypes } from '../../constant/settings'; import { uuid } from '../../utils/common'; import { visitEntityPage } from '../../utils/entity'; import { @@ -70,6 +71,7 @@ export class DashboardDataModelClass extends EntityClass { this.childrenTabId = 'model'; this.childrenSelectorId = this.children[0].name; this.serviceCategory = SERVICE_TYPE.Dashboard; + this.serviceType = ServiceTypes.DASHBOARD_SERVICES; } async create(apiContext: APIRequestContext) { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DatabaseClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DatabaseClass.ts index b5d2a634ad7f..df1bdfc0cbdf 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DatabaseClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DatabaseClass.ts @@ -13,6 +13,7 @@ import { APIRequestContext, expect, Page } from '@playwright/test'; import { Operation } from 'fast-json-patch'; import { SERVICE_TYPE } from '../../constant/service'; +import { ServiceTypes } from '../../constant/settings'; import { assignDomain, removeDomain, @@ -127,6 +128,7 @@ export class DatabaseClass extends EntityClass { super(EntityTypeEndpoint.Database); this.service.name = name ?? this.service.name; this.type = 'Database'; + this.serviceType = ServiceTypes.DATABASE_SERVICES; } async create(apiContext: APIRequestContext) { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DatabaseSchemaClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DatabaseSchemaClass.ts index 45909c1e466b..38aa8de0b954 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DatabaseSchemaClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DatabaseSchemaClass.ts @@ -13,6 +13,7 @@ import { APIRequestContext, Page } from '@playwright/test'; import { Operation } from 'fast-json-patch'; import { SERVICE_TYPE } from '../../constant/service'; +import { ServiceTypes } from '../../constant/settings'; import { uuid } from '../../utils/common'; import { visitServiceDetailsPage } from '../../utils/service'; import { @@ -61,6 +62,7 @@ export class DatabaseSchemaClass extends EntityClass { super(EntityTypeEndpoint.DatabaseSchema); this.service.name = name ?? this.service.name; this.type = 'Database Schema'; + this.serviceType = ServiceTypes.DATABASE_SERVICES; } async create(apiContext: APIRequestContext) { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/EntityClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/EntityClass.ts index 6df7ae49e18b..d3b91e20cf7b 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/EntityClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/EntityClass.ts @@ -12,7 +12,7 @@ */ import { APIRequestContext, Page } from '@playwright/test'; import { CustomPropertySupportedEntityList } from '../../constant/customProperty'; -import { GlobalSettingOptions } from '../../constant/settings'; +import { GlobalSettingOptions, ServiceTypes } from '../../constant/settings'; import { assignDomain, removeDomain, updateDomain } from '../../utils/common'; import { createCustomPropertyForEntity, @@ -56,6 +56,7 @@ import { EntityTypeEndpoint, ENTITY_PATH } from './Entity.interface'; export class EntityClass { type = ''; serviceCategory?: GlobalSettingOptions; + serviceType?: ServiceTypes; childrenTabId?: string; childrenSelectorId?: string; endpoint: EntityTypeEndpoint; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/MlModelClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/MlModelClass.ts index fe415769f519..b959705e3075 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/MlModelClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/MlModelClass.ts @@ -13,6 +13,7 @@ import { APIRequestContext, Page } from '@playwright/test'; import { Operation } from 'fast-json-patch'; import { SERVICE_TYPE } from '../../constant/service'; +import { ServiceTypes } from '../../constant/settings'; import { uuid } from '../../utils/common'; import { visitEntityPage } from '../../utils/entity'; import { @@ -64,6 +65,7 @@ export class MlModelClass extends EntityClass { this.childrenTabId = 'features'; this.childrenSelectorId = `feature-card-${this.children[0].name}`; this.serviceCategory = SERVICE_TYPE.MLModels; + this.serviceType = ServiceTypes.ML_MODEL_SERVICES; } async create(apiContext: APIRequestContext) { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/PipelineClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/PipelineClass.ts index 30a7c6adc108..6f82dd496267 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/PipelineClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/PipelineClass.ts @@ -13,6 +13,7 @@ import { APIRequestContext, Page } from '@playwright/test'; import { Operation } from 'fast-json-patch'; import { SERVICE_TYPE } from '../../constant/service'; +import { ServiceTypes } from '../../constant/settings'; import { uuid } from '../../utils/common'; import { visitEntityPage } from '../../utils/entity'; import { @@ -59,6 +60,7 @@ export class PipelineClass extends EntityClass { this.childrenTabId = 'tasks'; this.childrenSelectorId = this.children[0].name; this.serviceCategory = SERVICE_TYPE.Pipeline; + this.serviceType = ServiceTypes.PIPELINE_SERVICES; } async create(apiContext: APIRequestContext) { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/SearchIndexClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/SearchIndexClass.ts index 9f75df65cb19..46bd8b9c61df 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/SearchIndexClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/SearchIndexClass.ts @@ -13,6 +13,7 @@ import { APIRequestContext, Page } from '@playwright/test'; import { Operation } from 'fast-json-patch'; import { SERVICE_TYPE } from '../../constant/service'; +import { ServiceTypes } from '../../constant/settings'; import { uuid } from '../../utils/common'; import { visitEntityPage } from '../../utils/entity'; import { @@ -105,6 +106,7 @@ export class SearchIndexClass extends EntityClass { this.childrenTabId = 'fields'; this.childrenSelectorId = this.children[0].fullyQualifiedName; this.serviceCategory = SERVICE_TYPE.Search; + this.serviceType = ServiceTypes.SEARCH_SERVICES; } async create(apiContext: APIRequestContext) { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/StoredProcedureClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/StoredProcedureClass.ts index b0b41f3d4e0c..1c7c23ebeaf4 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/StoredProcedureClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/StoredProcedureClass.ts @@ -13,6 +13,7 @@ import { APIRequestContext, Page } from '@playwright/test'; import { Operation } from 'fast-json-patch'; import { SERVICE_TYPE } from '../../constant/service'; +import { ServiceTypes } from '../../constant/settings'; import { uuid } from '../../utils/common'; import { visitEntityPage } from '../../utils/entity'; import { @@ -71,6 +72,7 @@ export class StoredProcedureClass extends EntityClass { this.service.name = name ?? this.service.name; this.serviceCategory = SERVICE_TYPE.Database; this.type = 'Store Procedure'; + this.serviceType = ServiceTypes.DATABASE_SERVICES; } async create(apiContext: APIRequestContext) { 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 51d07d7ec0ee..130981f888a3 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 @@ -14,6 +14,7 @@ import { APIRequestContext, Page } from '@playwright/test'; import { Operation } from 'fast-json-patch'; import { isEmpty } from 'lodash'; import { SERVICE_TYPE } from '../../constant/service'; +import { ServiceTypes } from '../../constant/settings'; import { uuid } from '../../utils/common'; import { visitEntityPage } from '../../utils/entity'; import { @@ -125,6 +126,7 @@ export class TableClass extends EntityClass { super(EntityTypeEndpoint.Table); this.service.name = name ?? this.service.name; this.serviceCategory = SERVICE_TYPE.Database; + this.serviceType = ServiceTypes.DATABASE_SERVICES; this.type = 'Table'; this.childrenTabId = 'schema'; this.childrenSelectorId = `${this.entity.databaseSchema}.${this.entity.name}.${this.children[0].name}`; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/TopicClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/TopicClass.ts index 82ce4e87f5bd..b223f22d4fc2 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/TopicClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/TopicClass.ts @@ -13,6 +13,7 @@ import { APIRequestContext, Page } from '@playwright/test'; import { Operation } from 'fast-json-patch'; import { SERVICE_TYPE } from '../../constant/service'; +import { ServiceTypes } from '../../constant/settings'; import { uuid } from '../../utils/common'; import { visitEntityPage } from '../../utils/entity'; import { @@ -107,6 +108,7 @@ export class TopicClass extends EntityClass { this.childrenTabId = 'schema'; this.childrenSelectorId = this.children[0].name; this.serviceCategory = SERVICE_TYPE.Messaging; + this.serviceType = ServiceTypes.MESSAGING_SERVICES; } async create(apiContext: APIRequestContext) { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/interfaces/ConditionalPermissions.interface.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/interfaces/ConditionalPermissions.interface.ts new file mode 100644 index 000000000000..15bca0e688d8 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/interfaces/ConditionalPermissions.interface.ts @@ -0,0 +1,61 @@ +/* + * Copyright 2025 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 { PolicyClass } from '../access-control/PoliciesClass'; +import { RolesClass } from '../access-control/RolesClass'; +import { ApiCollectionClass } from '../entity/ApiCollectionClass'; +import { ContainerClass } from '../entity/ContainerClass'; +import { DashboardClass } from '../entity/DashboardClass'; +import { MlModelClass } from '../entity/MlModelClass'; +import { PipelineClass } from '../entity/PipelineClass'; +import { SearchIndexClass } from '../entity/SearchIndexClass'; +import { TableClass } from '../entity/TableClass'; +import { TopicClass } from '../entity/TopicClass'; +import { UserClass } from '../user/UserClass'; + +export interface EntityData { + isOwnerPolicy: PolicyClass; + matchAnyTagPolicy: PolicyClass; + isOwnerRole: RolesClass; + matchAnyTagRole: RolesClass; + userWithOwnerPermission: UserClass; + userWithTagPermission: UserClass; + withOwner: { + apiCollectionWithOwner: ApiCollectionClass; + containerWithOwner: ContainerClass; + dashboardWithOwner: DashboardClass; + mlModelWithOwner: MlModelClass; + pipelineWithOwner: PipelineClass; + searchIndexWithOwner: SearchIndexClass; + tableWithOwner: TableClass; + topicWithOwner: TopicClass; + }; + withTag: { + apiCollectionWithTag: ApiCollectionClass; + containerWithTag: ContainerClass; + dashboardWithTag: DashboardClass; + mlModelWithTag: MlModelClass; + pipelineWithTag: PipelineClass; + searchIndexWithTag: SearchIndexClass; + tableWithTag: TableClass; + topicWithTag: TopicClass; + }; +} + +export type AssetTypes = + | ApiCollectionClass + | ContainerClass + | DashboardClass + | MlModelClass + | PipelineClass + | TableClass + | TopicClass; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/authTeardown.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/authTeardown.ts new file mode 100644 index 000000000000..633a9f0bae11 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/authTeardown.ts @@ -0,0 +1,88 @@ +/* + * Copyright 2025 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 { APIRequestContext, Page } from '@playwright/test'; +import { + ORGANIZATION_POLICY_RULES, + VIEW_ALL_RULE, +} from '../constant/permission'; +import { AdminClass } from '../support/user/AdminClass'; +import { getApiContext } from './common'; + +export const restoreOrganizationDefaultRole = async ( + apiContext: APIRequestContext +) => { + const organizationTeamResponse = await apiContext + .get(`/api/v1/teams/name/Organization`) + .then((res) => res.json()); + + const dataConsumerRoleResponse = await apiContext + .get('/api/v1/roles/name/DataConsumer') + .then((res) => res.json()); + + await apiContext.patch(`/api/v1/teams/${organizationTeamResponse.id}`, { + data: [ + { + op: 'replace', + path: '/defaultRoles', + value: [ + { + id: dataConsumerRoleResponse.id, + type: 'role', + }, + ], + }, + ], + headers: { + 'Content-Type': 'application/json-patch+json', + }, + }); +}; + +export const updateDefaultOrganizationPolicy = async ( + apiContext: APIRequestContext +) => { + const orgPolicyResponse = await apiContext + .get('/api/v1/policies/name/OrganizationPolicy') + .then((response) => response.json()); + + await apiContext.patch(`/api/v1/policies/${orgPolicyResponse.id}`, { + data: [ + { + op: 'replace', + path: '/rules', + value: [...ORGANIZATION_POLICY_RULES, ...VIEW_ALL_RULE], + }, + ], + headers: { + 'Content-Type': 'application/json-patch+json', + }, + }); +}; + +const restoreRolesAndPolicies = async (page: Page) => { + const { apiContext, afterAction } = await getApiContext(page); + // Remove organization policy and role + await restoreOrganizationDefaultRole(apiContext); + // update default Organization policy + await updateDefaultOrganizationPolicy(apiContext); + + await afterAction(); +}; + +export const resetPolicyChanges = async (page: Page, admin: AdminClass) => { + await admin.login(page); + await page.waitForURL('**/my-data'); + await restoreRolesAndPolicies(page); + await admin.logout(page); + await page.waitForURL('**/signin'); +}; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/conditionalPermissions.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/conditionalPermissions.ts new file mode 100644 index 000000000000..82cadb5365f8 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/conditionalPermissions.ts @@ -0,0 +1,304 @@ +/* + * Copyright 2025 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 { APIRequestContext, expect, Page } from '@playwright/test'; +import { conditionalPermissionsEntityData } from '../constant/conditionalPermissions'; +import { + VIEW_ALL_WITH_IS_OWNER, + VIEW_ALL_WITH_MATCH_TAG_CONDITION, +} from '../constant/permission'; +import { TableClass } from '../support/entity/TableClass'; +import { AssetTypes } from '../support/interfaces/ConditionalPermissions.interface'; +import { redirectToHomePage } from './common'; + +export const conditionalPermissionsPrerequisites = async ( + apiContext: APIRequestContext +) => { + const { + isOwnerPolicy, + matchAnyTagPolicy, + isOwnerRole, + matchAnyTagRole, + userWithOwnerPermission, + userWithTagPermission, + withOwner, + withTag, + } = conditionalPermissionsEntityData; + await userWithOwnerPermission.create(apiContext); + await userWithTagPermission.create(apiContext); + await withOwner.apiCollectionWithOwner.create(apiContext); + await withTag.apiCollectionWithTag.create(apiContext); + await withOwner.containerWithOwner.create(apiContext); + await withTag.containerWithTag.create(apiContext); + await withOwner.dashboardWithOwner.create(apiContext); + await withTag.dashboardWithTag.create(apiContext); + await withOwner.mlModelWithOwner.create(apiContext); + await withTag.mlModelWithTag.create(apiContext); + await withOwner.pipelineWithOwner.create(apiContext); + await withTag.pipelineWithTag.create(apiContext); + await withOwner.searchIndexWithOwner.create(apiContext); + await withTag.searchIndexWithTag.create(apiContext); + await withOwner.tableWithOwner.create(apiContext); + await withTag.tableWithTag.create(apiContext); + await withOwner.topicWithOwner.create(apiContext); + await withTag.topicWithTag.create(apiContext); + + const isOwnerPolicyResponse = await isOwnerPolicy.create( + apiContext, + VIEW_ALL_WITH_IS_OWNER + ); + const matchAnyTagPolicyResponse = await matchAnyTagPolicy.create( + apiContext, + VIEW_ALL_WITH_MATCH_TAG_CONDITION + ); + + const isOwnerRoleResponse = await isOwnerRole.create(apiContext, [ + isOwnerPolicyResponse.fullyQualifiedName, + ]); + const matchAnyTagRoleResponse = await matchAnyTagRole.create(apiContext, [ + matchAnyTagPolicyResponse.fullyQualifiedName, + ]); + + await userWithOwnerPermission.patch({ + apiContext, + patchData: [ + { + op: 'replace', + path: '/roles', + value: [ + { + id: isOwnerRoleResponse.id, + type: 'role', + name: isOwnerRoleResponse.name, + }, + ], + }, + ], + }); + await userWithTagPermission.patch({ + apiContext, + patchData: [ + { + op: 'replace', + path: '/roles', + value: [ + { + id: matchAnyTagRoleResponse.id, + type: 'role', + name: matchAnyTagRoleResponse.name, + }, + ], + }, + ], + }); + + const ownerPatchData = { + data: [ + { + op: 'replace', + path: '/owners', + value: [ + { + id: userWithOwnerPermission.responseData.id, + type: 'user', + }, + ], + }, + ], + headers: { + 'Content-Type': 'application/json-patch+json', + }, + }; + + const tagPatchData = { + data: [ + { + op: 'replace', + path: '/tags', + value: [ + { + tagFQN: 'PersonalData.Personal', + source: 'Classification', + labelType: 'Manual', + name: 'Personal', + state: 'Confirmed', + }, + ], + }, + ], + headers: { + 'Content-Type': 'application/json-patch+json', + }, + }; + + for (const entity of Object.values(withOwner)) { + await apiContext.patch( + `/api/v1/services/${entity.serviceType}/${entity.serviceResponseData.id}`, + ownerPatchData + ); + } + + await apiContext.patch( + `/api/v1/databases/${withOwner.tableWithOwner.databaseResponseData.id}`, + ownerPatchData + ); + + await apiContext.patch( + `/api/v1/databaseSchemas/${withOwner.tableWithOwner.schemaResponseData.id}`, + ownerPatchData + ); + + await apiContext.patch( + `/api/v1/containers/${withOwner.containerWithOwner.entityResponseData.id}`, + ownerPatchData + ); + + for (const entity of Object.values(withTag)) { + await apiContext.patch( + `/api/v1/services/${entity.serviceType}/${entity.serviceResponseData.id}`, + tagPatchData + ); + } + + await apiContext.patch( + `/api/v1/databases/${withTag.tableWithTag.databaseResponseData.id}`, + tagPatchData + ); + + await apiContext.patch( + `/api/v1/databaseSchemas/${withTag.tableWithTag.schemaResponseData.id}`, + tagPatchData + ); + + await apiContext.patch( + `/api/v1/containers/${withTag.containerWithTag.entityResponseData.id}`, + tagPatchData + ); +}; + +export const conditionalPermissionsCleanup = async ( + apiContext: APIRequestContext +) => { + const { + isOwnerPolicy, + matchAnyTagPolicy, + isOwnerRole, + matchAnyTagRole, + userWithOwnerPermission, + userWithTagPermission, + withOwner: { + apiCollectionWithOwner, + containerWithOwner, + dashboardWithOwner, + mlModelWithOwner, + pipelineWithOwner, + searchIndexWithOwner, + tableWithOwner, + topicWithOwner, + }, + withTag: { + apiCollectionWithTag, + containerWithTag, + dashboardWithTag, + mlModelWithTag, + pipelineWithTag, + searchIndexWithTag, + tableWithTag, + topicWithTag, + }, + } = conditionalPermissionsEntityData; + await isOwnerRole.delete(apiContext); + await matchAnyTagRole.delete(apiContext); + await isOwnerPolicy.delete(apiContext); + await matchAnyTagPolicy.delete(apiContext); + await userWithOwnerPermission.delete(apiContext); + await userWithTagPermission.delete(apiContext); + await apiCollectionWithOwner.delete(apiContext); + await apiCollectionWithTag.delete(apiContext); + await containerWithOwner.delete(apiContext); + await containerWithTag.delete(apiContext); + await dashboardWithOwner.delete(apiContext); + await dashboardWithTag.delete(apiContext); + await mlModelWithOwner.delete(apiContext); + await mlModelWithTag.delete(apiContext); + await pipelineWithOwner.delete(apiContext); + await pipelineWithTag.delete(apiContext); + await searchIndexWithOwner.delete(apiContext); + await searchIndexWithTag.delete(apiContext); + await tableWithOwner.delete(apiContext); + await tableWithTag.delete(apiContext); + await topicWithOwner.delete(apiContext); + await topicWithTag.delete(apiContext); +}; + +export const getEntityFQN = (assetName: string, asset: AssetTypes) => { + if (assetName === 'database') { + return (asset as TableClass).databaseResponseData.fullyQualifiedName; + } + if (assetName === 'databaseSchema') { + return (asset as TableClass).schemaResponseData.fullyQualifiedName; + } + if (assetName === 'container') { + return asset.entityResponseData.fullyQualifiedName; + } + + return asset.serviceResponseData.fullyQualifiedName; +}; + +export const checkViewAllPermission = async ({ + page, + url1, + url2, + childTabId, + childTabId2, + childTableId2, +}: { + page: Page; + url1: string; + url2: string; + childTabId: string; + childTabId2?: string; + childTableId2?: string; +}) => { + await redirectToHomePage(page); + + // visit the page of the asset with permission + await page.goto(url1); + + // Check if the details are shown properly + await expect(page.getByTestId('data-assets-header')).toBeAttached(); + + await page.waitForSelector(`[data-testid="${childTabId}"]`); + + await page.click(`[data-testid="${childTabId}"]`); + + await expect( + page + .getByTestId('service-children-table') + .getByTestId('no-data-placeholder') + ).not.toBeAttached(); + + if (childTabId2) { + await page.click(`[data-testid="${childTabId2}"]`); + + await expect( + page.getByTestId(childTableId2 ?? '').getByTestId('no-data-placeholder') + ).not.toBeAttached(); + } + + // visit the page of the asset without permission + await page.goto(url2); + + // Check if the no permissions placeholder is shown + await expect(page.getByTestId('permission-error-placeholder')).toBeAttached(); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseDetailsPage/DatabaseDetailsPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseDetailsPage/DatabaseDetailsPage.tsx index 8bee29a444a1..d406fdd97126 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseDetailsPage/DatabaseDetailsPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseDetailsPage/DatabaseDetailsPage.tsx @@ -355,6 +355,8 @@ const DatabaseDetails: FunctionComponent = () => { if (databasePermission.ViewAll || databasePermission.ViewBasic) { getDetailsByFQN(); fetchDatabaseSchemaCount(); + } else { + setIsDatabaseDetailsLoading(false); } }, [databasePermission, decodedDatabaseFQN]); From 4b08eea180734a3114f096c91dbb0ea58349745e Mon Sep 17 00:00:00 2001 From: Karan Hotchandani <33024356+karanh37@users.noreply.github.com> Date: Thu, 16 Jan 2025 16:06:46 +0530 Subject: [PATCH 129/294] fix glossary playwright (#19394) * fix glossary tests * fix glossary test * Update GlossaryPagination.spec.ts (cherry picked from commit 6f63a5102452a3d217471c2ae85afe7fc2520772) --- .../e2e/Features/GlossaryPagination.spec.ts | 69 +++++++++++++++++++ .../ui/playwright/e2e/Pages/Glossary.spec.ts | 69 ++----------------- 2 files changed, 74 insertions(+), 64 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/GlossaryPagination.spec.ts diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/GlossaryPagination.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/GlossaryPagination.spec.ts new file mode 100644 index 000000000000..e3256e339d80 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/GlossaryPagination.spec.ts @@ -0,0 +1,69 @@ +/* + * Copyright 2025 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 test, { expect } from '@playwright/test'; +import { SidebarItem } from '../../constant/sidebar'; +import { Glossary } from '../../support/glossary/Glossary'; +import { getApiContext, redirectToHomePage } from '../../utils/common'; +import { sidebarClick } from '../../utils/sidebar'; + +test.use({ + storageState: 'playwright/.auth/admin.json', +}); + +test.describe('Glossary tests', () => { + test.beforeEach(async ({ page }) => { + await redirectToHomePage(page); + }); + + test('should check for glossary term pagination', async ({ page }) => { + test.slow(true); + + const { apiContext, afterAction } = await getApiContext(page); + const glossaries = []; + for (let i = 0; i < 60; i++) { + const glossary = new Glossary(`Z_PW_GLOSSARY_TEST_${i + 1}`); + await glossary.create(apiContext); + glossaries.push(glossary); + } + + try { + await redirectToHomePage(page); + const glossaryRes = page.waitForResponse('/api/v1/glossaries?*'); + await sidebarClick(page, SidebarItem.GLOSSARY); + await glossaryRes; + + const glossaryAfterRes = page.waitForResponse( + '/api/v1/glossaries?*after=*' + ); + await page + .getByTestId('glossary-left-panel-scroller') + .scrollIntoViewIfNeeded(); + + const res = await glossaryAfterRes; + const json = await res.json(); + + const firstGlossaryName = json.data[0].displayName; + + await expect( + page + .getByTestId('glossary-left-panel') + .getByRole('menuitem', { name: firstGlossaryName }) + ).toBeVisible(); + } finally { + for (const glossary of glossaries) { + await glossary.delete(apiContext); + } + await afterAction(); + } + }); +}); 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 a901513fb067..181601b7fd7d 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 @@ -314,7 +314,7 @@ test.describe('Glossary tests', () => { await glossaryTerm1.create(apiContext); await owner1.create(apiContext); await reviewer1.create(apiContext); - await await redirectToHomePage(page); + await redirectToHomePage(page); await sidebarClick(page, SidebarItem.GLOSSARY); await selectActiveGlossary(page, glossary1.data.displayName); @@ -511,7 +511,7 @@ test.describe('Glossary tests', () => { await patchRequest2; // Check if the terms are present - const glossaryContainer = await page.locator( + const glossaryContainer = page.locator( '[data-testid="entity-right-panel"] [data-testid="glossary-container"]' ); const glossaryContainerText = await glossaryContainer.innerText(); @@ -521,7 +521,7 @@ test.describe('Glossary tests', () => { // Check if the icons are present - const icons = await page.locator( + const icons = page.locator( '[data-testid="entity-right-panel"] [data-testid="glossary-container"] [data-testid="glossary-icon"]' ); @@ -571,7 +571,7 @@ test.describe('Glossary tests', () => { expect(tagSelectorText).toContain(glossaryTerm3.data.displayName); // Check if the icon is visible - const icon = await page.locator( + const icon = page.locator( '[data-testid="glossary-tags-0"] > [data-testid="tags-wrapper"] > [data-testid="glossary-container"] [data-testid="glossary-icon"]' ); @@ -583,7 +583,7 @@ test.describe('Glossary tests', () => { await goToAssetsTab(page, glossaryTerm3.data.displayName, 2); // Check if the selected asset are present - const assetContainer = await page.locator( + const assetContainer = page.locator( '[data-testid="table-container"] .assets-data-container' ); @@ -1187,63 +1187,4 @@ test.describe('Glossary tests', () => { await afterAction(); } }); - - test('should check for glossary term pagination', async ({ browser }) => { - test.slow(true); - - const { page, afterAction, apiContext } = await performAdminLogin(browser); - const glossaries = []; - for (let i = 0; i < 60; i++) { - const glossary = new Glossary(`PW_GLOSSARY_TEST_${i + 1}`); - await glossary.create(apiContext); - glossaries.push(glossary); - } - - try { - await redirectToHomePage(page); - const glossaryRes = page.waitForResponse('/api/v1/glossaries?*'); - await sidebarClick(page, SidebarItem.GLOSSARY); - await glossaryRes; - - const glossaryAfterRes = page.waitForResponse( - '/api/v1/glossaries?*after=*' - ); - await page - .getByTestId('glossary-left-panel-scroller') - .scrollIntoViewIfNeeded(); - - const res = await glossaryAfterRes; - const json = await res.json(); - - const firstGlossaryName = json.data[0].displayName; - - await expect( - page - .getByTestId('glossary-left-panel') - .getByRole('menuitem', { name: firstGlossaryName }) - ).toBeVisible(); - - const lastGlossaryName = json.data[json.data.length - 1].displayName; - - await expect( - page - .getByTestId('glossary-left-panel') - .getByRole('menuitem', { name: lastGlossaryName }) - ).toBeVisible(); - } finally { - for (const glossary of glossaries) { - await glossary.delete(apiContext); - } - await afterAction(); - } - }); - - test.afterAll(async ({ browser }) => { - const { afterAction, apiContext } = await performAdminLogin(browser); - await user1.delete(apiContext); - await user2.delete(apiContext); - await user3.create(apiContext); - await team.delete(apiContext); - await afterAction(); - }); }); From 9417f23fc832643a9a5ddfd9ee98d6d0f2fa00cb Mon Sep 17 00:00:00 2001 From: sonika-shah <58761340+sonika-shah@users.noreply.github.com> Date: Fri, 17 Jan 2025 14:44:14 +0530 Subject: [PATCH 130/294] Fix - 19359 : Get only non-deleted entities in export (#19396) (cherry picked from commit b8ea10539595debb8e0fcdf4b45fddc274760d69) --- .../org/openmetadata/service/jdbi3/EntityDAO.java | 13 +++++++++++++ .../service/jdbi3/EntityRepository.java | 10 +++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityDAO.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityDAO.java index 67d5a62078af..ba0e95a91cb2 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityDAO.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityDAO.java @@ -322,6 +322,14 @@ List listAll( @Bind("startHash") String startHash, @Bind("endHash") String endHash); + @SqlQuery("SELECT json FROM
AND BETWEEN :startHash AND :endHash ") + List listAll( + @Define("table") String table, + @Define("cond") String cond, + @Define("nameHashColumn") String nameHashColumn, + @Bind("startHash") String startHash, + @Bind("endHash") String endHash); + @SqlQuery("SELECT json FROM
LIMIT :limit OFFSET :offset") List listAfterWithOffset( @Define("table") String table, @Bind("limit") int limit, @Bind("offset") int offset); @@ -470,6 +478,11 @@ default List listAll(String startHash, String endHash) { return listAll(getTableName(), getNameHashColumn(), startHash, endHash); } + default List listAll(String startHash, String endHash, ListFilter filter) { + // Quoted name is stored in fullyQualifiedName column and not in the name column + return listAll(getTableName(), filter.getCondition(), getNameHashColumn(), startHash, endHash); + } + default List listAfterWithOffset(int limit, int offset) { // No ordering return listAfterWithOffset(getTableName(), limit, offset); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java index 6545a7faa33d..636199c880d5 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java @@ -688,7 +688,8 @@ public final List listAll(Fields fields, ListFilter filter) { } public final List listAllForCSV(Fields fields, String parentFqn) { - List jsons = listAllByParentFqn(parentFqn); + ListFilter filter = new ListFilter(Include.NON_DELETED); + List jsons = listAllByParentFqn(parentFqn, filter); List entities = new ArrayList<>(); setFieldsInBulk(jsons, fields, entities); return entities; @@ -713,6 +714,13 @@ public List listAllByParentFqn(String parentFqn) { return dao.listAll(startHash, endHash); } + public List listAllByParentFqn(String parentFqn, ListFilter filter) { + String fqnPrefixHash = FullyQualifiedName.buildHash(parentFqn); + String startHash = fqnPrefixHash + ".00000000000000000000000000000000"; + String endHash = fqnPrefixHash + ".ffffffffffffffffffffffffffffffff"; + return dao.listAll(startHash, endHash, filter); + } + public ResultList listAfter( UriInfo uriInfo, Fields fields, ListFilter filter, int limitParam, String after) { int total = dao.listCount(filter); From 2f929f69a6a8690c31af58c1852edb2c5de95389 Mon Sep 17 00:00:00 2001 From: Aniket Katkar Date: Sun, 19 Jan 2025 01:17:02 +0530 Subject: [PATCH 131/294] Fix the permissions call made for search service (#19430) (cherry picked from commit 8bbc5945889a9c483f006e3800b42dde695ca34f) --- .../constant/conditionalPermissions.ts | 17 ++++++++--------- .../resources/ui/src/utils/ServiceUtils.tsx | 4 ++++ 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/constant/conditionalPermissions.ts b/openmetadata-ui/src/main/resources/ui/playwright/constant/conditionalPermissions.ts index e760f9884790..bf32712c3fe2 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/constant/conditionalPermissions.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/constant/conditionalPermissions.ts @@ -112,15 +112,14 @@ export const assetsData = [ assetOwnerUrl: `/service/${pipelineWithOwner.serviceType}`, assetTagUrl: `/service/${pipelineWithTag.serviceType}`, }, - // TODO: Uncomment when search index permission issue is fixed - // { - // asset: ServiceTypes.SEARCH_SERVICES, - // withOwner: searchIndexWithOwner, - // withTag: searchIndexWithTag, - // childTabId: 'search indexes', - // assetOwnerUrl: `/service/${searchIndexWithOwner.serviceType}`, - // assetTagUrl: `/service/${searchIndexWithTag.serviceType}`, - // }, + { + asset: ServiceTypes.SEARCH_SERVICES, + withOwner: searchIndexWithOwner, + withTag: searchIndexWithTag, + childTabId: 'search indexes', + assetOwnerUrl: `/service/${searchIndexWithOwner.serviceType}`, + assetTagUrl: `/service/${searchIndexWithTag.serviceType}`, + }, { asset: ServiceTypes.DATABASE_SERVICES, withOwner: tableWithOwner, diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/ServiceUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/ServiceUtils.tsx index 54517773dfdd..0f62bc5e2f9b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/ServiceUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ServiceUtils.tsx @@ -385,6 +385,10 @@ export const getResourceEntityFromServiceCategory = ( case ServiceCategory.STORAGE_SERVICES: return ResourceEntity.STORAGE_SERVICE; + case 'searchIndex': + case ServiceCategory.SEARCH_SERVICES: + return ResourceEntity.SEARCH_SERVICE; + case ServiceCategory.API_SERVICES: return ResourceEntity.API_SERVICE; } From 0dd1114f1a51288c54374a09a81f9e0781493acf Mon Sep 17 00:00:00 2001 From: Aniket Katkar Date: Sun, 19 Jan 2025 12:20:56 +0530 Subject: [PATCH 132/294] Minor: Change log localization improvement (#19427) * Change the localization key for the 'Change Log' * make Change log plural * Localization changes --------- Co-authored-by: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> (cherry picked from commit 3a36554812b9b6490d23750f04cec9388fae1137) --- .../ui/src/components/Modals/WhatsNewModal/WhatsNewModal.tsx | 4 +--- .../src/main/resources/ui/src/locale/languages/de-de.json | 1 + .../src/main/resources/ui/src/locale/languages/en-us.json | 1 + .../src/main/resources/ui/src/locale/languages/es-es.json | 1 + .../src/main/resources/ui/src/locale/languages/fr-fr.json | 1 + .../src/main/resources/ui/src/locale/languages/gl-es.json | 1 + .../src/main/resources/ui/src/locale/languages/he-he.json | 1 + .../src/main/resources/ui/src/locale/languages/ja-jp.json | 1 + .../src/main/resources/ui/src/locale/languages/mr-in.json | 1 + .../src/main/resources/ui/src/locale/languages/nl-nl.json | 1 + .../src/main/resources/ui/src/locale/languages/pr-pr.json | 1 + .../src/main/resources/ui/src/locale/languages/pt-br.json | 1 + .../src/main/resources/ui/src/locale/languages/pt-pt.json | 1 + .../src/main/resources/ui/src/locale/languages/ru-ru.json | 1 + .../src/main/resources/ui/src/locale/languages/th-th.json | 1 + .../src/main/resources/ui/src/locale/languages/zh-cn.json | 1 + 16 files changed, 16 insertions(+), 3 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Modals/WhatsNewModal/WhatsNewModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Modals/WhatsNewModal/WhatsNewModal.tsx index 21c5efcef282..0f7af90ec41a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Modals/WhatsNewModal/WhatsNewModal.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Modals/WhatsNewModal/WhatsNewModal.tsx @@ -139,9 +139,7 @@ const WhatsNewModal: FunctionComponent = ({ onClick={() => { handleToggleChange(ToggleType.CHANGE_LOG); }}> - {t('label.change-entity', { - entity: t('label.log-plural'), - })} + {t('label.change-log-plural')} diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json index 571d2a3f2463..951e986c871f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json @@ -149,6 +149,7 @@ "category-plural": "Categories", "certification": "Certification", "change-entity": "{{entity}} ändern", + "change-log-plural": "Änderungsprotokolle", "change-parent-entity": "Change Parent {{entity}}", "chart": "Diagramm", "chart-entity": "{{entity}} Diagramm", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json index 412ff8de4a33..12c0e1155e4f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json @@ -149,6 +149,7 @@ "category-plural": "Categories", "certification": "Certification", "change-entity": "Change {{entity}}", + "change-log-plural": "Change Logs", "change-parent-entity": "Change Parent {{entity}}", "chart": "Chart", "chart-entity": "Chart {{entity}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json index b2b5d1e6dffd..4046d52f1f13 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json @@ -149,6 +149,7 @@ "category-plural": "Categorías", "certification": "Certification", "change-entity": "Cambiar {{entity}}", + "change-log-plural": "Registros de cambios", "change-parent-entity": "Change Parent {{entity}}", "chart": "Gráfico", "chart-entity": "Gráfico {{entity}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json index 22dfa0874a21..917bdb6c1ed9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json @@ -149,6 +149,7 @@ "category-plural": "Categories", "certification": "Certification", "change-entity": "Changer {{entity}}", + "change-log-plural": "Journaux de Modifications", "change-parent-entity": "Change Parent {{entity}}", "chart": "Graphique", "chart-entity": "Graphique {{entity}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json index 45db37b1c182..e37ad4fa8493 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json @@ -149,6 +149,7 @@ "category-plural": "Categorías", "certification": "Certification", "change-entity": "Cambiar {{entity}}", + "change-log-plural": "Rexistros de cambios", "change-parent-entity": "Cambiar {{entity}} pai", "chart": "Gráfico", "chart-entity": "Gráfico {{entity}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json index c042fa94d4a2..e6abcfa965d9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json @@ -149,6 +149,7 @@ "category-plural": "קטגוריות", "certification": "Certification", "change-entity": "שנה {{entity}}", + "change-log-plural": "יומני שינויים", "change-parent-entity": "Change Parent {{entity}}", "chart": "תרשים", "chart-entity": "תרשים {{entity}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json index 997fa869dbb2..cdd8e20c5cb8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json @@ -149,6 +149,7 @@ "category-plural": "Categories", "certification": "Certification", "change-entity": "{{entity}}を変更", + "change-log-plural": "変更ログ", "change-parent-entity": "Change Parent {{entity}}", "chart": "チャート", "chart-entity": "{{entity}}のチャート", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json index 17faea84e6ee..02dbb8788ba9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json @@ -149,6 +149,7 @@ "category-plural": "श्रेण्या", "certification": "प्रमाणपत्र", "change-entity": "{{entity}} बदला", + "change-log-plural": "बदल नोंदी", "change-parent-entity": "पालक {{entity}} बदला", "chart": "तक्ता", "chart-entity": "तक्ता {{entity}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json index 258a7d9571d8..88fa90e449c1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json @@ -149,6 +149,7 @@ "category-plural": "Categorieën", "certification": "Certification", "change-entity": "Verander {{entity}}", + "change-log-plural": "Wijzigingslogboeken", "change-parent-entity": "Change Parent {{entity}}", "chart": "Chart", "chart-entity": "Chart {{entity}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json index d4eb3873660a..f07f2e9025a3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json @@ -149,6 +149,7 @@ "category-plural": "دسته‌بندی‌ها", "certification": "Certification", "change-entity": "تغییر {{entity}}", + "change-log-plural": "لاگ‌های تغییر", "change-parent-entity": "تغییر والد {{entity}}", "chart": "نمودار", "chart-entity": "نمودار {{entity}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json index 2bf70963b6e7..301b99520e50 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json @@ -149,6 +149,7 @@ "category-plural": "Categorias", "certification": "Certification", "change-entity": "Mudar {{entity}}", + "change-log-plural": "Logs de Mudança", "change-parent-entity": "Change Parent {{entity}}", "chart": "Gráfico", "chart-entity": "Gráfico {{entity}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json index 75d8001dccfb..c27c48832b28 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json @@ -149,6 +149,7 @@ "category-plural": "Categorias", "certification": "Certification", "change-entity": "Mudar {{entity}}", + "change-log-plural": "Registros de Alteração", "change-parent-entity": "Change Parent {{entity}}", "chart": "Gráfico", "chart-entity": "Gráfico {{entity}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json index 9e837dd550f8..31bd5c778b60 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json @@ -149,6 +149,7 @@ "category-plural": "Categories", "certification": "Certification", "change-entity": "Изменить {{entity}}", + "change-log-plural": "Журналы изменений", "change-parent-entity": "Change Parent {{entity}}", "chart": "Диаграмма", "chart-entity": "Диаграмма {{entity}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json index 8b2961a30852..122c803d577d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json @@ -149,6 +149,7 @@ "category-plural": "หมวดหมู่หลายรายการ", "certification": "Certification", "change-entity": "เปลี่ยน {{entity}}", + "change-log-plural": "บันทึกการเปลี่ยนแปลง", "change-parent-entity": "เปลี่ยนผู้ปกครอง {{entity}}", "chart": "กราฟ", "chart-entity": "กราฟ {{entity}}", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json index a87fac0ad9e9..03a0f2cf8e63 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json @@ -149,6 +149,7 @@ "category-plural": "分类", "certification": "Certification", "change-entity": "更改{{entity}}", + "change-log-plural": "变更日志", "change-parent-entity": "更改父级{{entity}}", "chart": "图表", "chart-entity": "图表{{entity}}", From 6786e3efec1e3a6e5a81e9a6810dfa36f18b4656 Mon Sep 17 00:00:00 2001 From: Mayur Singal <39544459+ulixius9@users.noreply.github.com> Date: Mon, 20 Jan 2025 10:37:07 +0530 Subject: [PATCH 133/294] MINOR: Fix DI Filter not getting applied (#19400) (cherry picked from commit e5acdab3b814510636926afc2b19cf21449ed8b3) --- .../service/jdbi3/DataInsightSystemChartRepository.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DataInsightSystemChartRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DataInsightSystemChartRepository.java index d082445fcdc4..046ffa346920 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DataInsightSystemChartRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DataInsightSystemChartRepository.java @@ -92,7 +92,7 @@ public DataInsightCustomChartResultList getPreviewData( if (chartDetails.get("metrics") != null) { for (LinkedHashMap metrics : (List>) chartDetails.get("metrics")) { - metrics.put("filter ", filter); + metrics.put("filter", filter); } } } @@ -121,7 +121,7 @@ public Map listChartData( if (chartDetails.get("metrics") != null) { for (LinkedHashMap metrics : (List>) chartDetails.get("metrics")) { - metrics.put("filter ", filter); + metrics.put("filter", filter); } } } From 48c81a6277d6e2e8aabe1382bbef9e36df4ccba8 Mon Sep 17 00:00:00 2001 From: Aniket Katkar Date: Mon, 20 Jan 2025 10:32:53 +0530 Subject: [PATCH 134/294] Minor: Improve cron expression validations (#19426) --- .../src/main/resources/ui/package.json | 2 +- .../e2e/Features/CronValidations.spec.ts | 182 ++++++++++++++++++ .../AddIngestion/Steps/ScheduleInterval.tsx | 18 +- .../ui/src/locale/languages/de-de.json | 1 + .../ui/src/locale/languages/en-us.json | 1 + .../ui/src/locale/languages/es-es.json | 1 + .../ui/src/locale/languages/fr-fr.json | 1 + .../ui/src/locale/languages/gl-es.json | 3 +- .../ui/src/locale/languages/he-he.json | 1 + .../ui/src/locale/languages/ja-jp.json | 1 + .../ui/src/locale/languages/mr-in.json | 1 + .../ui/src/locale/languages/nl-nl.json | 1 + .../ui/src/locale/languages/pr-pr.json | 1 + .../ui/src/locale/languages/pt-br.json | 1 + .../ui/src/locale/languages/pt-pt.json | 1 + .../ui/src/locale/languages/ru-ru.json | 1 + .../ui/src/locale/languages/th-th.json | 1 + .../ui/src/locale/languages/zh-cn.json | 1 + .../ui/src/utils/SchedularUtils.test.tsx | 83 ++++++++ .../resources/ui/src/utils/SchedularUtils.tsx | 49 +++++ .../src/main/resources/ui/yarn.lock | 8 +- 21 files changed, 337 insertions(+), 22 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/CronValidations.spec.ts diff --git a/openmetadata-ui/src/main/resources/ui/package.json b/openmetadata-ui/src/main/resources/ui/package.json index 162e0c2d705e..007ed2df820d 100644 --- a/openmetadata-ui/src/main/resources/ui/package.json +++ b/openmetadata-ui/src/main/resources/ui/package.json @@ -78,7 +78,7 @@ "classnames": "^2.3.1", "codemirror": "^5.65.16", "cookie-storage": "^6.1.0", - "cronstrue": "^1.122.0", + "cronstrue": "^2.53.0", "crypto-random-string-with-promisify-polyfill": "^5.0.0", "diff": "^5.0.0", "dompurify": "^3.1.5", diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/CronValidations.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/CronValidations.spec.ts new file mode 100644 index 000000000000..788ad7a5840b --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/CronValidations.spec.ts @@ -0,0 +1,182 @@ +/* + * Copyright 2025 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, test } from '@playwright/test'; +import { GlobalSettingOptions } from '../../constant/settings'; +import { redirectToHomePage } from '../../utils/common'; +import { settingClick } from '../../utils/sidebar'; + +const inputCronExpression = async (page: Page, cron: string) => { + await page + .locator('[data-testid="cron-container"] #schedular-form_cron') + .click(); + await page + .locator('[data-testid="cron-container"] #schedular-form_cron') + .clear(); + await page + .locator('[data-testid="cron-container"] #schedular-form_cron') + .fill(cron); +}; + +// use the admin user to login +test.use({ + storageState: 'playwright/.auth/admin.json', +}); + +test.describe('Cron Validations', () => { + test('Validate different cron expressions', async ({ page }) => { + await redirectToHomePage(page); + + await settingClick(page, GlobalSettingOptions.APPLICATIONS); + + const applicationResponse = page.waitForResponse( + '/api/v1/apps/name/SearchIndexingApplication/status?offset=0&limit=1' + ); + + await page + .locator( + '[data-testid="search-indexing-application-card"] [data-testid="config-btn"]' + ) + .click(); + + await applicationResponse; + + await page.click('[data-testid="edit-button"]'); + await page.waitForSelector('[data-testid="schedular-card-container"]'); + await page + .getByTestId('schedular-card-container') + .getByText('Schedule', { exact: true }) + .click(); + + await page + .getByTestId('time-dropdown-container') + .getByTestId('cron-type') + .click(); + + await page.click('.ant-select-dropdown:visible [title="Custom"]'); + + await page.waitForSelector( + '[data-testid="cron-container"] #schedular-form_cron' + ); + + // Check Valid Crons + + // Check '0 0 * * *' to be valid + await inputCronExpression(page, '0 0 * * *'); + + await expect( + page.getByTestId('cron-container').getByText('At 12:00 AM, every day') + ).toBeAttached(); + await expect(page.locator('#schedular-form_cron_help')).not.toBeAttached(); + + // Check '0 0 1/3 * * 1' to be valid + await inputCronExpression(page, '0 0 1/3 * * 1'); + + await expect( + page + .getByTestId('cron-container') + .getByText( + 'At 0 minutes past the hour, every 3 hours, starting at 01:00 AM, only on Monday' + ) + ).toBeAttached(); + await expect(page.locator('#schedular-form_cron_help')).not.toBeAttached(); + + // Check '0 0 * * 1-6' to be valid + await inputCronExpression(page, '0 0 * * 1-6'); + + await expect( + page + .getByTestId('cron-container') + .getByText('At 12:00 AM, Monday through Saturday') + ).toBeAttached(); + await expect(page.locator('#schedular-form_cron_help')).not.toBeAttached(); + + // Check Invalid crons + + // Check every minute frequency throws an error + await inputCronExpression(page, '0/1 0 * * *'); + + await expect( + page + .locator('#schedular-form_cron_help') + .getByText( + 'Cron schedule too frequent. Please choose at least 1-hour intervals.' + ) + ).toBeAttached(); + + // Check every second frequency throws an error + await inputCronExpression(page, '0/1 0 * * * 1'); + + await expect( + page + .locator('#schedular-form_cron_help') + .getByText( + 'Cron schedule too frequent. Please choose at least 1-hour intervals.' + ) + ).toBeAttached(); + + // Check '0 0 * * 7' to be invalid + await inputCronExpression(page, '0 0 * * 7'); + + await expect( + page + .locator('#schedular-form_cron_help') + .getByText('DOW part must be >= 0 and <= 6') + ).toBeAttached(); + + // Check '0 0 * * 1 7' to be invalid + await inputCronExpression(page, '0 0 * * 1 7'); + + await expect( + page + .locator('#schedular-form_cron_help') + .getByText('DOW part must be >= 0 and <= 6') + ).toBeAttached(); + + // Check '0 0 * * 1 7 67' to be invalid + await inputCronExpression(page, '0 0 * * 1 7 67'); + + await expect( + page + .locator('#schedular-form_cron_help') + .getByText('DOW part must be >= 0 and <= 6') + ).toBeAttached(); + + // Check '0 0 * * 0-7' to be invalid + await inputCronExpression(page, '0 0 * * 0-7'); + + await expect( + page + .locator('#schedular-form_cron_help') + .getByText('DOW part must be >= 0 and <= 6') + ).toBeAttached(); + + // Check '0 0 * * 7-9' to be invalid + await inputCronExpression(page, '0 0 * * 7-9'); + + await expect( + page + .locator('#schedular-form_cron_help') + .getByText('DOW part must be >= 0 and <= 6') + ).toBeAttached(); + + // Check '0 0 * * -1-9' to be invalid + await inputCronExpression(page, '0 0 * * -1-9'); + + await expect( + page + .locator('#schedular-form_cron_help') + .getByText('Error: DOW part must be >= 0 and <= 6') + ).toBeAttached(); + }); +}); 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 61c883551519..d586974b506a 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 @@ -49,6 +49,7 @@ import { import { generateFormFields } from '../../../../../utils/formUtils'; import { getCurrentLocaleForConstrue } from '../../../../../utils/i18next/i18nextUtil'; import { + cronValidator, getCron, getDefaultScheduleValue, getHourMinuteSelect, @@ -360,22 +361,7 @@ const ScheduleInterval = ({ }), }, { - validator: async (_, value) => { - // Check if cron is valid and get the description - const description = cronstrue.toString(value); - - // Check if cron has a frequency of less than an hour - const isFrequencyInMinutes = /Every \d* *minute/.test( - description - ); - if (isFrequencyInMinutes) { - return Promise.reject( - t('message.cron-less-than-hour-message') - ); - } - - return Promise.resolve(); - }, + validator: cronValidator, }, ]}> diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json index 951e986c871f..5045aaaf9a35 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json @@ -1488,6 +1488,7 @@ "create-new-glossary-guide": "Ein Glossar ist ein kontrolliertes Vokabular, das verwendet wird, um die Konzepte und Terminologie in einer Organisation zu definieren. Glossare können spezifisch für einen bestimmten Bereich sein (z. B. Business Glossar, Technisches Glossar). Im Glossar können die Standardbegriffe und Konzepte definiert werden, zusammen mit Synonymen und verwandten Begriffen. Es kann festgelegt werden, wie und von wem Begriffe im Glossar hinzugefügt werden können.", "create-or-update-email-account-for-bot": "Die Änderung der Kontaktemail aktualisiert oder erstellt einen neuen Bot-Benutzer.", "created-this-task-lowercase": "hat diese Aufgabe erstellt", + "cron-dow-validation-failure": "Der DOW-Teil muss >= 0 und <= 6 sein", "cron-less-than-hour-message": "Cron schedule too frequent. Please choose at least 1-hour intervals.", "custom-classification-name-dbt-tags": "Benutzerdefinierter OpenMetadata-Klassifikationsname für dbt-Tags ", "custom-favicon-url-path-message": "URL path for the favicon icon.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json index 12c0e1155e4f..7ae350ab4d5f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json @@ -1488,6 +1488,7 @@ "create-new-glossary-guide": "A Glossary is a controlled vocabulary used to define the concepts and terminology in an organization. Glossaries can be specific to a certain domain (for e.g., Business Glossary, Technical Glossary). In the glossary, the standard terms and concepts can be defined along with the synonyms, and related terms. Control can be established over how and who can add the terms in the glossary.", "create-or-update-email-account-for-bot": "Changing the account email will update or create a new bot user.", "created-this-task-lowercase": "created this task", + "cron-dow-validation-failure": "DOW part must be >= 0 and <= 6", "cron-less-than-hour-message": "Cron schedule too frequent. Please choose at least 1-hour intervals.", "custom-classification-name-dbt-tags": "Custom OpenMetadata Classification name for dbt tags ", "custom-favicon-url-path-message": "URL path for the favicon icon.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json index 4046d52f1f13..6d1ea630bdfd 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json @@ -1488,6 +1488,7 @@ "create-new-glossary-guide": "Un glosario es un vocabulario controlado utilizado para definir los conceptos y terminología en una organización. Los glosarios pueden ser específicos para un determinado dominio (por ejemplo, glosario de negocios o técnico). En el glosario, se pueden definir los términos y conceptos estándar junto con los sinónimos y términos relacionados. Se puede establecer control sobre cómo y quién puede agregar los términos en el glosario.", "create-or-update-email-account-for-bot": "Cambiar el correo electrónico de la cuenta actualizará o creará un nuevo bot.", "created-this-task-lowercase": "creó esta tarea", + "cron-dow-validation-failure": "La parte DOW debe ser >= 0 y <= 6", "cron-less-than-hour-message": "Cron schedule too frequent. Please choose at least 1-hour intervals.", "custom-classification-name-dbt-tags": "Nombre personalizado de clasificación de OpenMetadata para tags de dbt", "custom-favicon-url-path-message": "Ruta URL para el ícono de favicon.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json index 917bdb6c1ed9..27866a462cee 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json @@ -1488,6 +1488,7 @@ "create-new-glossary-guide": "Un Glossaire est un recueil de termes et vocabulaire utilisé pour définir des concepts et terminologies. Glossaires peuvent être spécifiques à certains domaines (e.g., Glossaire Business, Glossaire Technique, etc.). Dans le glossaire, les termes et concepts peuvent être définis tout en spécifiant des synonymes et des termes liés. Il est possible de contrôler qui peut ajouter des termes dans le dans le glossaire et comment ces termes peuvent être ajoutés.", "create-or-update-email-account-for-bot": "Changer l'email créera un nouveau ou mettra à jour l'agent numérique", "created-this-task-lowercase": "a créé cette tâche", + "cron-dow-validation-failure": "La partie DOW doit être >= 0 et <= 6", "cron-less-than-hour-message": "Cron schedule too frequent. Please choose at least 1-hour intervals.", "custom-classification-name-dbt-tags": "Nom personnalisé de la classification OpenMetadata pour les tags dbt ", "custom-favicon-url-path-message": "Chemin (URL) de l'icône favicon.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json index e37ad4fa8493..e357577b5fbe 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json @@ -1488,7 +1488,8 @@ "create-new-glossary-guide": "Un glosario é un vocabulario controlado que se usa para definir os conceptos e a terminoloxía nunha organización. Os glosarios poden ser específicos dun determinado dominio (por exemplo, glosario empresarial, glosario técnico). No glosario, poden definirse os termos e conceptos estándar, xunto cos sinónimos e termos relacionados. Pódese establecer control sobre como e quen pode engadir termos ao glosario.", "create-or-update-email-account-for-bot": "Cambiar o correo electrónico da conta actualizará ou creará un novo usuario bot.", "created-this-task-lowercase": "creou esta tarefa", - "cron-less-than-hour-message": "Cron schedule too frequent. Please choose at least 1-hour intervals.", + "cron-dow-validation-failure": "A parte DOW debe ser >= 0 e <= 6", + "cron-less-than-hour-message": "Horario Cron demasiado frecuente. Escolle intervalos de polo menos 1 hora.", "custom-classification-name-dbt-tags": "Nome da clasificación personalizada de OpenMetadata para etiquetas dbt", "custom-favicon-url-path-message": "Ruta URL para o icono favicon.", "custom-logo-configuration-message": "Personaliza OpenMetadata co logotipo da túa empresa, monograma e favicon.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json index e6abcfa965d9..ce55ee75e970 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json @@ -1488,6 +1488,7 @@ "create-new-glossary-guide": "מדוע לא ניתן לתת למשתמש האחרון את השאלה שלו?", "create-or-update-email-account-for-bot": "שינוי כתובת האימייל של החשבון יעדכן או ייצור משתמש בוט חדש.", "created-this-task-lowercase": "יצר משימה זו", + "cron-dow-validation-failure": "חלק DOW חייב להיות >= 0 ו-<= 6", "cron-less-than-hour-message": "Cron schedule too frequent. Please choose at least 1-hour intervals.", "custom-classification-name-dbt-tags": "שם קטגוריה מותאמת אישית של OpenMetadata עבור תגי dbt ", "custom-favicon-url-path-message": "נתיב URL עבור סמל האתר.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json index cdd8e20c5cb8..f79442703c8d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json @@ -1488,6 +1488,7 @@ "create-new-glossary-guide": "A Glossary is a controlled vocabulary used to define the concepts and terminology in an organization. Glossaries can be specific to a certain domain (for e.g., Business Glossary, Technical Glossary). In the glossary, the standard terms and concepts can be defined along with the synonyms, and related terms. Control can be established over how and who can add the terms in the glossary.", "create-or-update-email-account-for-bot": "Changing the account email will update or create a new bot user.", "created-this-task-lowercase": "このタスクを作成する", + "cron-dow-validation-failure": "DOW部分は0以上6以下でなければなりません", "cron-less-than-hour-message": "Cron schedule too frequent. Please choose at least 1-hour intervals.", "custom-classification-name-dbt-tags": "Custom OpenMetadata Classification name for dbt tags ", "custom-favicon-url-path-message": "URL path for the favicon icon.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json index 02dbb8788ba9..b410e7088d69 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json @@ -1488,6 +1488,7 @@ "create-new-glossary-guide": "शब्दकोश ही नियंत्रित शब्दावली आहे जी संस्थेतील संकल्पना आणि शब्दावली परिभाषित करण्यासाठी वापरली जाते. शब्दकोश विशिष्ट डोमेनसाठी विशिष्ट असू शकतात (उदा., व्यवसाय शब्दकोश, तांत्रिक शब्दकोश). शब्दकोशात, मानक संज्ञा आणि संकल्पना समानार्थी शब्द आणि संबंधित संज्ञांसह परिभाषित केल्या जाऊ शकतात. शब्दकोशात संज्ञा कशा आणि कोण जोडू शकतात यावर नियंत्रण स्थापित केले जाऊ शकते.", "create-or-update-email-account-for-bot": "खाते ईमेल बदलल्याने नवीन बॉट वापरकर्ता अद्यतनित किंवा तयार होईल.", "created-this-task-lowercase": "हे कार्य तयार केले", + "cron-dow-validation-failure": "DOW भाग >= 0 आणि <= 6 असणे आवश्यक आहे", "cron-less-than-hour-message": "क्रॉन वेळापत्रक खूप वारंवार आहे. कृपया किमान 1-तास अंतर निवडा.", "custom-classification-name-dbt-tags": "dbt टॅगसाठी सानुकूल OpenMetadata वर्गीकरण नाव", "custom-favicon-url-path-message": "फेविकॉन आयकॉनसाठी URL पथ.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json index 88fa90e449c1..86db72a8f60a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json @@ -1488,6 +1488,7 @@ "create-new-glossary-guide": "Een woordenboek is een gecontroleerde woordenlijst die wordt gebruikt om concepten en terminologie van een organisatie te definiëren. Woordenboeken kunnen domeinspecifiek zijn (bijv. Bedrijfswoordenboek, Technisch woordenboek). In het woordenboek kunnen standaardtermen en concepten worden gedefinieerd, samen met synoniemen en gerelateerde termen. Hoe en wie de termen in het woordenboek kan toevoegen, kan worden beheerd.", "create-or-update-email-account-for-bot": "Het wijzigen van het account-e-mailadres zal een nieuwe botgebruiker updaten of maken.", "created-this-task-lowercase": "heeft deze taak aangemaakt", + "cron-dow-validation-failure": "DOW-deel moet >= 0 en <= 6 zijn", "cron-less-than-hour-message": "Cron schedule too frequent. Please choose at least 1-hour intervals.", "custom-classification-name-dbt-tags": "Aangepaste OpenMetadata-classificatienaam voor dbt-tags", "custom-favicon-url-path-message": "URL-pad voor het favicon-pictogram.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json index f07f2e9025a3..2dc2ae753635 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json @@ -1488,6 +1488,7 @@ "create-new-glossary-guide": "یک فرهنگ لغت واژگان کنترل‌شده‌ای است که برای تعریف مفاهیم و اصطلاحات در یک سازمان استفاده می‌شود. فرهنگ لغت‌ها می‌توانند خاص یک دامنه خاص باشند (به عنوان مثال، فرهنگ لغت تجاری، فرهنگ لغت فنی). در فرهنگ لغت، اصطلاحات و مفاهیم استاندارد می‌توانند همراه با مترادف‌ها و اصطلاحات مرتبط تعریف شوند. کنترل می‌تواند بر نحوه و افرادی که می‌توانند اصطلاحات را به فرهنگ لغت اضافه کنند، برقرار شود.", "create-or-update-email-account-for-bot": "تغییر ایمیل حساب باعث به‌روزرسانی یا ایجاد یک کاربر ربات جدید خواهد شد.", "created-this-task-lowercase": "این وظیفه را ایجاد کرد", + "cron-dow-validation-failure": "بخش DOW باید >= 0 و <= 6 باشد", "cron-less-than-hour-message": "Cron schedule too frequent. Please choose at least 1-hour intervals.", "custom-classification-name-dbt-tags": "نام دسته‌بندی سفارشی OpenMetadata برای برچسب‌های dbt", "custom-favicon-url-path-message": "مسیر URL برای آیکون favicon.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json index 301b99520e50..1c95271c777a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json @@ -1488,6 +1488,7 @@ "create-new-glossary-guide": "Um Glossário é um vocabulário controlado usado para definir os conceitos e terminologias em uma organização. Os glossários podem ser específicos para um determinado domínio (por exemplo, Glossário de Negócios, Glossário Técnico). No glossário, os termos e conceitos padrão podem ser definidos juntamente com os sinônimos e termos relacionados. O controle pode ser estabelecido sobre como e quem pode adicionar os termos no glossário.", "create-or-update-email-account-for-bot": "Alterar o e-mail da conta atualizará ou criará um novo usuário bot.", "created-this-task-lowercase": "criou esta tarefa", + "cron-dow-validation-failure": "A parte DOW deve ser >= 0 e <= 6", "cron-less-than-hour-message": "Cron schedule too frequent. Please choose at least 1-hour intervals.", "custom-classification-name-dbt-tags": "Nome de Classificação Personalizada do OpenMetadata para tags dbt", "custom-favicon-url-path-message": "Caminho da URL para o ícone favicon.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json index c27c48832b28..e2c1138a79b2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json @@ -1488,6 +1488,7 @@ "create-new-glossary-guide": "Um Glossário é um vocabulário controlado usado para definir os conceitos e terminologias em uma organização. Os glossários podem ser específicos para um determinado domínio (por exemplo, Glossário de Negócios, Glossário Técnico). No glossário, os termos e conceitos padrão podem ser definidos juntamente com os sinónimos e termos relacionados. O controle pode ser estabelecido sobre como e quem pode adicionar os termos no glossário.", "create-or-update-email-account-for-bot": "Alterar o e-mail da conta atualizará ou criará um novo utilizador bot.", "created-this-task-lowercase": "criou esta tarefa", + "cron-dow-validation-failure": "A parte DOW deve ser >= 0 e <= 6", "cron-less-than-hour-message": "Cron schedule too frequent. Please choose at least 1-hour intervals.", "custom-classification-name-dbt-tags": "Nome de Classificação Personalizada do OpenMetadata para tags dbt", "custom-favicon-url-path-message": "Caminho da URL para o ícone favicon.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json index 31bd5c778b60..19e49bbaaeef 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json @@ -1488,6 +1488,7 @@ "create-new-glossary-guide": "Глоссарий — это контролируемый словарь, используемый для определения понятий и терминологии в организации. Глоссарии могут относиться к определенному домену (например, деловой глоссарий, технический глоссарий). В глоссарии стандартные термины и понятия могут быть определены вместе с синонимами и родственными терминами. Можно установить контроль над тем, как и кто может добавлять термины в глоссарий.", "create-or-update-email-account-for-bot": "Изменение адреса электронной почты учетной записи приведет к обновлению или созданию нового пользователя-бота.", "created-this-task-lowercase": "Задача создана", + "cron-dow-validation-failure": "Часть DOW должна быть >= 0 и <= 6", "cron-less-than-hour-message": "Cron schedule too frequent. Please choose at least 1-hour intervals.", "custom-classification-name-dbt-tags": "Пользовательское имя классификации OpenMetadata для тегов dbt", "custom-favicon-url-path-message": "URL path for the favicon icon.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json index 122c803d577d..4e98227be817 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json @@ -1488,6 +1488,7 @@ "create-new-glossary-guide": "สารานุกรมคือคำศัพท์ที่ควบคุมใช้ในการกำหนดแนวคิดและคำศัพท์ในองค์กร สารานุกรมสามารถเฉพาะเจาะจงได้ตามโดเมนใดโดเมนหนึ่ง (เช่น สารานุกรมธุรกิจ, สารานุกรมเทคนิค) ในสารานุกรม คำศัพท์และแนวคิดมาตรฐานสามารถถูกกำหนดพร้อมกับคำพ้องและคำที่เกี่ยวข้อง ควบคุมสามารถถูกตั้งขึ้นเกี่ยวกับวิธีการและผู้ที่สามารถเพิ่มคำในสารานุกรม", "create-or-update-email-account-for-bot": "การเปลี่ยนแปลงอีเมลบัญชีจะอัปเดตหรือสร้างผู้ใช้บอทใหม่", "created-this-task-lowercase": "สร้างงานนี้", + "cron-dow-validation-failure": "ส่วน DOW ต้องเป็น >= 0 และ <= 6", "cron-less-than-hour-message": "กำหนดการ Cron บ่อยเกินไป กรุณาเลือกช่วงเวลาอย่างน้อย 1 ชั่วโมง", "custom-classification-name-dbt-tags": "ชื่อการจำแนกประเภท OpenMetadata ที่กำหนดเองสำหรับแท็ก dbt", "custom-favicon-url-path-message": "เส้นทาง URL สำหรับไอคอน favicon", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json index 03a0f2cf8e63..2044a39de258 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json @@ -1488,6 +1488,7 @@ "create-new-glossary-guide": "术语库是用于定义组织中的概念和术语的受控词汇集合。术语库可以特定于某个域(例如, 业务术语库, 技术术语库)。在术语库中, 可以定义标准术语、概念及其同义词和相关术语。还可以控制向术语库中添加术语的人员和方式。", "create-or-update-email-account-for-bot": "更改帐号电子邮箱将更新或创建一个新的机器人用户", "created-this-task-lowercase": "创建了此任务", + "cron-dow-validation-failure": "DOW 部分必须 >= 0 且 <= 6", "cron-less-than-hour-message": "Cron schedule too frequent. Please choose at least 1-hour intervals.", "custom-classification-name-dbt-tags": "dbt 标签的自定义 OpenMetadata 分类名称", "custom-favicon-url-path-message": "网站 Favicon 指向的 URL 地址", diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/SchedularUtils.test.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/SchedularUtils.test.tsx index a9c08561ec7e..f82afc505686 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/SchedularUtils.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/SchedularUtils.test.tsx @@ -20,6 +20,8 @@ import { mockOldState1, } from '../mocks/Schedular.mock'; import { + checkDOWValidity, + cronValidator, getCronDefaultValue, getScheduleOptionsFromSchedules, getUpdatedStateFromFormState, @@ -162,3 +164,84 @@ describe('getUpdatedStateFromFormState', () => { }); }); }); + +describe('checkDOWValidity', () => { + it('should not throw an error for valid day of week (0-6)', () => { + expect(() => checkDOWValidity('0')).not.toThrow(); + expect(() => checkDOWValidity('3')).not.toThrow(); + expect(() => checkDOWValidity('6')).not.toThrow(); + }); + + it('should throw an error for invalid day of week is >6)', async () => { + await expect(checkDOWValidity('7')).rejects.toMatch( + 'message.cron-dow-validation-failure' + ); + }); + + it('should not throw an error for valid day of week range (0-6)', () => { + expect(() => checkDOWValidity('0-3')).not.toThrow(); + expect(() => checkDOWValidity('4-6')).not.toThrow(); + }); + + it('should throw an error for invalid day of week range is >6', async () => { + await expect(checkDOWValidity('7-9')).rejects.toMatch( + 'message.cron-dow-validation-failure' + ); + }); + + it('should throw an error for mixed valid and invalid day of week range', async () => { + await expect(checkDOWValidity('0-7')).rejects.toMatch( + 'message.cron-dow-validation-failure' + ); + }); +}); + +describe('cronValidator', () => { + it('should resolve for valid cron expression', async () => { + await expect(cronValidator({}, '0 0 * * *')).resolves.toBeUndefined(); + }); + + it('should reject for cron expression with frequency less than an hour', async () => { + await expect(cronValidator({}, '*/30 * * * *')).rejects.toMatch( + 'message.cron-less-than-hour-message' + ); + }); + + it('should reject for invalid day of week (<0 or >6)', async () => { + await expect(cronValidator({}, '0 0 * * 7')).rejects.toMatch( + 'message.cron-dow-validation-failure' + ); + await expect(cronValidator({}, '0 0 * * -1')).rejects.toMatch( + 'Error: DOW part must be >= 0 and <= 6' + ); + }); + + it('should resolve for valid day of week (0-6)', async () => { + await expect(cronValidator({}, '0 0 * * 0')).resolves.toBeUndefined(); + await expect(cronValidator({}, '0 0 * * 3')).resolves.toBeUndefined(); + await expect(cronValidator({}, '0 0 * * 6')).resolves.toBeUndefined(); + }); + + it('should resolve for valid day of week range (0-6)', async () => { + await expect(cronValidator({}, '0 0 * * 0-3')).resolves.toBeUndefined(); + await expect(cronValidator({}, '0 0 * * 4-6')).resolves.toBeUndefined(); + }); + + it('should reject for invalid day of week range (<0 or >6)', async () => { + await expect(cronValidator({}, '0 0 * * 4-7')).rejects.toMatch( + 'message.cron-dow-validation-failure' + ); + await expect(cronValidator({}, '0 0 * * -1-3')).rejects.toMatch( + 'Error: DOW part must be >= 0 and <= 6' + ); + }); + + it('should reject for mixed valid and invalid day of week range', async () => { + await expect(cronValidator({}, '0 0 * * 0-7')).rejects.toMatch( + 'message.cron-dow-validation-failure' + ); + await expect(cronValidator({}, '0 0 * * -1-6')).rejects.toMatch( + 'Error: DOW part must be >= 0 and <= 6' + ); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/SchedularUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/SchedularUtils.tsx index 8d50f240e86a..563f47c31052 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/SchedularUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/SchedularUtils.tsx @@ -12,7 +12,10 @@ */ import { Select } from 'antd'; +import cronstrue from 'cronstrue/i18n'; +import { t } from 'i18next'; import { isUndefined, toNumber, toString } from 'lodash'; +import { RuleObject } from 'rc-field-form/es/interface'; import React from 'react'; import { Combination, @@ -281,3 +284,49 @@ export const getUpdatedStateFromFormState = ( return { ...currentState, ...formValues }; } }; + +export const checkDOWValidity = async (dow: string) => { + // Check if dow is valid if it is not a number between 0-6 + const isDayValid = toNumber(dow) < 0 || toNumber(dow) > 6; + + // Check if dow is a range and any of the values are not between 0-6 + const isDayRangeValid = + dow.includes('-') && + dow.split('-').some((d) => toNumber(d) < 0 || toNumber(d) > 6); + + // If dow is not valid or dow range is not valid, throw an error + if (isDayValid || isDayRangeValid) { + return Promise.reject(t('message.cron-dow-validation-failure')); + } + + return Promise.resolve(); +}; + +export const cronValidator = async (_: RuleObject, value: string) => { + // Check if cron is valid and get the description + const description = cronstrue.toString(value.trim()); + + // Check if cron has a frequency of less than an hour + const isFrequencyInMinutes = /Every \d* *minute/.test(description); + const isFrequencyInSeconds = /Every \d* *second/.test(description); + + if (isFrequencyInMinutes || isFrequencyInSeconds) { + return Promise.reject(t('message.cron-less-than-hour-message')); + } + + // Check if dow is other than 0-6 + // Adding this manual check since cronstrue accepts 7 as a valid value for dow + // which is not a valid value for argo + const cronParts = value.trim().split(' '); + + // dow is at index 4 if there is no year field or seconds field + let dow = cronParts[4]; + if (cronParts.length !== 5) { + dow = cronParts[5]; + } + + // Check if dow is valid + await checkDOWValidity(dow); + + return Promise.resolve(); +}; diff --git a/openmetadata-ui/src/main/resources/ui/yarn.lock b/openmetadata-ui/src/main/resources/ui/yarn.lock index 3e063ab4db60..7c45f9ea9188 100644 --- a/openmetadata-ui/src/main/resources/ui/yarn.lock +++ b/openmetadata-ui/src/main/resources/ui/yarn.lock @@ -6515,10 +6515,10 @@ crelt@^1.0.0: resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.6.tgz#7cc898ea74e190fb6ef9dae57f8f81cf7302df72" integrity sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g== -cronstrue@^1.122.0: - version "1.122.0" - resolved "https://registry.yarnpkg.com/cronstrue/-/cronstrue-1.122.0.tgz#bd6838077b476d28f61d381398b47b8c3912a126" - integrity sha512-PFuhZd+iPQQ0AWTXIEYX+t3nFGzBrWxmTWUKJOrsGRewaBSLKZ4I1f8s2kryU75nNxgyugZgiGh2OJsCTA/XlA== +cronstrue@^2.53.0: + version "2.53.0" + resolved "https://registry.yarnpkg.com/cronstrue/-/cronstrue-2.53.0.tgz#5bbcd7483636b99379480f624faef5056f3efbd8" + integrity sha512-CkAcaI94xL8h6N7cGxgXfR5D7oV2yVtDzB9vMZP8tIgPyEv/oc/7nq9rlk7LMxvc3N+q6LKZmNLCVxJRpyEg8A== cross-fetch@^3.1.5: version "3.1.5" From 67b79afbe435999d73291a09584a69070af38d50 Mon Sep 17 00:00:00 2001 From: Suman Maharana Date: Mon, 20 Jan 2025 11:58:00 +0530 Subject: [PATCH 135/294] Fix Kafkaconnect validation errors (#19401) (cherry picked from commit 1112de22bc85d0f7f194e8d93cc65160e1261015) --- .../source/pipeline/kafkaconnect/metadata.py | 6 +++--- .../source/pipeline/kafkaconnect/models.py | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/ingestion/src/metadata/ingestion/source/pipeline/kafkaconnect/metadata.py b/ingestion/src/metadata/ingestion/source/pipeline/kafkaconnect/metadata.py index 620f2416033e..ce37ca3f285c 100644 --- a/ingestion/src/metadata/ingestion/source/pipeline/kafkaconnect/metadata.py +++ b/ingestion/src/metadata/ingestion/source/pipeline/kafkaconnect/metadata.py @@ -95,7 +95,7 @@ def yield_pipeline( sourceUrl=connection_url, tasks=[ Task( - name=task.id, + name=str(task.id), ) for task in pipeline_details.tasks or [] ], @@ -205,7 +205,7 @@ def yield_pipeline_lineage_details( metadata=self.metadata, entity_type=Topic, service_name=self.service_connection.messagingServiceName, - topic_name=topic.name, + topic_name=str(topic.name), ) topic_entity = self.metadata.get_by_name(entity=Topic, fqn=topic_fqn) @@ -279,7 +279,7 @@ def yield_pipeline_status( try: task_status = [ TaskStatus( - name=task.id, + name=str(task.id), executionStatus=STATUS_MAP.get(task.state, StatusType.Pending), ) for task in pipeline_details.tasks or [] diff --git a/ingestion/src/metadata/ingestion/source/pipeline/kafkaconnect/models.py b/ingestion/src/metadata/ingestion/source/pipeline/kafkaconnect/models.py index 55109d02d712..9de270f18a2c 100644 --- a/ingestion/src/metadata/ingestion/source/pipeline/kafkaconnect/models.py +++ b/ingestion/src/metadata/ingestion/source/pipeline/kafkaconnect/models.py @@ -27,7 +27,7 @@ class KafkaConnectTasks(BaseModel): default="UNASSIGNED", description="State of the task (e.g., RUNNING, STOPPED)" ) worker_id: Optional[str] = Field( - ..., description="ID of the worker running the task" + default=None, description="ID of the worker running the task" ) @@ -43,15 +43,15 @@ class KafkaConnectPipelineDetails(BaseModel): default="UNASSIGNED", description="State of the connector (e.g., RUNNING, STOPPED)", ) - tasks: Optional[List[KafkaConnectTasks]] - topics: Optional[List[KafkaConnectTopics]] - conn_type: Optional[str] = Field(..., alias="type") + tasks: Optional[List[KafkaConnectTasks]] = [] + topics: Optional[List[KafkaConnectTopics]] = [] + conn_type: Optional[str] = Field(default="UNKNOWN", alias="type") class KafkaConnectDatasetDetails(BaseModel): - table: Optional[str] - database: Optional[str] - container_name: Optional[str] + table: Optional[str] = None + database: Optional[str] = None + container_name: Optional[str] = None @property def dataset_type(self): From 4be638c996099c21ef9c6c464472fb64031ce1fa Mon Sep 17 00:00:00 2001 From: Shailesh Parmar Date: Wed, 15 Jan 2025 14:56:40 +0530 Subject: [PATCH 136/294] playwright: fixed failing incident manager test (#19367) --- .../e2e/Features/IncidentManager.spec.ts | 7 +++++-- .../entity/ingestion/MySqlIngestionClass.ts | 7 +++++-- .../src/main/resources/ui/playwright/utils/bot.ts | 14 ++++++++++---- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/IncidentManager.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/IncidentManager.spec.ts index 4e3dbd74f1a3..b00da9cc961d 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/IncidentManager.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/IncidentManager.spec.ts @@ -15,7 +15,7 @@ import { PLAYWRIGHT_INGESTION_TAG_OBJ } from '../../constant/config'; import { SidebarItem } from '../../constant/sidebar'; import { TableClass } from '../../support/entity/TableClass'; import { UserClass } from '../../support/user/UserClass'; -import { resetTokenFromProfilerBotPage } from '../../utils/bot'; +import { resetTokenFromBotPage } from '../../utils/bot'; import { createNewPage, descriptionBox, @@ -49,7 +49,10 @@ test.describe('Incident Manager', PLAYWRIGHT_INGESTION_TAG_OBJ, () => { const { afterAction, apiContext, page } = await createNewPage(browser); // Todo: Remove this patch once the issue is fixed #19140 - await resetTokenFromProfilerBotPage(page); + await resetTokenFromBotPage(page, { + name: 'testsuite', + testId: 'bot-link-TestSuiteBot', + }); for (const user of users) { await user.create(apiContext); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ingestion/MySqlIngestionClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ingestion/MySqlIngestionClass.ts index d4045bdc5812..2aeb33b059e4 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ingestion/MySqlIngestionClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ingestion/MySqlIngestionClass.ts @@ -18,7 +18,7 @@ import { TestType, } from '@playwright/test'; import { env } from 'process'; -import { resetTokenFromProfilerBotPage } from '../../../utils/bot'; +import { resetTokenFromBotPage } from '../../../utils/bot'; import { getApiContext, redirectToHomePage, @@ -87,7 +87,10 @@ class MysqlIngestionClass extends ServiceBaseClass { await redirectToHomePage(page); // Todo: Remove this patch once the issue is fixed #19140 - await resetTokenFromProfilerBotPage(page); + await resetTokenFromBotPage(page, { + name: 'profiler', + testId: 'bot-link-ProfilerBot', + }); await visitServiceDetailsPage( page, diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/bot.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/bot.ts index 39a4a813e893..0467fb53e188 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/bot.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/bot.ts @@ -222,15 +222,21 @@ export const redirectToBotPage = async (page: Page) => { await fetchResponse; }; -export const resetTokenFromProfilerBotPage = async (page: Page) => { +export const resetTokenFromBotPage = async ( + page: Page, + bot: { + name: string; + testId: string; + } +) => { await settingClick(page, GlobalSettingOptions.BOTS); await page.getByTestId('searchbar').click(); - await page.getByTestId('searchbar').fill('profiler'); + await page.getByTestId('searchbar').fill(bot.name); - await expect(page.getByTestId('bot-link-ProfilerBot')).toBeVisible(); + await expect(page.getByTestId(bot.testId)).toBeVisible(); - await page.getByTestId('bot-link-ProfilerBot').click(); + await page.getByTestId(bot.testId).click(); await expect(page.getByTestId('revoke-button')).toBeVisible(); From aeef12352ce4ba42a9756eba4ab1c3f8fc0a483d Mon Sep 17 00:00:00 2001 From: Keshav Mohta <68001229+keshavmohta09@users.noreply.github.com> Date: Tue, 14 Jan 2025 16:58:25 +0530 Subject: [PATCH 137/294] Fixes #18570 - Redash Get Dashboards (#19331) * fix: get dashboard by id * fix: function param name id->dashboard_id | python checkstyle * refactor: used api version and rename get dashboard function * fix: used clean uri --- .../source/dashboard/redash/client.py | 18 ++++++-------- .../source/dashboard/redash/metadata.py | 24 +++++++++++-------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/ingestion/src/metadata/ingestion/source/dashboard/redash/client.py b/ingestion/src/metadata/ingestion/source/dashboard/redash/client.py index 30cc9e9cd6c8..40a0e925aade 100644 --- a/ingestion/src/metadata/ingestion/source/dashboard/redash/client.py +++ b/ingestion/src/metadata/ingestion/source/dashboard/redash/client.py @@ -13,6 +13,7 @@ """ from metadata.ingestion.ometa.client import REST, ClientConfig +from metadata.utils.helpers import clean_uri from metadata.utils.logger import utils_logger logger = utils_logger() @@ -28,8 +29,8 @@ class RedashApiClient: def __init__(self, config): self.config = config client_config = ClientConfig( - base_url=str(config.hostPort), - api_version="", + base_url=clean_uri(config.hostPort), + api_version="api", access_token=config.apiKey.get_secret_value(), auth_header="Authorization", auth_token_mode="Key", @@ -41,16 +42,11 @@ def dashboards(self, page=1, page_size=25): """GET api/dashboards""" params_data = {"page": page, "page_size": page_size} - return self.client.get(path="api/dashboards", data=params_data) + return self.client.get(path="/dashboards", data=params_data) - def get_dashboard(self, slug): - """GET api/dashboards/""" - - # The API changed from redash v9 onwards - # legacy=true allows us to get the results in the old way - return self.client.get( - f"api/dashboards/{slug}?legacy=true", - ) + def get_dashboard(self, dashboard_id: int): + """GET api/dashboards/""" + return self.client.get(f"/dashboards/{dashboard_id}") def paginate(self, resource, page=1, page_size=25, **kwargs): """Load all items of a paginated resource""" diff --git a/ingestion/src/metadata/ingestion/source/dashboard/redash/metadata.py b/ingestion/src/metadata/ingestion/source/dashboard/redash/metadata.py index 94ae4a0cb7dc..1067e1afcc76 100644 --- a/ingestion/src/metadata/ingestion/source/dashboard/redash/metadata.py +++ b/ingestion/src/metadata/ingestion/source/dashboard/redash/metadata.py @@ -115,7 +115,7 @@ def get_dashboard_name(self, dashboard: dict) -> str: return dashboard["name"] def get_dashboard_details(self, dashboard: dict) -> dict: - return self.client.get_dashboard(dashboard["slug"]) + return self.client.get_dashboard(dashboard["id"]) def get_owner_ref(self, dashboard_details) -> Optional[EntityReferenceList]: """ @@ -160,9 +160,9 @@ def yield_dashboard( dashboard_request = CreateDashboardRequest( name=EntityName(str(dashboard_details["id"])), displayName=dashboard_details.get("name"), - description=Markdown(dashboard_description) - if dashboard_description - else None, + description=( + Markdown(dashboard_description) if dashboard_description else None + ), charts=[ FullyQualifiedEntityName( fqn.build( @@ -275,9 +275,11 @@ def yield_dashboard_chart( yield Either( right=CreateChartRequest( name=EntityName(str(widgets["id"])), - displayName=chart_display_name - if visualization and visualization["query"] - else "", + displayName=( + chart_display_name + if visualization and visualization["query"] + else "" + ), chartType=get_standard_chart_type( visualization["type"] if visualization else "" ), @@ -285,9 +287,11 @@ def yield_dashboard_chart( self.context.get().dashboard_service ), sourceUrl=SourceUrl(self.get_dashboard_url(dashboard_details)), - description=Markdown(visualization["description"]) - if visualization - else None, + description=( + Markdown(visualization["description"]) + if visualization + else None + ), ) ) except Exception as exc: From 6fc6d347388b314a43f8749a0fbc26584eea710e Mon Sep 17 00:00:00 2001 From: Ashish Gupta Date: Mon, 20 Jan 2025 22:45:17 +0530 Subject: [PATCH 138/294] #19406: supported the task filter on landing page widget (#19431) * supported the task filter on landing page widget * added playwright for the test (cherry picked from commit 57ed033703f19d7da5844ab0b6b9f72f7541ae87) --- .../e2e/Features/ActivityFeed.spec.ts | 94 +++++++++++++++++++ .../FeedsWidget/FeedsWidget.component.tsx | 19 ++-- .../Widgets/FeedsWidget/FeedsWidget.test.tsx | 63 ++++++++++++- .../FeedsFilterPopover.component.tsx | 24 +---- .../FeedsFilterPopover.interface.ts | 2 + .../FeedsFilterPopover.test.tsx | 2 + .../ui/src/constants/Widgets.constant.ts | 51 ++++++++++ .../LandingPageWidget/WidgetsUtils.test.tsx | 56 +++++++++++ .../utils/LandingPageWidget/WidgetsUtils.ts | 35 +++++++ 9 files changed, 317 insertions(+), 29 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/constants/Widgets.constant.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/LandingPageWidget/WidgetsUtils.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/LandingPageWidget/WidgetsUtils.ts 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 954bd2c3f272..121adeadeec6 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 @@ -590,6 +590,100 @@ test.describe('Activity feed', () => { } ); }); + + test('Check Task Filter in Landing Page Widget', async ({ browser }) => { + const { page: page1, afterAction: afterActionUser1 } = + await performUserLogin(browser, user1); + const { page: page2, afterAction: afterActionUser2 } = + await performUserLogin(browser, user2); + + await base.step('Create and Assign Task to User 2', async () => { + await redirectToHomePage(page1); + await entity.visitEntityPage(page1); + + // Create task for the user 2 + await page1.getByTestId('request-description').click(); + await createDescriptionTask(page1, { + term: entity.entity.displayName, + assignee: user2.responseData.name, + }); + + await afterActionUser1(); + }); + + await base.step('Create and Validate Task as per Filters', async () => { + await redirectToHomePage(page2); + await entity.visitEntityPage(page2); + + // Create task for the user 1 + await page2.getByTestId('request-entity-tags').click(); + await createTagTask(page2, { + term: entity.entity.displayName, + tag: 'PII.None', + assignee: user1.responseData.name, + }); + + await redirectToHomePage(page2); + const taskResponse = page2.waitForResponse( + '/api/v1/feed?type=Task&filterType=OWNER&taskStatus=Open&userId=*' + ); + + await page2 + .getByTestId('activity-feed-widget') + .getByText('Tasks') + .click(); + + await taskResponse; + + await expect( + page2.locator( + '[data-testid="activity-feed-widget"] [data-testid="no-data-placeholder"]' + ) + ).not.toBeVisible(); + + // Check the Task based on ALL task filter + await expect(page2.getByTestId('message-container')).toHaveCount(2); + + // Check the Task based on Assigned task filter + await page2.getByTestId('filter-button').click(); + await page2.waitForSelector('.ant-popover ', { state: 'visible' }); + + const taskAssignedResponse = page2.waitForResponse( + '/api/v1/feed?type=Task&filterType=ASSIGNED_TO&taskStatus=Open&userId=*' + ); + await page2.getByText('Assigned').click(); + await page2.getByTestId('selectable-list-update-btn').click(); + + await taskAssignedResponse; + + await expect(page2.getByTestId('message-container')).toHaveCount(1); + + await expect(page2.getByTestId('owner-link')).toContainText( + user2.responseData.displayName + ); + + // Check the Task based on Created by me task filter + + await page2.getByTestId('filter-button').click(); + await page2.waitForSelector('.ant-popover ', { state: 'visible' }); + + const taskCreatedByResponse = page2.waitForResponse( + '/api/v1/feed?type=Task&filterType=ASSIGNED_BY&taskStatus=Open&userId=*' + ); + await page2.getByText('Created By').click(); + await page2.getByTestId('selectable-list-update-btn').click(); + + await taskCreatedByResponse; + + await expect(page2.getByTestId('message-container')).toHaveCount(1); + + await expect(page2.getByTestId('owner-link')).toContainText( + user1.responseData.displayName + ); + + await afterActionUser2(); + }); + }); }); base.describe('Activity feed with Data Consumer User', () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/Widgets/FeedsWidget/FeedsWidget.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MyData/Widgets/FeedsWidget/FeedsWidget.component.tsx index b7ea93167d02..43cdd5660d7c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/Widgets/FeedsWidget/FeedsWidget.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/MyData/Widgets/FeedsWidget/FeedsWidget.component.tsx @@ -20,6 +20,7 @@ import { Link, useHistory } from 'react-router-dom'; import { PAGE_SIZE_MEDIUM, ROUTES } from '../../../../constants/constants'; import { FEED_COUNT_INITIAL_DATA } from '../../../../constants/entity.constants'; import { mockFeedData } from '../../../../constants/mockTourData.constants'; +import { TAB_SUPPORTED_FILTER } from '../../../../constants/Widgets.constant'; import { useTourProvider } from '../../../../context/TourProvider/TourProvider'; import { EntityTabs, EntityType } from '../../../../enums/entity.enum'; import { FeedFilter } from '../../../../enums/mydata.enum'; @@ -76,7 +77,7 @@ const FeedsWidget = ({ getFeedData(FeedFilter.MENTIONS); } else if (activeTab === ActivityFeedTabs.TASKS) { getFeedData( - FeedFilter.OWNER, + defaultFilter, undefined, ThreadType.Task, undefined, @@ -106,7 +107,12 @@ const FeedsWidget = ({ [count.openTaskCount, activeTab] ); - const onTabChange = (key: string) => setActiveTab(key as ActivityFeedTabs); + const onTabChange = (key: string) => { + if (key === ActivityFeedTabs.TASKS) { + setDefaultFilter(FeedFilter.OWNER); + } + setActiveTab(key as ActivityFeedTabs); + }; const redirectToUserPage = useCallback(() => { history.push( @@ -259,13 +265,10 @@ const FeedsWidget = ({ ]} tabBarExtraContent={ - {activeTab === ActivityFeedTabs.ALL && ( + {TAB_SUPPORTED_FILTER.includes(activeTab) && ( )} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/Widgets/FeedsWidget/FeedsWidget.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MyData/Widgets/FeedsWidget/FeedsWidget.test.tsx index 229585548936..4ad32a7a273c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/Widgets/FeedsWidget/FeedsWidget.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/MyData/Widgets/FeedsWidget/FeedsWidget.test.tsx @@ -82,7 +82,16 @@ jest.mock( jest.mock( '../../../common/FeedsFilterPopover/FeedsFilterPopover.component', - () => jest.fn().mockImplementation(({ children }) =>

{children}

) + () => + jest.fn().mockImplementation(({ onUpdate }) => ( +
+ + + +
+ )) ); jest.mock('../../../../rest/feedsAPI', () => ({ @@ -193,4 +202,56 @@ describe('FeedsWidget', () => { expect(mockHandleRemoveWidget).toHaveBeenCalledWith(widgetProps.widgetKey); }); + + it('should call api with correct parameters based on the tab selected', () => { + render(); + const tabs = screen.getAllByRole('tab'); + const conversationTab = tabs[0]; + fireEvent.click(conversationTab); + + // initial API call for the Feed + expect(conversationTab.getAttribute('aria-selected')).toBe('true'); + expect(mockUseActivityFeedProviderValue.getFeedData).toHaveBeenCalledWith( + 'OWNER_OR_FOLLOWS', + undefined, + 'Conversation', + undefined, + undefined, + undefined, + 25 + ); + + // Reset mock between checks + mockUseActivityFeedProviderValue.getFeedData.mockReset(); + + // Testing for "Task Tab", to call API with OWNER filter parameters + const taskTab = tabs[2]; + fireEvent.click(taskTab); + + expect(taskTab.getAttribute('aria-selected')).toBe('true'); + expect(mockUseActivityFeedProviderValue.getFeedData).toHaveBeenCalledWith( + 'OWNER', + undefined, + 'Task', + undefined, + undefined, + 'Open' + ); + + // Reset mock for the next check + mockUseActivityFeedProviderValue.getFeedData.mockReset(); + + // Applying the filter for the assigned button + const assignedFilterButton = screen.getByText('assigned_button'); + fireEvent.click(assignedFilterButton); + + expect(mockUseActivityFeedProviderValue.getFeedData).toHaveBeenCalledWith( + 'ASSIGNED_TO', + undefined, + 'Task', + undefined, + undefined, + 'Open' + ); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/FeedsFilterPopover/FeedsFilterPopover.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/FeedsFilterPopover/FeedsFilterPopover.component.tsx index f30be520f933..1e01120c2a9e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/FeedsFilterPopover/FeedsFilterPopover.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/FeedsFilterPopover/FeedsFilterPopover.component.tsx @@ -25,10 +25,12 @@ import { ReactComponent as FilterIcon } from '../../../assets/svg/ic-feeds-filte import { FeedFilter } from '../../../enums/mydata.enum'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; +import { getFeedFilterWidgets } from '../../../utils/LandingPageWidget/WidgetsUtils'; import './feeds-filter-popover.less'; import { FeedsFilterPopoverProps } from './FeedsFilterPopover.interface'; const FeedsFilterPopover = ({ + feedTab, defaultFilter, onUpdate, }: FeedsFilterPopoverProps) => { @@ -39,26 +41,8 @@ const FeedsFilterPopover = ({ useState(defaultFilter); const items = useMemo( - () => [ - { - title: t('label.all'), - key: currentUser?.isAdmin - ? FeedFilter.ALL - : FeedFilter.OWNER_OR_FOLLOWS, - description: t('message.feed-filter-all'), - }, - { - title: t('label.my-data'), - key: FeedFilter.OWNER, - description: t('message.feed-filter-owner'), - }, - { - title: t('label.following'), - key: FeedFilter.FOLLOWS, - description: t('message.feed-filter-following'), - }, - ], - [currentUser] + () => getFeedFilterWidgets(feedTab, currentUser?.isAdmin), + [currentUser?.isAdmin, feedTab] ); const onFilterUpdate = useCallback(() => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/FeedsFilterPopover/FeedsFilterPopover.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/common/FeedsFilterPopover/FeedsFilterPopover.interface.ts index bb1d41f156f0..66690980d7ce 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/FeedsFilterPopover/FeedsFilterPopover.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/FeedsFilterPopover/FeedsFilterPopover.interface.ts @@ -11,8 +11,10 @@ * limitations under the License. */ import { FeedFilter } from '../../../enums/mydata.enum'; +import { ActivityFeedTabs } from '../../ActivityFeed/ActivityFeedTab/ActivityFeedTab.interface'; export interface FeedsFilterPopoverProps { + feedTab: ActivityFeedTabs; defaultFilter: FeedFilter; onUpdate: (value: FeedFilter) => void; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/FeedsFilterPopover/FeedsFilterPopover.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/FeedsFilterPopover/FeedsFilterPopover.test.tsx index 77c737e766f1..98de8a954e73 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/FeedsFilterPopover/FeedsFilterPopover.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/FeedsFilterPopover/FeedsFilterPopover.test.tsx @@ -13,10 +13,12 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; import { FeedFilter } from '../../../enums/mydata.enum'; +import { ActivityFeedTabs } from '../../ActivityFeed/ActivityFeedTab/ActivityFeedTab.interface'; import FeedsFilterPopover from './FeedsFilterPopover.component'; const onUpdateMock = jest.fn(); const mockProps = { + feedTab: ActivityFeedTabs.ALL, defaultFilter: FeedFilter.ALL, onUpdate: onUpdateMock, }; diff --git a/openmetadata-ui/src/main/resources/ui/src/constants/Widgets.constant.ts b/openmetadata-ui/src/main/resources/ui/src/constants/Widgets.constant.ts new file mode 100644 index 000000000000..74ce1b1ff417 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/constants/Widgets.constant.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2025 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 { ActivityFeedTabs } from '../components/ActivityFeed/ActivityFeedTab/ActivityFeedTab.interface'; +import { FeedFilter } from '../enums/mydata.enum'; +import i18n from '../utils/i18next/LocalUtil'; + +export const TAB_SUPPORTED_FILTER = [ + ActivityFeedTabs.ALL, + ActivityFeedTabs.TASKS, +]; + +export const TASK_FEED_FILTER_LIST = [ + { + title: i18n.t('label.all'), + key: FeedFilter.OWNER, + description: i18n.t('message.feed-filter-all'), + }, + { + title: i18n.t('label.assigned'), + key: FeedFilter.ASSIGNED_TO, + description: i18n.t('message.feed-filter-owner'), + }, + { + title: i18n.t('label.created-by'), + key: FeedFilter.ASSIGNED_BY, + description: i18n.t('message.feed-filter-following'), + }, +]; + +export const ACTIVITY_FEED_FILTER_LIST = [ + { + title: i18n.t('label.my-data'), + key: FeedFilter.OWNER, + description: i18n.t('message.feed-filter-owner'), + }, + { + title: i18n.t('label.following'), + key: FeedFilter.FOLLOWS, + description: i18n.t('message.feed-filter-following'), + }, +]; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/LandingPageWidget/WidgetsUtils.test.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/LandingPageWidget/WidgetsUtils.test.tsx new file mode 100644 index 000000000000..5c395673af6f --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/LandingPageWidget/WidgetsUtils.test.tsx @@ -0,0 +1,56 @@ +/* + * Copyright 2025 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 { ActivityFeedTabs } from '../../components/ActivityFeed/ActivityFeedTab/ActivityFeedTab.interface'; +import { + ACTIVITY_FEED_FILTER_LIST, + TASK_FEED_FILTER_LIST, +} from '../../constants/Widgets.constant'; +import { FeedFilter } from '../../enums/mydata.enum'; +import i18n from '../i18next/LocalUtil'; +import { getFeedFilterWidgets } from './WidgetsUtils'; + +describe('Widgets Utils', () => { + describe('getFeedFilterWidgets', () => { + it('should return list for Feed Filters', () => { + const response = getFeedFilterWidgets(ActivityFeedTabs.ALL); + + expect(response).toEqual([ + { + title: i18n.t('label.all'), + key: FeedFilter.OWNER_OR_FOLLOWS, + description: i18n.t('message.feed-filter-all'), + }, + ...ACTIVITY_FEED_FILTER_LIST, + ]); + }); + + it('should return list for Feed Filters for Admin', () => { + const response = getFeedFilterWidgets(ActivityFeedTabs.ALL, true); + + expect(response).toEqual([ + { + title: i18n.t('label.all'), + key: FeedFilter.ALL, + description: i18n.t('message.feed-filter-all'), + }, + ...ACTIVITY_FEED_FILTER_LIST, + ]); + }); + + it('should return list for Task Filters', () => { + const response = getFeedFilterWidgets(ActivityFeedTabs.TASKS); + + expect(response).toEqual(TASK_FEED_FILTER_LIST); + }); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/LandingPageWidget/WidgetsUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/LandingPageWidget/WidgetsUtils.ts new file mode 100644 index 000000000000..d8131847369b --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/LandingPageWidget/WidgetsUtils.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2025 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 { ActivityFeedTabs } from '../../components/ActivityFeed/ActivityFeedTab/ActivityFeedTab.interface'; +import { + ACTIVITY_FEED_FILTER_LIST, + TASK_FEED_FILTER_LIST, +} from '../../constants/Widgets.constant'; +import { FeedFilter } from '../../enums/mydata.enum'; +import i18n from '../i18next/LocalUtil'; + +export const getFeedFilterWidgets = ( + tab: ActivityFeedTabs, + isAdmin?: boolean +) => { + return tab === ActivityFeedTabs.TASKS + ? TASK_FEED_FILTER_LIST + : [ + { + title: i18n.t('label.all'), + key: isAdmin ? FeedFilter.ALL : FeedFilter.OWNER_OR_FOLLOWS, + description: i18n.t('message.feed-filter-all'), + }, + ...ACTIVITY_FEED_FILTER_LIST, + ]; +}; From be77f1017fd15012d4761be9e2cc12a321650dd7 Mon Sep 17 00:00:00 2001 From: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> Date: Tue, 21 Jan 2025 11:42:38 +0530 Subject: [PATCH 139/294] fix(test): rest api service deletion taking longer (#19439) * fix(test): rest api service deletion taking longer * fix entity name with schema (cherry picked from commit 58b1ef1c039cd0c7c596f9d67417b8f3527d82d0) --- .../playwright/support/entity/ingestion/ApiIngestionClass.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ingestion/ApiIngestionClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ingestion/ApiIngestionClass.ts index 36565e388f17..a21a8106cfff 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ingestion/ApiIngestionClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ingestion/ApiIngestionClass.ts @@ -21,7 +21,7 @@ import ServiceBaseClass from './ServiceBaseClass'; class ApiIngestionClass extends ServiceBaseClass { constructor() { - super(Services.API, `pw-api-with-%-${uuid()}`, 'Rest', 'Containers'); + super(Services.API, `pw-api-with-%-${uuid()}`, 'Rest', 'store'); } async createService(page: Page) { @@ -33,7 +33,7 @@ class ApiIngestionClass extends ServiceBaseClass { } async fillConnectionDetails(page: Page) { - const openAPISchemaURL = 'https://docs.open-metadata.org/swagger.json'; + const openAPISchemaURL = 'https://petstore3.swagger.io/api/v3/openapi.json'; await page.locator('#root\\/openAPISchemaURL').fill(openAPISchemaURL); await checkServiceFieldSectionHighlighting(page, 'openAPISchemaURL'); From 9de8bcb31c5ba753ce25330930914a7bf083a651 Mon Sep 17 00:00:00 2001 From: Ashish Gupta Date: Tue, 21 Jan 2025 12:42:23 +0530 Subject: [PATCH 140/294] fix the description not rendered in Glossary Modal while edit (#19453) (cherry picked from commit 5e7847e2f5d1a2168ea08189d2253d34232992c9) --- .../ui/playwright/e2e/Pages/Glossary.spec.ts | 12 ++++++++- .../resources/ui/playwright/utils/glossary.ts | 26 +++++++++++++++++++ .../AddGlossaryTermForm.component.tsx | 2 +- 3 files changed, 38 insertions(+), 2 deletions(-) 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 181601b7fd7d..07871962aa48 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 @@ -43,6 +43,7 @@ import { approveTagsTask, assignTagToGlossaryTerm, changeTermHierarchyFromModal, + checkGlossaryTermDetails, clickSaveButton, confirmationDragAndDropGlossary, createDescriptionTaskForGlossary, @@ -302,7 +303,9 @@ test.describe('Glossary tests', () => { } }); - test('Add and Update Glossary Term', async ({ browser }) => { + test('Add, Update and Verify Data Glossary Term', async ({ browser }) => { + test.slow(true); + const { page, afterAction, apiContext } = await performAdminLogin(browser); const glossary1 = new Glossary(); const glossaryTerm1 = new GlossaryTerm(glossary1); @@ -356,6 +359,13 @@ test.describe('Glossary tests', () => { const ownerText = await termRow.locator(ownerSelector).textContent(); expect(ownerText).toBe(`${owner1.data.firstName}${owner1.data.lastName}`); + + await checkGlossaryTermDetails( + page, + glossaryTerm1.data, + owner1, + reviewer1 + ); } finally { await glossaryTerm1.delete(apiContext); await glossary1.delete(apiContext); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts index 339fadbd240f..c06c7b2eddf2 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts @@ -25,6 +25,7 @@ import { UserTeamRef, } from '../support/glossary/Glossary.interface'; import { GlossaryTerm } from '../support/glossary/GlossaryTerm'; +import { UserClass } from '../support/user/UserClass'; import { clickOutside, closeFirstPopupAlert, @@ -1418,3 +1419,28 @@ export const updateGlossaryTermReviewers = async ( await page.getByTestId('save-glossary-term').click(); await glossaryTermResponse; }; + +export const checkGlossaryTermDetails = async ( + page: Page, + term: GlossaryTermData, + owner: UserClass, + reviewer: UserClass +) => { + await openEditGlossaryTermModal(page, term); + + await expect(page.locator('[data-testid="name"]')).toHaveValue(term.name); + await expect(page.locator('[data-testid="display-name"]')).toHaveValue( + term.displayName + ); + await expect(page.getByTestId('editor')).toContainText(term.description); + + await expect( + page.locator('[data-testid="owner-container"] [data-testid="owner-link"]') + ).toContainText(owner.responseData.displayName); + + await expect( + page.locator( + '[data-testid="reviewers-container"] [data-testid="owner-link"]' + ) + ).toContainText(reviewer.responseData.displayName); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/AddGlossaryTermForm/AddGlossaryTermForm.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/AddGlossaryTermForm/AddGlossaryTermForm.component.tsx index 958dba68ee54..20950863841c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/AddGlossaryTermForm/AddGlossaryTermForm.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/AddGlossaryTermForm/AddGlossaryTermForm.component.tsx @@ -201,7 +201,7 @@ const AddGlossaryTermForm = ({ type: FieldTypes.DESCRIPTION, props: { 'data-testid': 'description', - initialValue: '', + initialValue: glossaryTerm?.description, height: 'auto', }, rules: [ From 0577e139f2fa4b7b1872f98f7139261450eb75ec Mon Sep 17 00:00:00 2001 From: sonika-shah <58761340+sonika-shah@users.noreply.github.com> Date: Tue, 21 Jan 2025 14:58:04 +0530 Subject: [PATCH 141/294] Minor : Get missing dataProducts and pipeline properties in /customProperties api (#19445) (cherry picked from commit 9a69c18b594de5dabd956904038f02b0dc96aa62) --- .../org/openmetadata/service/util/SchemaFieldExtractor.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/util/SchemaFieldExtractor.java b/openmetadata-service/src/main/java/org/openmetadata/service/util/SchemaFieldExtractor.java index dfbb05a8e33f..9186d7721ad2 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/util/SchemaFieldExtractor.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/util/SchemaFieldExtractor.java @@ -596,8 +596,9 @@ private static String getEntitySubdirectory(String entityType) { Map.of( "dashboard", "data", "table", "data", - "pipeline", "services", - "votes", "data"); + "pipeline", "data", + "votes", "data", + "dataProduct", "domains"); return entityTypeToSubdirectory.getOrDefault(entityType, "data"); } From eef769fb4760d4c87579044f78122af6b1500121 Mon Sep 17 00:00:00 2001 From: Siddhant <86899184+Siddhanttimeline@users.noreply.github.com> Date: Tue, 21 Jan 2025 18:12:35 +0530 Subject: [PATCH 142/294] #15600: Retention Application : Delete change_events, activity threads, versions based on admin retention policies (#18913) * feat(retention-policy): initial setup of the Retention Policy app * refactor: Update logic to make RetentionPolicyApp a ScheduledOrManual app * refactor: Update retention policy JSON schema and logic to support event subscription, versions, and activity thread with individual retention periods * refactor: Rename eventSubscriptionRetentionPeriod to changeEventRetentionPeriod * fix: Implement activity thread retention policy cleanup * chore: Remove activity thread and versions retention code; add banner and icon (cherry picked from commit 486230827dfa955a4dc77d3624aa7efb8d557137) --- .../bundles/dataRetention/DataRetention.java | 100 ++++++++++++++++++ .../EventSubscriptionCleanupJob.java | 41 ------- .../scheduled/EventSubscriptionScheduler.java | 29 ----- .../service/jdbi3/CollectionDAO.java | 51 +++++++++ .../data/app/DataRetentionApplication.json | 11 ++ .../DataRetentionApplication.json | 18 ++++ .../configuration/applicationConfig.json | 3 + .../internal/dataRetentionConfiguration.json | 19 ++++ .../DataRetentionApplication.png | Bin 0 -> 754719 bytes .../assets/svg/DataRetentionApplication.svg | 56 ++++++++++ .../internal/dataRetentionConfiguration.ts | 19 ++++ .../internal/retentionPolicyConfiguration.ts | 28 +++++ .../DataRetentionApplication.json | 18 ++++ 13 files changed, 323 insertions(+), 70 deletions(-) create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/dataRetention/DataRetention.java delete mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/events/scheduled/EventSubscriptionCleanupJob.java create mode 100644 openmetadata-service/src/main/resources/json/data/app/DataRetentionApplication.json create mode 100644 openmetadata-service/src/main/resources/json/data/appMarketPlaceDefinition/DataRetentionApplication.json create mode 100644 openmetadata-spec/src/main/resources/json/schema/entity/applications/configuration/internal/dataRetentionConfiguration.json create mode 100644 openmetadata-ui/src/main/resources/ui/src/assets/img/appScreenshots/DataRetentionApplication.png create mode 100644 openmetadata-ui/src/main/resources/ui/src/assets/svg/DataRetentionApplication.svg create mode 100644 openmetadata-ui/src/main/resources/ui/src/generated/entity/applications/configuration/internal/dataRetentionConfiguration.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/generated/entity/applications/configuration/internal/retentionPolicyConfiguration.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/ApplicationSchemas/DataRetentionApplication.json diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/dataRetention/DataRetention.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/dataRetention/DataRetention.java new file mode 100644 index 000000000000..214807bd10ba --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/dataRetention/DataRetention.java @@ -0,0 +1,100 @@ +package org.openmetadata.service.apps.bundles.dataRetention; + +import java.time.Duration; +import java.time.Instant; +import java.util.function.Supplier; +import lombok.extern.slf4j.Slf4j; +import org.jdbi.v3.sqlobject.transaction.Transaction; +import org.openmetadata.common.utils.CommonUtil; +import org.openmetadata.schema.entity.app.App; +import org.openmetadata.schema.entity.applications.configuration.internal.DataRetentionConfiguration; +import org.openmetadata.service.apps.AbstractNativeApplication; +import org.openmetadata.service.jdbi3.CollectionDAO; +import org.openmetadata.service.search.SearchRepository; +import org.openmetadata.service.util.JsonUtils; +import org.quartz.JobExecutionContext; + +@Slf4j +public class DataRetention extends AbstractNativeApplication { + private static final int BATCH_SIZE = 10_000; + private DataRetentionConfiguration dataRetentionConfiguration; + private final CollectionDAO.EventSubscriptionDAO eventSubscriptionDAO; + + public DataRetention(CollectionDAO collectionDAO, SearchRepository searchRepository) { + super(collectionDAO, searchRepository); + this.eventSubscriptionDAO = collectionDAO.eventSubscriptionDAO(); + } + + @Override + public void init(App app) { + super.init(app); + this.dataRetentionConfiguration = + JsonUtils.convertValue(app.getAppConfiguration(), DataRetentionConfiguration.class); + if (CommonUtil.nullOrEmpty(this.dataRetentionConfiguration)) { + LOG.warn("No retention policy configuration provided. Cleanup tasks will not run."); + } + } + + @Override + public void startApp(JobExecutionContext jobExecutionContext) { + executeCleanup(dataRetentionConfiguration); + } + + public void executeCleanup(DataRetentionConfiguration config) { + if (CommonUtil.nullOrEmpty(config)) { + return; + } + + cleanChangeEvents(config.getChangeEventRetentionPeriod()); + } + + @Transaction + private void cleanChangeEvents(int retentionPeriod) { + LOG.info( + "Initiating change events cleanup: Deleting records with a retention period of {} days.", + retentionPeriod); + long cutoffMillis = getRetentionCutoffMillis(retentionPeriod); + + int totalDeletedSuccessfulEvents = + batchDelete( + () -> + eventSubscriptionDAO.deleteSuccessfulSentChangeEventsInBatches( + cutoffMillis, BATCH_SIZE)); + + int totalDeletedChangeEvents = + batchDelete( + () -> eventSubscriptionDAO.deleteChangeEventsInBatches(cutoffMillis, BATCH_SIZE)); + + int totalDeletedDlq = + batchDelete( + () -> eventSubscriptionDAO.deleteConsumersDlqInBatches(cutoffMillis, BATCH_SIZE)); + + LOG.info( + "Change events cleanup completed: {} successful_sent_change_events, {} change_events, and {} consumers_dlq records deleted (retention period: {} days).", + totalDeletedSuccessfulEvents, + totalDeletedChangeEvents, + totalDeletedDlq, + retentionPeriod); + } + + private long getRetentionCutoffMillis(int retentionPeriodInDays) { + return Instant.now() + .minusMillis(Duration.ofDays(retentionPeriodInDays).toMillis()) + .toEpochMilli(); + } + + /** + * Runs a batch delete operation in a loop until fewer than BATCH_SIZE records are deleted in a single iteration. + */ + private int batchDelete(Supplier deleteFunction) { + var totalDeleted = 0; + while (true) { + var deletedCount = deleteFunction.get(); + totalDeleted += deletedCount; + if (deletedCount < BATCH_SIZE) { + break; + } + } + return totalDeleted; + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/events/scheduled/EventSubscriptionCleanupJob.java b/openmetadata-service/src/main/java/org/openmetadata/service/events/scheduled/EventSubscriptionCleanupJob.java deleted file mode 100644 index f0eb43ab9cc6..000000000000 --- a/openmetadata-service/src/main/java/org/openmetadata/service/events/scheduled/EventSubscriptionCleanupJob.java +++ /dev/null @@ -1,41 +0,0 @@ -package org.openmetadata.service.events.scheduled; - -import java.util.List; -import lombok.extern.slf4j.Slf4j; -import org.jdbi.v3.sqlobject.transaction.Transaction; -import org.openmetadata.service.Entity; -import org.quartz.Job; -import org.quartz.JobExecutionContext; -import org.quartz.JobExecutionException; - -@Slf4j -public class EventSubscriptionCleanupJob implements Job { - private static final int TARGET_COUNT = 50; - private static final int THRESHOLD = 100; - - @Override - public void execute(JobExecutionContext context) throws JobExecutionException { - performCleanup(); - } - - @Transaction - public static void performCleanup() { - List subscriptionsToClean = - Entity.getCollectionDAO().eventSubscriptionDAO().findSubscriptionsAboveThreshold(THRESHOLD); - - for (String subscriptionId : subscriptionsToClean) { - long recordCount = - Entity.getCollectionDAO().eventSubscriptionDAO().getSuccessfulRecordCount(subscriptionId); - - long excessRecords = recordCount - TARGET_COUNT; - if (excessRecords > 0) { - Entity.getCollectionDAO() - .eventSubscriptionDAO() - .deleteOldRecords(subscriptionId, excessRecords); - } - } - LOG.debug( - "Performed cleanup for subscriptions, retaining {} records per subscription.", - TARGET_COUNT); - } -} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/events/scheduled/EventSubscriptionScheduler.java b/openmetadata-service/src/main/java/org/openmetadata/service/events/scheduled/EventSubscriptionScheduler.java index d76bcafcebeb..f49900b1a66d 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/events/scheduled/EventSubscriptionScheduler.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/events/scheduled/EventSubscriptionScheduler.java @@ -61,7 +61,6 @@ public class EventSubscriptionScheduler { public static final String ALERT_TRIGGER_GROUP = "OMAlertJobGroup"; private static EventSubscriptionScheduler instance; private static volatile boolean initialized = false; - public static volatile boolean cleanupJobInitialised = false; private final Scheduler alertsScheduler = new StdSchedulerFactory().getScheduler(); @@ -118,7 +117,6 @@ public void addSubscriptionPublisher(EventSubscription eventSubscription) // Schedule the Job alertsScheduler.scheduleJob(jobDetail, trigger); - instance.scheduleCleanupJob(); LOG.info( "Event Subscription started as {} : status {} for all Destinations", @@ -171,33 +169,6 @@ public void deleteEventSubscriptionPublisher(EventSubscription deletedEntity) LOG.info("Alert publisher deleted for {}", deletedEntity.getName()); } - public void scheduleCleanupJob() { - if (!cleanupJobInitialised) { - try { - JobDetail cleanupJob = - JobBuilder.newJob(EventSubscriptionCleanupJob.class) - .withIdentity("CleanupJob", ALERT_JOB_GROUP) - .build(); - - Trigger cleanupTrigger = - TriggerBuilder.newTrigger() - .withIdentity("CleanupTrigger", ALERT_TRIGGER_GROUP) - .withSchedule( - SimpleScheduleBuilder.simpleSchedule() - .withIntervalInSeconds(10) - .repeatForever()) - .startNow() - .build(); - - alertsScheduler.scheduleJob(cleanupJob, cleanupTrigger); - cleanupJobInitialised = true; - LOG.info("Scheduled periodic cleanup job to run every 10 seconds."); - } catch (SchedulerException e) { - LOG.error("Failed to schedule cleanup job", e); - } - } - } - @Transaction public void deleteSuccessfulAndFailedEventsRecordByAlert(UUID id) { Entity.getCollectionDAO() diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java index 3bf706b624be..0e42b907331c 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java @@ -1374,6 +1374,11 @@ List listTasksOfUser( @Bind("limit") int limit, @Define("condition") String condition); + @SqlQuery( + "SELECT id FROM thread_entity WHERE type = 'Conversation' AND createdAt < :cutoffMillis LIMIT :batchSize") + List fetchConversationThreadIdsOlderThan( + @Bind("cutoffMillis") long cutoffMillis, @Bind("batchSize") int batchSize); + @ConnectionAwareSqlQuery( value = "SELECT count(id) FROM thread_entity " @@ -2227,6 +2232,52 @@ void upsertSuccessfulChangeEvent( void deleteOldRecords( @Bind("eventSubscriptionId") String eventSubscriptionId, @Bind("limit") long limit); + @ConnectionAwareSqlUpdate( + value = + "DELETE FROM successful_sent_change_events " + + "WHERE timestamp < :cutoff ORDER BY timestamp LIMIT :limit", + connectionType = MYSQL) + @ConnectionAwareSqlUpdate( + value = + "DELETE FROM successful_sent_change_events " + + "WHERE ctid IN ( " + + " SELECT ctid FROM successful_sent_change_events " + + " WHERE timestamp < :cutoff ORDER BY timestamp LIMIT :limit " + + ")", + connectionType = POSTGRES) + int deleteSuccessfulSentChangeEventsInBatches( + @Bind("cutoff") long cutoff, @Bind("limit") int limit); + + @ConnectionAwareSqlUpdate( + value = + "DELETE FROM change_event " + + "WHERE eventTime < :cutoff ORDER BY eventTime LIMIT :limit", + connectionType = MYSQL) + @ConnectionAwareSqlUpdate( + value = + "DELETE FROM change_event " + + "WHERE ctid IN ( " + + " SELECT ctid FROM change_event " + + " WHERE eventTime < :cutoff ORDER BY eventTime LIMIT :limit " + + ")", + connectionType = POSTGRES) + int deleteChangeEventsInBatches(@Bind("cutoff") long cutoff, @Bind("limit") int limit); + + @ConnectionAwareSqlUpdate( + value = + "DELETE FROM consumers_dlq " + + "WHERE timestamp < :cutoff ORDER BY timestamp LIMIT :limit", + connectionType = MYSQL) + @ConnectionAwareSqlUpdate( + value = + "DELETE FROM consumers_dlq " + + "WHERE ctid IN ( " + + " SELECT ctid FROM consumers_dlq " + + " WHERE timestamp < :cutoff ORDER BY timestamp LIMIT :limit " + + ")", + connectionType = POSTGRES) + int deleteConsumersDlqInBatches(@Bind("cutoff") long cutoff, @Bind("limit") int limit); + @SqlQuery( "SELECT json FROM successful_sent_change_events WHERE event_subscription_id = :eventSubscriptionId ORDER BY timestamp DESC LIMIT :limit OFFSET :paginationOffset") List getSuccessfulChangeEventBySubscriptionId( diff --git a/openmetadata-service/src/main/resources/json/data/app/DataRetentionApplication.json b/openmetadata-service/src/main/resources/json/data/app/DataRetentionApplication.json new file mode 100644 index 000000000000..c5e14f24f4e7 --- /dev/null +++ b/openmetadata-service/src/main/resources/json/data/app/DataRetentionApplication.json @@ -0,0 +1,11 @@ +{ + "name": "DataRetentionApplication", + "displayName": "Data Retention", + "appConfiguration": { + "changeEventRetentionPeriod": 7 + }, + "appSchedule": { + "scheduleTimeline": "Custom", + "cronExpression": "0 0 * * 0" + } +} \ No newline at end of file diff --git a/openmetadata-service/src/main/resources/json/data/appMarketPlaceDefinition/DataRetentionApplication.json b/openmetadata-service/src/main/resources/json/data/appMarketPlaceDefinition/DataRetentionApplication.json new file mode 100644 index 000000000000..78ff3a06f876 --- /dev/null +++ b/openmetadata-service/src/main/resources/json/data/appMarketPlaceDefinition/DataRetentionApplication.json @@ -0,0 +1,18 @@ +{ + "name": "DataRetentionApplication", + "displayName": "Data Retention", + "description": "The Data Retention App automates the cleanup of OpenMetadata's internal database to maintain performance and efficiency. Based on customizable retention periods and admin-defined retention policies, this application helps manage rapidly growing tables and prevents data bloat by removing outdated records.\n\n**Efficient Record Management:** The Retention Policy App automates the cleanup and deletion of records for selected entities based on specified retention durations. It simplifies compliance with data retention policies and ensures efficient database management.", + "appType": "internal", + "appScreenshots": ["DataRetentionApplication.png"], + "developer": "Collate Inc.", + "developerUrl": "https://www.getcollate.io", + "privacyPolicyUrl": "https://www.getcollate.io", + "supportEmail": "support@getcollate.io", + "scheduleType": "ScheduledOrManual", + "permission": "All", + "className": "org.openmetadata.service.apps.bundles.dataRetention.DataRetention", + "runtime": { + "enabled": "true" + }, + "appConfiguration": {} +} \ No newline at end of file diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/applications/configuration/applicationConfig.json b/openmetadata-spec/src/main/resources/json/schema/entity/applications/configuration/applicationConfig.json index 74de4576ef66..947d54a309fc 100644 --- a/openmetadata-spec/src/main/resources/json/schema/entity/applications/configuration/applicationConfig.json +++ b/openmetadata-spec/src/main/resources/json/schema/entity/applications/configuration/applicationConfig.json @@ -24,6 +24,9 @@ }, { "$ref": "external/slackAppTokenConfiguration.json" + }, + { + "$ref": "internal/dataRetentionConfiguration.json" } ] }, diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/applications/configuration/internal/dataRetentionConfiguration.json b/openmetadata-spec/src/main/resources/json/schema/entity/applications/configuration/internal/dataRetentionConfiguration.json new file mode 100644 index 000000000000..bdb4745559fa --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/entity/applications/configuration/internal/dataRetentionConfiguration.json @@ -0,0 +1,19 @@ +{ + "$id": "https://open-metadata.org/schema/entity/applications/configuration/dataRetentionConfiguration.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Retention Configuration", + "description": "Configure retention policies for each entity.", + "properties": { + "changeEventRetentionPeriod": { + "title": "Change Event Retention Period (days)", + "description": "Enter the retention period for change event records in days (e.g., 7 for one week, 30 for one month).", + "type": "integer", + "default": 7, + "minimum": 1 + } + }, + "required": [ + "changeEventRetentionPeriod" + ], + "additionalProperties": false +} diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/img/appScreenshots/DataRetentionApplication.png b/openmetadata-ui/src/main/resources/ui/src/assets/img/appScreenshots/DataRetentionApplication.png new file mode 100644 index 0000000000000000000000000000000000000000..aff9b000643f241d3cf273e516fc3abf6ffd76e7 GIT binary patch literal 754719 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%0D_vVwl@)S5!nsZ%e9yX*}5&1}x3e@HB# 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

Ck3GbKk7M3QaJ&92la-gq$|JMPIP{5%morlT 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 z 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%0AygG{Xm;^tC-$}WSoh4Gb*7Y2_xRSqo%bE8GEj~ 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/applications/configuration/internal/dataRetentionConfiguration.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/applications/configuration/internal/dataRetentionConfiguration.ts new file mode 100644 index 000000000000..2e3cd4b41266 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/applications/configuration/internal/dataRetentionConfiguration.ts @@ -0,0 +1,19 @@ +/* + * 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 DataRetentionConfigurationClass { + /** + * Enter the retention period for change event records in days (e.g., 7 for one week, 30 for + * one month). + */ + changeEventRetentionPeriod: number; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/applications/configuration/internal/retentionPolicyConfiguration.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/applications/configuration/internal/retentionPolicyConfiguration.ts new file mode 100644 index 000000000000..4e1f2d9e847b --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/applications/configuration/internal/retentionPolicyConfiguration.ts @@ -0,0 +1,28 @@ +/* + * 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 RetentionPolicyConfigurationClass { + /** + * Enter the retention period for Activity Threads records in days (e.g., 30 for one month, + * 60 for two months). + */ + activityThreadsRetentionPeriod: number; + /** + * Enter the retention period for change event records in days (e.g., 7 for one week, 30 for + * one month). + */ + changeEventRetentionPeriod: number; + /** + * Enter the number of versions to retain. + */ + versionsRetentionPeriod: number; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/ApplicationSchemas/DataRetentionApplication.json b/openmetadata-ui/src/main/resources/ui/src/utils/ApplicationSchemas/DataRetentionApplication.json new file mode 100644 index 000000000000..0d152ee5ba49 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ApplicationSchemas/DataRetentionApplication.json @@ -0,0 +1,18 @@ +{ + "$id": "https://open-metadata.org/schema/entity/applications/configuration/dataRetentionConfiguration.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Retention Configuration", + "type": "object", + "description": "Configure retention policies for each entity.", + "properties": { + "changeEventRetentionPeriod": { + "title": "Change Event Retention Period (days)", + "description": "Enter the retention period for change event records in days (e.g., 7 for one week, 30 for one month).", + "type": "integer", + "default": 7, + "minimum": 1 + } + }, + "required": ["changeEventRetentionPeriod"], + "additionalProperties": false +} From c19463148852434c144302fff317a5b17ba5967c Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 12 Dec 2024 19:27:53 -0800 Subject: [PATCH 143/294] Fix #issue-16041: The persona JSON schema is named Team (#19035) --- .../src/main/resources/json/schema/entity/teams/persona.json | 2 +- .../main/resources/ui/src/generated/entity/teams/persona.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/teams/persona.json b/openmetadata-spec/src/main/resources/json/schema/entity/teams/persona.json index 9247653ad94f..16588c532f3b 100644 --- a/openmetadata-spec/src/main/resources/json/schema/entity/teams/persona.json +++ b/openmetadata-spec/src/main/resources/json/schema/entity/teams/persona.json @@ -1,7 +1,7 @@ { "$id": "https://open-metadata.org/schema/entity/teams/persona.json", "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Team", + "title": "Persona", "description": "This schema defines the Persona entity. A `Persona` is a job function associated with a user. An Example, Data Engineer or Data Consumer is a Persona of a user in Metadata world.", "type": "object", "javaType": "org.openmetadata.schema.entity.teams.Persona", diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/teams/persona.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/teams/persona.ts index 9bbc34d2b474..fe0f86c6e401 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/teams/persona.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/teams/persona.ts @@ -10,9 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - - - /** +/** * This schema defines the Persona entity. A `Persona` is a job function associated with a * user. An Example, Data Engineer or Data Consumer is a Persona of a user in Metadata world. */ From 8ae94f598b03b0c0154f5bc3d6bf8aa2963f00f2 Mon Sep 17 00:00:00 2001 From: Mohit Yadav <105265192+mohityadav766@users.noreply.github.com> Date: Wed, 22 Jan 2025 11:46:32 +0530 Subject: [PATCH 144/294] [Fix-19437] Redirection issue on IDP initiated calls (#19443) Co-authored-by: Siddhant <86899184+Siddhanttimeline@users.noreply.github.com> Co-authored-by: Sriharsha Chintalapani (cherry picked from commit 5064602dc8228cfd0c60b31f07d6447851f8a226) --- .../saml/SamlAssertionConsumerServlet.java | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/saml/SamlAssertionConsumerServlet.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/saml/SamlAssertionConsumerServlet.java index 2bc6a9cbd2bb..516ba6d54e83 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/saml/SamlAssertionConsumerServlet.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/saml/SamlAssertionConsumerServlet.java @@ -130,17 +130,22 @@ private void handleResponse(HttpServletRequest req, HttpServletResponse resp) th // Redirect with JWT Token String redirectUri = (String) req.getSession().getAttribute(SESSION_REDIRECT_URI); String url = - redirectUri - + "?id_token=" - + jwtAuthMechanism.getJWTToken() - + "&email=" - + nameId - + "&name=" - + username; + String.format( + "%s?id_token=%s&email=%s&name=%s", + (nullOrEmpty(redirectUri) ? buildBaseRequestUrl(req) : redirectUri), + jwtAuthMechanism.getJWTToken(), + nameId, + username); resp.sendRedirect(url); } } + private String buildBaseRequestUrl(HttpServletRequest req) { + // In case of IDP initiated one it needs to be built on fly, since the session might not exist + return String.format( + "%s://%s:%s/saml/callback", req.getScheme(), req.getServerName(), req.getServerPort()); + } + private JwtResponse getJwtResponseWithRefresh( User storedUser, JWTAuthMechanism jwtAuthMechanism) { RefreshToken newRefreshToken = TokenUtil.getRefreshToken(storedUser.getId(), UUID.randomUUID()); From 0bc5164f2b5aff87e6cd4459bdfa099d4746b5e0 Mon Sep 17 00:00:00 2001 From: Ashish Gupta Date: Wed, 22 Jan 2025 13:19:45 +0530 Subject: [PATCH 145/294] #19474: fix the async export csv not happening in lineage (#19397) * fix lineage playwrigt flaky test * increase the timer * fix the async export issue * remove the timer * minor revert (cherry picked from commit 68c324679ab9078849d4300043d4fc55d11657e5) --- .../EntityExportModalProvider.component.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityExportModalProvider/EntityExportModalProvider.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityExportModalProvider/EntityExportModalProvider.component.tsx index 7bae7271755f..a976e6bc483c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityExportModalProvider/EntityExportModalProvider.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityExportModalProvider/EntityExportModalProvider.component.tsx @@ -78,6 +78,12 @@ export const EntityExportModalProvider = ({ } try { setDownloading(true); + // assigning the job data to ref here, as exportData.onExport may take time to return the data + // and websocket connection may be respond before that, so we need to keep the job data in ref + // to handle the download + csvExportJobRef.current = { + fileName: fileName, + }; const data = await exportData.onExport(exportData.name); if (isString(data)) { From fb017572b3e4f9dd0a341018695479d99144a3b7 Mon Sep 17 00:00:00 2001 From: Ashish Gupta Date: Wed, 22 Jan 2025 16:22:44 +0530 Subject: [PATCH 146/294] #19432: fix the description renderer in glossary import (#19450) * fix the description renderer in glossary import * added test for the same (cherry picked from commit c406cceb9a649292efa15e915ec5056c277c0149) --- .../ui/playwright/utils/importUtils.ts | 5 ++- .../resources/ui/src/utils/CSV/CSV.utils.tsx | 35 +++++++++++++++---- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/importUtils.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/importUtils.ts index b1bbed0d03d1..3096c2062347 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/importUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/importUtils.ts @@ -51,7 +51,10 @@ export const fillDescriptionDetails = async ( await page.fill(descriptionBox, description); await page.click('[data-testid="save"]'); - await page.click('.InovuaReactDataGrid__cell--cell-active'); + + await expect( + page.locator('.InovuaReactDataGrid__cell--cell-active') + ).not.toContainText('

    '); }; export const fillOwnerDetails = async (page: Page, owners: string[]) => { diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/CSV/CSV.utils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/CSV/CSV.utils.tsx index c6f23d1d1987..fa0656d30814 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/CSV/CSV.utils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/CSV/CSV.utils.tsx @@ -23,6 +23,7 @@ import React from 'react'; import { ReactComponent as SuccessBadgeIcon } from '../..//assets/svg/success-badge.svg'; import { ReactComponent as FailBadgeIcon } from '../../assets/svg/fail-badge.svg'; import { TableTypePropertyValueType } from '../../components/common/CustomPropertyTable/CustomPropertyTable.interface'; +import RichTextEditorPreviewerV1 from '../../components/common/RichTextEditor/RichTextEditorPreviewerV1'; import { ExtensionDataProps, ExtensionDataTypes, @@ -55,12 +56,7 @@ export const COLUMNS_WIDTH: Record = { status: 70, }; -const statusRenderer = ({ - value, -}: { - value: Status; - data: { details: string }; -}) => { +const statusRenderer = (value: Status) => { return value === Status.Failure ? ( { + const { value } = recordData; + switch (column) { + case 'status': + return statusRenderer(value as Status); + case 'description': + return ( + + ); + + default: + return value; + } +}; + export const getColumnConfig = ( column: string, entityType: EntityType @@ -91,7 +112,7 @@ export const getColumnConfig = ( sortable: false, renderEditor: csvUtilsClassBase.getEditor(colType, entityType), minWidth: COLUMNS_WIDTH[colType] ?? 180, - render: column === 'status' ? statusRenderer : undefined, + render: (recordData) => renderColumnDataEditor(column, recordData), } as TypeColumn; }; From 4c8a268688d5fd1d4dbbe85328d018bbf66dbc5b Mon Sep 17 00:00:00 2001 From: Shailesh Parmar Date: Wed, 22 Jan 2025 18:53:43 +0530 Subject: [PATCH 147/294] Fix: Implement Data Quality Dashboards (Incident Manager + Data Quality) (#19231) * Fix: Implement Data Quality Dashboards (Incident Manager + Data Quality) * added icon for test case status * added filters in the api * added filters for dq * added filter of table/column * added test coverage * address the comments (cherry picked from commit 2c06bcf32719183c2a85a231763fdbc41224f6ea) --- .../DataQuality/DataQuality.interface.ts | 5 + .../SummaryPannel/SummaryPanel.component.tsx | 64 +++++++- .../SummaryPannel/SummaryPanel.interface.ts | 1 + .../SummaryPannel/SummaryPanel.test.tsx | 8 + .../TestCases/TestCases.component.tsx | 5 +- .../TestSuiteList/TestSuites.component.tsx | 1 + .../SummaryCard/SummaryCard.component.tsx | 22 ++- .../SummaryCard/SummaryCard.interface.ts | 7 + .../common/SummaryCard/SummaryCard.test.tsx | 27 ++++ .../SummaryCard/summary-card.style.less | 18 +++ .../DataQuality/DataQualityPage.interface.ts | 8 +- .../DataQuality/DataQualityProvider.test.tsx | 80 +++++++++- .../pages/DataQuality/DataQualityProvider.tsx | 71 +++++++- .../src/rest/dataQualityDashboardAPI.test.ts | 151 ++++++++++++------ .../ui/src/rest/dataQualityDashboardAPI.ts | 90 +---------- .../src/utils/DataQuality/DataQualityUtils.ts | 112 ++++++++++++- 16 files changed, 510 insertions(+), 160 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/DataQuality.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/DataQuality.interface.ts index 9ccff47fccff..bdf772293500 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/DataQuality.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/DataQuality.interface.ts @@ -46,6 +46,11 @@ export type TestCaseSearchParams = { dataQualityDimension?: string; }; +export type DataQualityPageParams = TestCaseSearchParams & { + owner?: string; + tags?: string[]; +}; + export interface IncidentTypeAreaChartWidgetProps { title: string; incidentStatusType: TestCaseResolutionStatusTypes; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/SummaryPannel/SummaryPanel.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/SummaryPannel/SummaryPanel.component.tsx index c8091098a7e3..67bcef2682ea 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/SummaryPannel/SummaryPanel.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/SummaryPannel/SummaryPanel.component.tsx @@ -11,56 +11,110 @@ * limitations under the License. */ import { Col, Row } from 'antd'; -import React, { FC } from 'react'; +import React, { FC, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { ReactComponent as TestCaseAbortedIcon } from '../../../assets/svg/aborted-status.svg'; +import { ReactComponent as TestCaseIcon } from '../../../assets/svg/all-activity-v2.svg'; +import { ReactComponent as TestCaseFailedIcon } from '../../../assets/svg/failed-status.svg'; +import { ReactComponent as DataAssetsCoverageIcon } from '../../../assets/svg/ic-data-assets-coverage.svg'; +import { ReactComponent as HealthCheckIcon } from '../../../assets/svg/ic-green-heart-border.svg'; +import { ReactComponent as TestCaseSuccessIcon } from '../../../assets/svg/success-colored.svg'; import { SummaryCard } from '../../../components/common/SummaryCard/SummaryCard.component'; +import { PRIMARY_COLOR } from '../../../constants/Color.constants'; import { SummaryPanelProps } from './SummaryPanel.interface'; export const SummaryPanel: FC = ({ testSummary: summary, isLoading = false, + showAdditionalSummary = false, }: SummaryPanelProps) => { const { t } = useTranslation(); + const spanValue = useMemo( + () => (showAdditionalSummary ? 8 : 6), + [showAdditionalSummary] + ); return ( -

+ + } total={summary?.total ?? 0} value={summary?.total ?? 0} /> - + } total={summary?.total ?? 0} type="success" value={summary?.success ?? 0} /> - + } total={summary?.total ?? 0} type="aborted" value={summary?.aborted ?? 0} /> - + } total={summary?.total ?? 0} type="failed" value={summary?.failed ?? 0} /> + {showAdditionalSummary && ( + <> + + } + total={summary?.totalDQEntities ?? 0} + type="success" + value={summary?.healthy ?? 0} + /> + + + } + total={summary?.totalEntityCount ?? 0} + type="acknowledged" + value={summary?.totalDQEntities ?? 0} + /> + + + )} ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/SummaryPannel/SummaryPanel.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/SummaryPannel/SummaryPanel.interface.ts index 4a1d6d158f60..2c2414e7a8fb 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/SummaryPannel/SummaryPanel.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/SummaryPannel/SummaryPanel.interface.ts @@ -15,4 +15,5 @@ import { TestSummary } from '../../../generated/tests/testSuite'; export interface SummaryPanelProps { testSummary: TestSummary; isLoading?: boolean; + showAdditionalSummary?: boolean; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/SummaryPannel/SummaryPanel.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/SummaryPannel/SummaryPanel.test.tsx index 91228de6dedf..7ed582ac4b78 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/SummaryPannel/SummaryPanel.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/SummaryPannel/SummaryPanel.test.tsx @@ -55,6 +55,14 @@ describe('SummaryPanel component', () => { expect(summaryCards).toHaveLength(4); }); + it('Show additional summary card if showAdditionalSummary is true', async () => { + render(); + + const summaryCards = await screen.findAllByText('SummaryCard.component'); + + expect(summaryCards).toHaveLength(6); + }); + it('should not call getTestCaseExecutionSummary API, if testSummary data is provided', async () => { const mockGetTestCaseExecutionSummary = getTestCaseExecutionSummary as jest.Mock; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCases/TestCases.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCases/TestCases.component.tsx index ea92c5a08711..460a08e84d7b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCases/TestCases.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestCases/TestCases.component.tsx @@ -111,8 +111,8 @@ export const TestCases = () => { return params as TestCaseSearchParams; }, [location.search]); - const { searchValue = '' } = params; + const { searchValue = '' } = params; const [testCase, setTestCase] = useState([]); const [isLoading, setIsLoading] = useState(true); const [selectedFilter, setSelectedFilter] = useState([ @@ -416,7 +416,6 @@ export const TestCases = () => { key: filter, label: startCase(name), value: filter, - onClick: handleMenuClick, })); }, []); @@ -497,6 +496,7 @@ export const TestCases = () => { menu={{ items: filterMenu, selectedKeys: selectedFilter, + onClick: handleMenuClick, }} trigger={['click']}> diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestSuite/TestSuiteList/TestSuites.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestSuite/TestSuiteList/TestSuites.component.tsx index ab7bbf4b6a06..c7a148ae5229 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestSuite/TestSuiteList/TestSuites.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestSuite/TestSuiteList/TestSuites.component.tsx @@ -321,6 +321,7 @@ export const TestSuites = () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/SummaryCard/SummaryCard.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/SummaryCard/SummaryCard.component.tsx index c93f7a26dbd2..08c648eb1012 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/SummaryCard/SummaryCard.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/SummaryCard/SummaryCard.component.tsx @@ -27,6 +27,9 @@ export const SummaryCard = ({ showProgressBar = true, className, isLoading = false, + inverseLabel, + titleIcon, + cardBackgroundClass, }: SummaryCardProps) => { const percent = useMemo(() => { if (isNumber(value)) { @@ -48,14 +51,19 @@ export const SummaryCard = ({ return ( -
- - {title} - +
+ + {titleIcon} + + {title} + + diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/SummaryCard/SummaryCard.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/common/SummaryCard/SummaryCard.interface.ts index 5914ff5d9abf..6c6742b21be8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/SummaryCard/SummaryCard.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/SummaryCard/SummaryCard.interface.ts @@ -18,4 +18,11 @@ export interface SummaryCardProps { showProgressBar?: boolean; className?: string; isLoading?: boolean; + inverseLabel?: boolean; + titleIcon?: React.ReactElement; + cardBackgroundClass?: + | 'bg-success' + | 'bg-failed' + | 'bg-aborted' + | 'bg-primary'; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/SummaryCard/SummaryCard.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/SummaryCard/SummaryCard.test.tsx index eef84fcbceef..a199adbdfd71 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/SummaryCard/SummaryCard.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/SummaryCard/SummaryCard.test.tsx @@ -67,4 +67,31 @@ describe('SummaryCard component', () => { expect(title.textContent).toStrictEqual('summary title'); expect(description.textContent).toStrictEqual('description'); }); + + it("label should be inverse, if 'inverseLabel' is true", async () => { + render(); + + const label = await screen.findByTestId('summary-card-label'); + + expect(label).toHaveClass('inverse-label'); + }); + + it("should render title icon, if 'titleIcon' is provided", async () => { + render( + icon} + /> + ); + + expect(await screen.findByTestId('title-icon')).toBeInTheDocument(); + }); + + it("should render card background based on 'cardBackgroundClass'", async () => { + render(); + + const container = await screen.findByTestId('summary-card-container'); + + expect(container).toHaveClass('bg-success'); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/SummaryCard/summary-card.style.less b/openmetadata-ui/src/main/resources/ui/src/components/common/SummaryCard/summary-card.style.less index 153fa455d077..4b764e52d8be 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/SummaryCard/summary-card.style.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/SummaryCard/summary-card.style.less @@ -25,6 +25,11 @@ margin-bottom: 4px; } + .inverse-label { + display: flex; + flex-direction: column-reverse; + } + .summary-card-description { font-weight: 500; font-size: 18px; @@ -37,6 +42,19 @@ } } + &.bg-success { + background-color: @green-8; + } + &.bg-failed { + background-color: @red-8; + } + &.bg-aborted { + background-color: @yellow-8; + } + &.bg-primary { + background-color: @blue-8; + } + .new.ant-progress-circle { .ant-progress-circle-path { stroke: @blue-3 !important; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DataQuality/DataQualityPage.interface.ts b/openmetadata-ui/src/main/resources/ui/src/pages/DataQuality/DataQualityPage.interface.ts index 17ed29461b78..2344a972c951 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/DataQuality/DataQualityPage.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/pages/DataQuality/DataQualityPage.interface.ts @@ -12,7 +12,8 @@ */ import { EntityType } from '../../enums/entity.enum'; -import { TestSummary } from '../../generated/tests/testCase'; +import { TestCaseStatus, TestSummary } from '../../generated/tests/testCase'; +import { TestCaseType } from '../../rest/testAPI'; export enum DataQualityPageTabs { TEST_SUITES = 'test-suites', @@ -35,4 +36,9 @@ export type DataQualityDashboardChartFilters = { endTs?: number; entityFQN?: string; entityType?: EntityType; + serviceName?: string; + testPlatforms?: string[]; + dataQualityDimension?: string; + testCaseStatus?: TestCaseStatus; + testCaseType?: TestCaseType; }; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DataQuality/DataQualityProvider.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DataQuality/DataQualityProvider.test.tsx index 04dbd277dbe6..a401637fa945 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/DataQuality/DataQualityProvider.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/DataQuality/DataQualityProvider.test.tsx @@ -13,7 +13,11 @@ /* eslint-disable i18next/no-literal-string */ import { act, render, screen } from '@testing-library/react'; import React from 'react'; -import { fetchTestCaseSummary } from '../../rest/dataQualityDashboardAPI'; +import { + fetchEntityCoveredWithDQ, + fetchTestCaseSummary, + fetchTotalEntityCount, +} from '../../rest/dataQualityDashboardAPI'; import { DataQualityPageTabs } from './DataQualityPage.interface'; import DataQualityProvider, { useDataQualityProvider, @@ -30,6 +34,10 @@ const mockPermissionsData = { const mockUseParam = { tab: DataQualityPageTabs.TABLES } as { tab?: DataQualityPageTabs; }; + +const mockLocation = { + search: '', +}; jest.mock('../../context/PermissionProvider/PermissionProvider', () => ({ usePermissionProvider: () => mockPermissionsData, })); @@ -38,11 +46,43 @@ jest.mock('react-router-dom', () => { useParams: jest.fn().mockImplementation(() => mockUseParam), }; }); +jest.mock('../../hooks/useCustomLocation/useCustomLocation', () => { + return jest.fn().mockImplementation(() => mockLocation); +}); jest.mock('../../rest/dataQualityDashboardAPI', () => ({ fetchTestCaseSummary: jest.fn().mockImplementation(() => { return new Promise((resolve) => { setTimeout(() => { - resolve({ data: [] }); + resolve({ + data: [ + { + document_count: '4', + 'testCaseResult.testCaseStatus': 'success', + }, + { + document_count: '3', + 'testCaseResult.testCaseStatus': 'failed', + }, + { + document_count: '1', + 'testCaseResult.testCaseStatus': 'aborted', + }, + ], + }); + }, 2000); // Simulate a delay + }); + }), + fetchEntityCoveredWithDQ: jest.fn().mockImplementation(() => { + return new Promise((resolve) => { + setTimeout(() => { + resolve({ data: [{ originEntityFQN: '1' }] }); + }, 2000); // Simulate a delay + }); + }), + fetchTotalEntityCount: jest.fn().mockImplementation(() => { + return new Promise((resolve) => { + setTimeout(() => { + resolve({ data: [{ fullyQualifiedName: '29' }] }); }, 2000); // Simulate a delay }); }), @@ -87,7 +127,41 @@ describe('DataQualityProvider', () => { expect(await screen.findByText('tables component')).toBeInTheDocument(); }); - it('should call fetchTestCaseSummary', async () => { + it('should call fetchTestCaseSummary, fetchEntityCoveredWithDQ & fetchTotalEntityCount', async () => { + expect(await screen.findByText('tables component')).toBeInTheDocument(); expect(fetchTestCaseSummary).toHaveBeenCalledTimes(1); + expect(fetchEntityCoveredWithDQ).toHaveBeenCalledTimes(2); + expect(fetchTotalEntityCount).toHaveBeenCalledTimes(1); + }); + + it('should call fetchTestCaseSummary, fetchEntityCoveredWithDQ & fetchTotalEntityCount based on prams change', async () => { + mockLocation.search = + '?testCaseType=table&testCaseStatus=Success&tier=Tier.Tier1'; + + expect(await screen.findByText('tables component')).toBeInTheDocument(); + expect(fetchTestCaseSummary).toHaveBeenCalledWith({ + entityFQN: undefined, + ownerFqn: undefined, + testCaseStatus: 'Success', + testCaseType: 'table', + tier: ['Tier.Tier1'], + }); + expect(fetchEntityCoveredWithDQ).toHaveBeenCalledWith( + { + entityFQN: undefined, + ownerFqn: undefined, + testCaseStatus: 'Success', + testCaseType: 'table', + tier: ['Tier.Tier1'], + }, + true + ); + expect(fetchTotalEntityCount).toHaveBeenCalledWith({ + entityFQN: undefined, + ownerFqn: undefined, + testCaseStatus: 'Success', + testCaseType: 'table', + tier: ['Tier.Tier1'], + }); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DataQuality/DataQualityProvider.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DataQuality/DataQualityProvider.tsx index 7469e2c09bbd..296457c82ebb 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/DataQuality/DataQualityProvider.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/DataQuality/DataQualityProvider.tsx @@ -11,6 +11,8 @@ * limitations under the License. */ import { AxiosError } from 'axios'; +import { pick } from 'lodash'; +import QueryString from 'qs'; import React, { createContext, useContext, @@ -19,10 +21,16 @@ import React, { useState, } from 'react'; import { useParams } from 'react-router-dom'; +import { DataQualityPageParams } from '../../components/DataQuality/DataQuality.interface'; import { INITIAL_TEST_SUMMARY } from '../../constants/TestSuite.constant'; import { usePermissionProvider } from '../../context/PermissionProvider/PermissionProvider'; import { TestSummary } from '../../generated/tests/testCase'; -import { fetchTestCaseSummary } from '../../rest/dataQualityDashboardAPI'; +import useCustomLocation from '../../hooks/useCustomLocation/useCustomLocation'; +import { + fetchEntityCoveredWithDQ, + fetchTestCaseSummary, + fetchTotalEntityCount, +} from '../../rest/dataQualityDashboardAPI'; import { transformToTestCaseStatusObject } from '../../utils/DataQuality/DataQualityUtils'; import { showErrorToast } from '../../utils/ToastUtils'; import { @@ -36,6 +44,17 @@ export const DataQualityContext = createContext( const DataQualityProvider = ({ children }: { children: React.ReactNode }) => { const { tab: activeTab } = useParams<{ tab: DataQualityPageTabs }>(); + const location = useCustomLocation(); + const params = useMemo(() => { + const search = location.search; + + const params = QueryString.parse( + search.startsWith('?') ? search.substring(1) : search + ); + + return params as DataQualityPageParams; + }, [location.search]); + const [testCaseSummary, setTestCaseSummary] = useState(INITIAL_TEST_SUMMARY); const [isTestCaseSummaryLoading, setIsTestCaseSummaryLoading] = @@ -52,25 +71,65 @@ const DataQualityProvider = ({ children }: { children: React.ReactNode }) => { }; }, [testCaseSummary, isTestCaseSummaryLoading, activeTab]); - const fetchTestSummary = async () => { + const fetchTestSummary = async (params?: DataQualityPageParams) => { + const filters = { + ...pick(params, [ + 'tags', + 'serviceName', + 'testPlatforms', + 'dataQualityDimension', + 'testCaseStatus', + 'testCaseType', + ]), + ownerFqn: params?.owner ? JSON.parse(params.owner)?.name : undefined, + tier: params?.tier ? [params.tier] : undefined, + entityFQN: params?.tableFqn, + }; + setIsTestCaseSummaryLoading(true); try { - const { data } = await fetchTestCaseSummary(); + const { data } = await fetchTestCaseSummary(filters); + const { data: unhealthyData } = await fetchEntityCoveredWithDQ( + filters, + true + ); + const { data: totalDQCoverage } = await fetchEntityCoveredWithDQ( + filters, + false + ); + + const { data: entityCount } = await fetchTotalEntityCount(filters); + + const unhealthy = parseInt(unhealthyData[0].originEntityFQN); + const total = parseInt(totalDQCoverage[0].originEntityFQN); + let totalEntityCount = parseInt(entityCount[0].fullyQualifiedName); + + if (total > totalEntityCount) { + totalEntityCount = total; + } + const updatedData = transformToTestCaseStatusObject(data); - setTestCaseSummary(updatedData); + setTestCaseSummary({ + ...updatedData, + unhealthy, + healthy: total - unhealthy, + totalDQEntities: total, + totalEntityCount, + }); } catch (error) { showErrorToast(error as AxiosError); } finally { setIsTestCaseSummaryLoading(false); } }; + useEffect(() => { if (testCasePermission?.ViewAll || testCasePermission?.ViewBasic) { - fetchTestSummary(); + fetchTestSummary(params); } else { setIsTestCaseSummaryLoading(false); } - }, []); + }, [params]); return ( diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/dataQualityDashboardAPI.test.ts b/openmetadata-ui/src/main/resources/ui/src/rest/dataQualityDashboardAPI.test.ts index 818f41fa7524..0f49c5c4953d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/dataQualityDashboardAPI.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/dataQualityDashboardAPI.test.ts @@ -16,6 +16,7 @@ import { EntityType } from '../enums/entity.enum'; import { TestCaseStatus } from '../generated/tests/testCase'; import { TestCaseResolutionStatusTypes } from '../generated/tests/testCaseResolutionStatus'; import { + buildDataQualityDashboardFilters, buildMustEsFilterForOwner, buildMustEsFilterForTags, } from '../utils/DataQuality/DataQualityUtils'; @@ -37,21 +38,27 @@ jest.mock('./testAPI', () => ({ jest.mock('../utils/DataQuality/DataQualityUtils', () => ({ buildMustEsFilterForOwner: jest.fn(), buildMustEsFilterForTags: jest.fn(), + buildDataQualityDashboardFilters: jest.fn().mockReturnValue([]), })); describe('dataQualityDashboardAPI', () => { describe('fetchTotalEntityCount', () => { it('should call getDataQualityReport with correct query when ownerFqn is provided', async () => { const filters = { ownerFqn: 'owner1' }; - (buildMustEsFilterForOwner as jest.Mock).mockReturnValue({ - term: { - 'owners.fullyQualifiedName': 'owner1', + (buildDataQualityDashboardFilters as jest.Mock).mockReturnValueOnce([ + { + term: { + 'owners.fullyQualifiedName': 'owner1', + }, }, - }); + ]); await fetchTotalEntityCount(filters); - expect(buildMustEsFilterForOwner).toHaveBeenCalledWith('owner1'); + expect(buildDataQualityDashboardFilters).toHaveBeenCalledWith({ + filters: { ownerFqn: 'owner1' }, + isTableApi: true, + }); expect(getDataQualityReport).toHaveBeenCalledWith({ q: JSON.stringify({ query: { @@ -73,6 +80,16 @@ describe('dataQualityDashboardAPI', () => { it('should call getDataQualityReport with correct query when tags are provided', async () => { const filters = { tags: ['tag1', 'tag2'] }; + (buildDataQualityDashboardFilters as jest.Mock).mockReturnValueOnce([ + { + bool: { + should: [ + { term: { 'tags.tagFQN': 'tag1' } }, + { term: { 'tags.tagFQN': 'tag2' } }, + ], + }, + }, + ]); await fetchTotalEntityCount(filters); @@ -100,6 +117,16 @@ describe('dataQualityDashboardAPI', () => { it('should call getDataQualityReport with correct query when tier is provided', async () => { const filters = { tier: ['tier1', 'tier2'] }; + (buildDataQualityDashboardFilters as jest.Mock).mockReturnValueOnce([ + { + bool: { + should: [ + { term: { 'tier.tagFQN': 'tier1' } }, + { term: { 'tier.tagFQN': 'tier2' } }, + ], + }, + }, + ]); await fetchTotalEntityCount(filters); @@ -127,15 +154,30 @@ describe('dataQualityDashboardAPI', () => { it('should call getDataQualityReport with correct query when all filters are provided', async () => { const filters = { ownerFqn: 'owner1', tags: ['tag1'], tier: ['tier1'] }; - (buildMustEsFilterForOwner as jest.Mock).mockReturnValue({ - term: { - 'owners.fullyQualifiedName': 'owner1', + (buildDataQualityDashboardFilters as jest.Mock).mockReturnValueOnce([ + { + term: { + 'owners.fullyQualifiedName': 'owner1', + }, }, - }); + { + bool: { + should: [{ term: { 'tags.tagFQN': 'tag1' } }], + }, + }, + { + bool: { + should: [{ term: { 'tier.tagFQN': 'tier1' } }], + }, + }, + ]); await fetchTotalEntityCount(filters); - expect(buildMustEsFilterForOwner).toHaveBeenCalledWith('owner1'); + expect(buildDataQualityDashboardFilters).toHaveBeenCalledWith({ + filters: { ownerFqn: 'owner1', tags: ['tag1'], tier: ['tier1'] }, + isTableApi: true, + }); expect(getDataQualityReport).toHaveBeenCalledWith({ q: JSON.stringify({ query: { @@ -350,6 +392,9 @@ describe('dataQualityDashboardAPI', () => { func: fetchEntityCoveredWithDQ, index: 'testCase', aggregationQuery: `bucketName=entityWithTests:aggType=cardinality:field=originEntityFQN`, + params: { + unhealthy: false, + }, }, { functionName: 'fetchTestCaseSummaryByDimension', @@ -364,15 +409,16 @@ describe('dataQualityDashboardAPI', () => { describe(`${testData.functionName}`, () => { it('should call getDataQualityReport with correct query when ownerFqn is provided', async () => { const filters = { ownerFqn: testCaseData.filters.ownerFqn }; - (buildMustEsFilterForOwner as jest.Mock).mockReturnValue( - testCaseData.ownerExpectedQuery - ); + (buildDataQualityDashboardFilters as jest.Mock).mockReturnValueOnce([ + testCaseData.ownerExpectedQuery, + ]); await testData.func(filters); - expect(buildMustEsFilterForOwner).toHaveBeenCalledWith( - testCaseData.filters.ownerFqn - ); + expect(buildDataQualityDashboardFilters).toHaveBeenCalledWith({ + filters, + ...testData.params, + }); expect(getDataQualityReport).toHaveBeenCalledWith({ q: testCaseData.test1.q, index: testData.index, @@ -382,15 +428,16 @@ describe('dataQualityDashboardAPI', () => { it('should call getDataQualityReport with correct query when tags are provided', async () => { const filters = { tags: testCaseData.filters.tags }; - (buildMustEsFilterForTags as jest.Mock).mockReturnValue( - testCaseData.test2.expected - ); + (buildDataQualityDashboardFilters as jest.Mock).mockReturnValueOnce([ + testCaseData.test2.expected, + ]); await testData.func(filters); - expect(buildMustEsFilterForTags).toHaveBeenCalledWith( - testCaseData.filters.tags - ); + expect(buildDataQualityDashboardFilters).toHaveBeenCalledWith({ + filters, + ...testData.params, + }); expect(getDataQualityReport).toHaveBeenCalledWith({ q: testCaseData.test2.q, index: testData.index, @@ -400,15 +447,16 @@ describe('dataQualityDashboardAPI', () => { it('should call getDataQualityReport with correct query when tier is provided', async () => { const filters = { tier: testCaseData.filters.tier }; - (buildMustEsFilterForTags as jest.Mock).mockReturnValue( - testCaseData.test3.expected - ); + (buildDataQualityDashboardFilters as jest.Mock).mockReturnValueOnce([ + testCaseData.test3.expected, + ]); await testData.func(filters); - expect(buildMustEsFilterForTags).toHaveBeenCalledWith( - testCaseData.filters.tier - ); + expect(buildDataQualityDashboardFilters).toHaveBeenCalledWith({ + filters, + ...testData.params, + }); expect(getDataQualityReport).toHaveBeenCalledWith({ q: testCaseData.test3.q, index: testData.index, @@ -418,22 +466,19 @@ describe('dataQualityDashboardAPI', () => { it('should call getDataQualityReport with correct query when all filters are provided', async () => { const filters = testCaseData.filters; - (buildMustEsFilterForOwner as jest.Mock).mockReturnValue( - testCaseData.ownerExpectedQuery - ); - (buildMustEsFilterForTags as jest.Mock).mockReturnValue( - testCaseData.test4.expected - ); + + (buildDataQualityDashboardFilters as jest.Mock).mockReturnValueOnce([ + testCaseData.ownerExpectedQuery, + testCaseData.test4.expected, + ]); await testData.func(filters); - expect(buildMustEsFilterForOwner).toHaveBeenCalledWith( - testCaseData.filters.ownerFqn - ); - expect(buildMustEsFilterForTags).toHaveBeenCalledWith([ - ...testCaseData.filters.tags, - ...testCaseData.filters.tier, - ]); + expect(buildDataQualityDashboardFilters).toHaveBeenCalledWith({ + filters, + ...testData.params, + }); + expect(getDataQualityReport).toHaveBeenCalledWith({ q: testCaseData.test4.q, index: testData.index, @@ -486,7 +531,7 @@ describe('dataQualityDashboardAPI', () => { it('should call getDataQualityReport with correct query when ownerFqn is provided', async () => { const status = TestCaseResolutionStatusTypes.Assigned; const filters = { ownerFqn: 'owner1' }; - (buildMustEsFilterForOwner as jest.Mock).mockReturnValue({ + (buildMustEsFilterForOwner as jest.Mock).mockReturnValueOnce({ term: { 'owners.fullyQualifiedName': 'owner1', }, @@ -527,7 +572,7 @@ describe('dataQualityDashboardAPI', () => { it('should call getDataQualityReport with correct query when tags and tier are provided', async () => { const status = TestCaseResolutionStatusTypes.New; const filters = { tags: ['tag1'], tier: ['tier1'] }; - (buildMustEsFilterForTags as jest.Mock).mockReturnValue({ + (buildMustEsFilterForTags as jest.Mock).mockReturnValueOnce({ nested: { path: 'tags', query: { @@ -587,12 +632,12 @@ describe('dataQualityDashboardAPI', () => { it('should call getDataQualityReport with correct query when all filters are provided', async () => { const status = TestCaseResolutionStatusTypes.Resolved; const filters = { ownerFqn: 'owner1', tags: ['tag1'], tier: ['tier1'] }; - (buildMustEsFilterForOwner as jest.Mock).mockReturnValue({ + (buildMustEsFilterForOwner as jest.Mock).mockReturnValueOnce({ term: { 'owners.fullyQualifiedName': 'owner1', }, }); - (buildMustEsFilterForTags as jest.Mock).mockReturnValue({ + (buildMustEsFilterForTags as jest.Mock).mockReturnValueOnce({ nested: { path: 'tags', query: { @@ -732,7 +777,7 @@ describe('dataQualityDashboardAPI', () => { startTs: 1729073964962, endTs: 1729678764965, }; - (buildMustEsFilterForOwner as jest.Mock).mockReturnValue( + (buildMustEsFilterForOwner as jest.Mock).mockReturnValueOnce( testCaseData.ownerExpectedQuery ); @@ -788,7 +833,7 @@ describe('dataQualityDashboardAPI', () => { startTs: 1729073964962, endTs: 1729678764965, }; - (buildMustEsFilterForTags as jest.Mock).mockReturnValue({ + (buildMustEsFilterForTags as jest.Mock).mockReturnValueOnce({ nested: { path: 'tags', query: { @@ -863,10 +908,10 @@ describe('dataQualityDashboardAPI', () => { startTs: 1729073964962, endTs: 1729678764965, }; - (buildMustEsFilterForOwner as jest.Mock).mockReturnValue( + (buildMustEsFilterForOwner as jest.Mock).mockReturnValueOnce( testCaseData.ownerExpectedQuery ); - (buildMustEsFilterForTags as jest.Mock).mockReturnValue({ + (buildMustEsFilterForTags as jest.Mock).mockReturnValueOnce({ nested: { path: 'tags', query: { @@ -1011,7 +1056,7 @@ describe('dataQualityDashboardAPI', () => { it('should call getDataQualityReport with correct query when ownerFqn is provided', async () => { const status = TestCaseStatus.Failed; const filters = { ownerFqn: testCaseData.filters.ownerFqn }; - (buildMustEsFilterForOwner as jest.Mock).mockReturnValue( + (buildMustEsFilterForOwner as jest.Mock).mockReturnValueOnce( testCaseData.ownerExpectedQuery ); @@ -1053,7 +1098,7 @@ describe('dataQualityDashboardAPI', () => { it('should call getDataQualityReport with correct query when tags and tier are provided', async () => { const status = TestCaseStatus.Aborted; const filters = { tags: ['tag1'], tier: ['tier1'] }; - (buildMustEsFilterForTags as jest.Mock).mockReturnValue({ + (buildMustEsFilterForTags as jest.Mock).mockReturnValueOnce({ nested: { path: 'tags', query: { @@ -1113,12 +1158,12 @@ describe('dataQualityDashboardAPI', () => { it('should call getDataQualityReport with correct query when all filters are provided', async () => { const status = TestCaseStatus.Failed; const filters = { ownerFqn: 'owner1', tags: ['tag1'], tier: ['tier1'] }; - (buildMustEsFilterForOwner as jest.Mock).mockReturnValue({ + (buildMustEsFilterForOwner as jest.Mock).mockReturnValueOnce({ term: { 'owners.fullyQualifiedName': 'owner1', }, }); - (buildMustEsFilterForTags as jest.Mock).mockReturnValue({ + (buildMustEsFilterForTags as jest.Mock).mockReturnValueOnce({ nested: { path: 'tags', query: { diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/dataQualityDashboardAPI.ts b/openmetadata-ui/src/main/resources/ui/src/rest/dataQualityDashboardAPI.ts index 530e0cfef760..2522ba17eefd 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/dataQualityDashboardAPI.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/dataQualityDashboardAPI.ts @@ -16,6 +16,7 @@ import { TestCaseStatus } from '../generated/tests/testCase'; import { TestCaseResolutionStatusTypes } from '../generated/tests/testCaseResolutionStatus'; import { DataQualityDashboardChartFilters } from '../pages/DataQuality/DataQualityPage.interface'; import { + buildDataQualityDashboardFilters, buildMustEsFilterForOwner, buildMustEsFilterForTags, } from '../utils/DataQuality/DataQualityUtils'; @@ -25,27 +26,7 @@ export const fetchEntityCoveredWithDQ = ( filters?: DataQualityDashboardChartFilters, unhealthy = false ) => { - const mustFilter = []; - if (unhealthy) { - mustFilter.push({ - terms: { - 'testCaseStatus.keyword': ['Failed', 'Aborted'], - }, - }); - } - - if (filters?.ownerFqn) { - mustFilter.push(buildMustEsFilterForOwner(filters.ownerFqn)); - } - - if (filters?.tags || filters?.tier) { - mustFilter.push( - buildMustEsFilterForTags([ - ...(filters?.tags ?? []), - ...(filters?.tier ?? []), - ]) - ); - } + const mustFilter = buildDataQualityDashboardFilters({ filters, unhealthy }); return getDataQualityReport({ q: JSON.stringify({ @@ -63,35 +44,10 @@ export const fetchEntityCoveredWithDQ = ( export const fetchTotalEntityCount = ( filters?: DataQualityDashboardChartFilters ) => { - const mustFilter = []; - - if (filters?.ownerFqn) { - mustFilter.push(buildMustEsFilterForOwner(filters.ownerFqn)); - } - - if (filters?.tags) { - mustFilter.push({ - bool: { - should: filters.tags.map((tag) => ({ - term: { - 'tags.tagFQN': tag, - }, - })), - }, - }); - } - - if (filters?.tier) { - mustFilter.push({ - bool: { - should: filters.tier.map((tag) => ({ - term: { - 'tier.tagFQN': tag, - }, - })), - }, - }); - } + const mustFilter = buildDataQualityDashboardFilters({ + filters, + isTableApi: true, + }); return getDataQualityReport({ q: JSON.stringify({ @@ -109,26 +65,7 @@ export const fetchTotalEntityCount = ( export const fetchTestCaseSummary = ( filters?: DataQualityDashboardChartFilters ) => { - const mustFilter = []; - if (filters?.ownerFqn) { - mustFilter.push(buildMustEsFilterForOwner(filters.ownerFqn)); - } - if (filters?.tags || filters?.tier) { - mustFilter.push( - buildMustEsFilterForTags([ - ...(filters?.tags ?? []), - ...(filters?.tier ?? []), - ]) - ); - } - - if (filters?.entityFQN) { - mustFilter.push({ - term: { - entityFQN: filters.entityFQN, - }, - }); - } + const mustFilter = buildDataQualityDashboardFilters({ filters }); return getDataQualityReport({ q: JSON.stringify({ @@ -147,18 +84,7 @@ export const fetchTestCaseSummary = ( export const fetchTestCaseSummaryByDimension = ( filters?: DataQualityDashboardChartFilters ) => { - const mustFilter = []; - if (filters?.ownerFqn) { - mustFilter.push(buildMustEsFilterForOwner(filters.ownerFqn)); - } - if (filters?.tags || filters?.tier) { - mustFilter.push( - buildMustEsFilterForTags([ - ...(filters?.tags ?? []), - ...(filters?.tier ?? []), - ]) - ); - } + const mustFilter = buildDataQualityDashboardFilters({ filters }); return getDataQualityReport({ q: JSON.stringify({ diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/DataQuality/DataQualityUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/DataQuality/DataQualityUtils.ts index f9959fc49d71..8f5bf9d012d1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/DataQuality/DataQualityUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/DataQuality/DataQualityUtils.ts @@ -32,7 +32,8 @@ import { TestDataType, TestDefinition, } from '../../generated/tests/testDefinition'; -import { ListTestCaseParamsBySearch } from '../../rest/testAPI'; +import { DataQualityDashboardChartFilters } from '../../pages/DataQuality/DataQualityPage.interface'; +import { ListTestCaseParamsBySearch, TestCaseType } from '../../rest/testAPI'; import { generateEntityLink } from '../TableUtils'; /** @@ -218,6 +219,115 @@ export const buildMustEsFilterForOwner = ( }; }; +export const buildDataQualityDashboardFilters = (data: { + filters?: DataQualityDashboardChartFilters; + unhealthy?: boolean; + isTableApi?: boolean; +}) => { + const { filters, unhealthy = false, isTableApi = false } = data; + const mustFilter = []; + + if (unhealthy) { + mustFilter.push({ + terms: { + 'testCaseStatus.keyword': ['Failed', 'Aborted'], + }, + }); + } + + if (filters?.ownerFqn) { + mustFilter.push(buildMustEsFilterForOwner(filters.ownerFqn)); + } + + if (filters?.tags && isTableApi) { + mustFilter.push({ + bool: { + should: filters.tags.map((tag) => ({ + term: { + 'tags.tagFQN': tag, + }, + })), + }, + }); + } + + if (filters?.tier && isTableApi) { + mustFilter.push({ + bool: { + should: filters.tier.map((tag) => ({ + term: { + 'tier.tagFQN': tag, + }, + })), + }, + }); + } + + if ((filters?.tags || filters?.tier) && !isTableApi) { + mustFilter.push( + buildMustEsFilterForTags([ + ...(filters?.tags ?? []), + ...(filters?.tier ?? []), + ]) + ); + } + + if (filters?.entityFQN) { + mustFilter.push({ + term: { + [isTableApi ? 'fullyQualifiedName.keyword' : 'entityFQN']: + filters.entityFQN, + }, + }); + } + + if (filters?.serviceName) { + mustFilter.push({ + term: { + 'service.name.keyword': filters.serviceName, + }, + }); + } + + if (filters?.testPlatforms) { + mustFilter.push({ + terms: { + testPlatforms: filters.testPlatforms, + }, + }); + } + + if (filters?.dataQualityDimension) { + mustFilter.push({ + term: { + dataQualityDimension: filters.dataQualityDimension, + }, + }); + } + + if (filters?.testCaseStatus) { + mustFilter.push({ + term: { + 'testCaseResult.testCaseStatus': filters.testCaseStatus, + }, + }); + } + + if (filters?.testCaseType) { + if (filters.testCaseType === TestCaseType.table) { + mustFilter.push({ + bool: { must_not: [{ regexp: { entityLink: '.*::columns::.*' } }] }, + }); + } + + if (filters.testCaseType === TestCaseType.column) { + mustFilter.push({ regexp: { entityLink: '.*::columns::.*' } }); + } + } + + return mustFilter; +}; + export const getDimensionIcon = (dimension: DataQualityDimensions) => { switch (dimension) { case DataQualityDimensions.Accuracy: From ff9f9063d7e242c17e39a504a2b90b491f9530c2 Mon Sep 17 00:00:00 2001 From: Ashish Gupta Date: Wed, 22 Jan 2025 19:05:54 +0530 Subject: [PATCH 148/294] fix the richTextEditor output in case on no data save (#19476) * fix the richTextEditor output in case on no data save * revert the undefined to empty string as we previously had (cherry picked from commit 06759c589da7b859e10ab31ab0d6cbd838b0069a) --- .../ui/src/components/common/RichTextEditor/RichTextEditor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditor.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditor.tsx index 495c8f35c7f8..f661b74c1029 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditor.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/RichTextEditor/RichTextEditor.tsx @@ -47,7 +47,7 @@ const RichTextEditor = forwardRef( const htmlContent = editorRef.current?.editor?.getHTML() ?? ''; const backendFormat = formatContent(htmlContent, 'server'); - return backendFormat; + return backendFormat === '

' ? '' : backendFormat; }, })); From 54f7985dbf4413caf4c3132e4effd77f9433b9fc Mon Sep 17 00:00:00 2001 From: Abdallah Serghine <76706155+KylixSerg@users.noreply.github.com> Date: Sat, 14 Dec 2024 11:40:23 +0100 Subject: [PATCH 149/294] ISSUE-16094: fix s3 storage parquet structureFormat ingestion (#18660) This aims at fixing the s3 ingestion for parquet files, current behaviour is that the pipeline will break if it encounters a file that is not valid parquet in the the container, this is not great as containers might container non parquet files on purpose like for example _SUCCESS files created by spark. For that do not fail the whole pipeline when a single container fails, instead count it as a failure and move on with the remainder of the containers, this is already an improvement by ideally the ingestion should try a couple more files under the given prefix before given up, additionally we can allow users to specify file patterns to be ignored. Co-authored-by: Abdallah Serghine Co-authored-by: Pere Miquel Brull --- .../ingestion/source/storage/s3/metadata.py | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/ingestion/src/metadata/ingestion/source/storage/s3/metadata.py b/ingestion/src/metadata/ingestion/source/storage/s3/metadata.py index 9949f6163301..6220433d72b5 100644 --- a/ingestion/src/metadata/ingestion/source/storage/s3/metadata.py +++ b/ingestion/src/metadata/ingestion/source/storage/s3/metadata.py @@ -294,15 +294,26 @@ def _generate_container_details( ) # if we have a sample file to fetch a schema from if sample_key: - columns = self._get_columns( - container_name=bucket_name, - sample_key=sample_key, - metadata_entry=metadata_entry, - config_source=S3Config( - securityConfig=self.service_connection.awsConfig - ), - client=self.s3_client, - ) + try: + columns = self._get_columns( + container_name=bucket_name, + sample_key=sample_key, + metadata_entry=metadata_entry, + config_source=S3Config( + securityConfig=self.service_connection.awsConfig + ), + client=self.s3_client, + ) + except Exception as err: + logger.warning() + self.status.failed( + error=StackTraceError( + name=f"{bucket_name}/{sample_key}", + error=f"Error extracting columns from [{bucket_name}/{sample_key}] due to: [{err}]", + stackTrace=traceback.format_exc(), + ) + ) + return None if columns: prefix = ( f"{KEY_SEPARATOR}{metadata_entry.dataPath.strip(KEY_SEPARATOR)}" @@ -460,7 +471,7 @@ def _yield_nested_unstructured_containers( candidate_keys = [ entry["Key"] for entry in response[S3_CLIENT_ROOT_RESPONSE] - if entry and entry.get("Key") + if entry and entry.get("Key") and not entry.get("Key").endswith("/") ] for key in candidate_keys: if self.is_valid_unstructured_file(metadata_entry.unstructuredFormats, key): @@ -669,7 +680,7 @@ def _get_sample_file_path( candidate_keys = [ entry["Key"] for entry in response[S3_CLIENT_ROOT_RESPONSE] - if entry and entry.get("Key") + if entry and entry.get("Key") and not entry.get("Key").endswith("/") ] # pick a random key out of the candidates if any were returned if candidate_keys: From 95501d850e442bb63a5e1263a3bb086c609cf03d Mon Sep 17 00:00:00 2001 From: IceS2 Date: Thu, 23 Jan 2025 10:02:52 +0100 Subject: [PATCH 150/294] FIX #19386 & #19388: Fixing Data Insights index mapping (#19423) * Fixing Data Insights index mapping * Add OpenMetadataOperations cli endpoint to reindex data insights * Improve IndexMapTemplate building * Improve the code a bit * Fix test (cherry picked from commit 901063b80262a7fe2a43d5d9a2ed51529aaced1a) --- .../bundles/insights/DataInsightsApp.java | 18 ++- .../DataInsightsSearchConfiguration.java | 10 ++ .../search/DataInsightsSearchInterface.java | 62 +++++++- .../insights/search/EntityIndexMap.java | 18 +++ .../insights/search/IndexMappingTemplate.java | 8 ++ .../ElasticSearchDataInsightsClient.java | 11 +- .../OpenSearchDataInsightsClient.java | 11 +- .../dataAssets/DataAssetsWorkflow.java | 14 +- .../DataInsightsEntityEnricherProcessor.java | 3 + .../service/util/OpenMetadataOperations.java | 102 ++++++++++++++ .../main/resources/dataInsights/config.json | 122 ++++++++++++++++ .../elasticsearch/indexMappingsTemplate.json | 132 +----------------- .../opensearch/indexMappingsTemplate.json | 132 +----------------- .../en/pipeline_index_mapping.json | 9 ++ .../resources/apps/AppsResourceTest.java | 2 +- .../resources/kpi/KpiResourceTest.java | 6 +- 16 files changed, 396 insertions(+), 264 deletions(-) create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/DataInsightsSearchConfiguration.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/EntityIndexMap.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/IndexMappingTemplate.java create mode 100644 openmetadata-service/src/main/resources/dataInsights/config.json diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/DataInsightsApp.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/DataInsightsApp.java index d0b5faf7b6ed..d46f625b6bbd 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/DataInsightsApp.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/DataInsightsApp.java @@ -146,11 +146,19 @@ private void deleteDataQualityDataIndex() { private void createDataAssetsDataStream() { DataInsightsSearchInterface searchInterface = getSearchInterface(); + ElasticSearchConfiguration config = searchRepository.getElasticSearchConfiguration(); + String language = + config != null && config.getSearchIndexMappingLanguage() != null + ? config.getSearchIndexMappingLanguage().value() + : "en"; + try { for (String dataAssetType : dataAssetTypes) { + IndexMapping dataAssetIndex = searchRepository.getIndexMapping(dataAssetType); String dataStreamName = getDataStreamName(dataAssetType); if (!searchInterface.dataAssetDataStreamExists(dataStreamName)) { - searchInterface.createDataAssetsDataStream(dataStreamName); + searchInterface.createDataAssetsDataStream( + dataStreamName, dataAssetType, dataAssetIndex, language); } } } catch (IOException ex) { @@ -312,7 +320,13 @@ private WorkflowStats processCostAnalysis() { private WorkflowStats processDataAssets() { DataAssetsWorkflow workflow = new DataAssetsWorkflow( - timestamp, batchSize, backfill, dataAssetTypes, collectionDAO, searchRepository); + timestamp, + batchSize, + backfill, + dataAssetTypes, + collectionDAO, + searchRepository, + getSearchInterface()); WorkflowStats workflowStats = workflow.getWorkflowStats(); try { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/DataInsightsSearchConfiguration.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/DataInsightsSearchConfiguration.java new file mode 100644 index 000000000000..d9fc4dc1672f --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/DataInsightsSearchConfiguration.java @@ -0,0 +1,10 @@ +package org.openmetadata.service.apps.bundles.insights.search; + +import java.util.List; +import java.util.Map; +import lombok.Getter; + +@Getter +public class DataInsightsSearchConfiguration { + private Map> mappingFields; +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/DataInsightsSearchInterface.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/DataInsightsSearchInterface.java index 0d06b3e03be2..3cba896e8ee5 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/DataInsightsSearchInterface.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/DataInsightsSearchInterface.java @@ -2,9 +2,13 @@ import java.io.IOException; import java.io.InputStream; +import java.util.List; import org.openmetadata.service.exception.UnhandledServerException; +import org.openmetadata.service.search.models.IndexMapping; +import org.openmetadata.service.util.JsonUtils; public interface DataInsightsSearchInterface { + String DATA_INSIGHTS_SEARCH_CONFIG_PATH = "/dataInsights/config.json"; void createLifecyclePolicy(String name, String policy) throws IOException; @@ -23,7 +27,63 @@ default String readResource(String resourceFile) { } } - void createDataAssetsDataStream(String name) throws IOException; + default String buildMapping( + String entityType, + IndexMapping entityIndexMapping, + String language, + String indexMappingTemplateStr) { + IndexMappingTemplate indexMappingTemplate = + JsonUtils.readOrConvertValue(indexMappingTemplateStr, IndexMappingTemplate.class); + EntityIndexMap entityIndexMap = + JsonUtils.readOrConvertValue( + readResource( + String.format(entityIndexMapping.getIndexMappingFile(), language.toLowerCase())), + EntityIndexMap.class); + + DataInsightsSearchConfiguration dataInsightsSearchConfiguration = + readDataInsightsSearchConfiguration(); + List entityAttributeFields = + getEntityAttributeFields(dataInsightsSearchConfiguration, entityType); + + indexMappingTemplate + .getTemplate() + .getSettings() + .put("analysis", entityIndexMap.getSettings().get("analysis")); + + for (String attribute : entityAttributeFields) { + if (!indexMappingTemplate + .getTemplate() + .getMappings() + .getProperties() + .containsKey(attribute)) { + Object value = entityIndexMap.getMappings().getProperties().get(attribute); + if (value != null) { + indexMappingTemplate.getTemplate().getMappings().getProperties().put(attribute, value); + } + } + } + + return JsonUtils.pojoToJson(indexMappingTemplate); + } + + default DataInsightsSearchConfiguration readDataInsightsSearchConfiguration() { + return JsonUtils.readOrConvertValue( + readResource(DATA_INSIGHTS_SEARCH_CONFIG_PATH), DataInsightsSearchConfiguration.class); + } + + default List getEntityAttributeFields( + DataInsightsSearchConfiguration dataInsightsSearchConfiguration, String entityType) { + List entityAttributeFields = + dataInsightsSearchConfiguration.getMappingFields().get("common"); + entityAttributeFields.addAll( + dataInsightsSearchConfiguration.getMappingFields().get(entityType)); + + return entityAttributeFields; + } + + void createDataAssetsDataStream( + String name, String entityType, IndexMapping entityIndexMapping, String language) + throws IOException; void deleteDataAssetDataStream(String name) throws IOException; diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/EntityIndexMap.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/EntityIndexMap.java new file mode 100644 index 000000000000..8a5e9b64dbbf --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/EntityIndexMap.java @@ -0,0 +1,18 @@ +package org.openmetadata.service.apps.bundles.insights.search; + +import java.util.Map; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class EntityIndexMap { + private Map settings; + private Mappings mappings; + + @Getter + @Setter + public static class Mappings { + private Map properties; + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/IndexMappingTemplate.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/IndexMappingTemplate.java new file mode 100644 index 000000000000..c418ab63b757 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/IndexMappingTemplate.java @@ -0,0 +1,8 @@ +package org.openmetadata.service.apps.bundles.insights.search; + +import lombok.Getter; + +@Getter +public class IndexMappingTemplate { + private EntityIndexMap template; +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/elasticsearch/ElasticSearchDataInsightsClient.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/elasticsearch/ElasticSearchDataInsightsClient.java index 150fc48c33cc..d5a1bbab55cd 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/elasticsearch/ElasticSearchDataInsightsClient.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/elasticsearch/ElasticSearchDataInsightsClient.java @@ -5,6 +5,7 @@ import es.org.elasticsearch.client.RestClient; import java.io.IOException; import org.openmetadata.service.apps.bundles.insights.search.DataInsightsSearchInterface; +import org.openmetadata.service.search.models.IndexMapping; public class ElasticSearchDataInsightsClient implements DataInsightsSearchInterface { private final RestClient client; @@ -52,7 +53,9 @@ public Boolean dataAssetDataStreamExists(String name) throws IOException { } @Override - public void createDataAssetsDataStream(String name) throws IOException { + public void createDataAssetsDataStream( + String name, String entityType, IndexMapping entityIndexMapping, String language) + throws IOException { String resourcePath = "/dataInsights/elasticsearch"; createLifecyclePolicy( "di-data-assets-lifecycle", @@ -62,7 +65,11 @@ public void createDataAssetsDataStream(String name) throws IOException { readResource(String.format("%s/indexSettingsTemplate.json", resourcePath))); createComponentTemplate( "di-data-assets-mapping", - readResource(String.format("%s/indexMappingsTemplate.json", resourcePath))); + buildMapping( + entityType, + entityIndexMapping, + language, + readResource(String.format("%s/indexMappingsTemplate.json", resourcePath)))); createIndexTemplate( "di-data-assets", readResource(String.format("%s/indexTemplate.json", resourcePath))); createDataStream(name); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/opensearch/OpenSearchDataInsightsClient.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/opensearch/OpenSearchDataInsightsClient.java index da94394a3a5f..8d8096a8adfa 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/opensearch/OpenSearchDataInsightsClient.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/opensearch/OpenSearchDataInsightsClient.java @@ -2,6 +2,7 @@ import java.io.IOException; import org.openmetadata.service.apps.bundles.insights.search.DataInsightsSearchInterface; +import org.openmetadata.service.search.models.IndexMapping; import os.org.opensearch.client.Request; import os.org.opensearch.client.Response; import os.org.opensearch.client.ResponseException; @@ -63,14 +64,20 @@ public Boolean dataAssetDataStreamExists(String name) throws IOException { } @Override - public void createDataAssetsDataStream(String name) throws IOException { + public void createDataAssetsDataStream( + String name, String entityType, IndexMapping entityIndexMapping, String language) + throws IOException { String resourcePath = "/dataInsights/opensearch"; createLifecyclePolicy( "di-data-assets-lifecycle", readResource(String.format("%s/indexLifecyclePolicy.json", resourcePath))); createComponentTemplate( "di-data-assets-mapping", - readResource(String.format("%s/indexMappingsTemplate.json", resourcePath))); + buildMapping( + entityType, + entityIndexMapping, + language, + readResource(String.format("%s/indexMappingsTemplate.json", resourcePath)))); createIndexTemplate( "di-data-assets", readResource(String.format("%s/indexTemplate.json", resourcePath))); createDataStream(name); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/workflows/dataAssets/DataAssetsWorkflow.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/workflows/dataAssets/DataAssetsWorkflow.java index 2e30832ea5d8..938cba712766 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/workflows/dataAssets/DataAssetsWorkflow.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/workflows/dataAssets/DataAssetsWorkflow.java @@ -23,6 +23,8 @@ import org.openmetadata.schema.system.StepStats; import org.openmetadata.schema.type.Include; import org.openmetadata.service.apps.bundles.insights.DataInsightsApp; +import org.openmetadata.service.apps.bundles.insights.search.DataInsightsSearchConfiguration; +import org.openmetadata.service.apps.bundles.insights.search.DataInsightsSearchInterface; import org.openmetadata.service.apps.bundles.insights.utils.TimestampUtils; import org.openmetadata.service.apps.bundles.insights.workflows.WorkflowStats; import org.openmetadata.service.apps.bundles.insights.workflows.dataAssets.processors.DataInsightsElasticSearchProcessor; @@ -43,6 +45,7 @@ @Slf4j public class DataAssetsWorkflow { public static final String DATA_STREAM_KEY = "DataStreamKey"; + public static final String ENTITY_TYPE_FIELDS_KEY = "EnityTypeFields"; private final int retentionDays = 30; private final Long startTimestamp; private final Long endTimestamp; @@ -51,6 +54,8 @@ public class DataAssetsWorkflow { private final CollectionDAO collectionDAO; private final List sources = new ArrayList<>(); private final Set entityTypes; + private final DataInsightsSearchConfiguration dataInsightsSearchConfiguration; + private final DataInsightsSearchInterface searchInterface; private DataInsightsEntityEnricherProcessor entityEnricher; private Processor entityProcessor; @@ -63,7 +68,8 @@ public DataAssetsWorkflow( Optional backfill, Set entityTypes, CollectionDAO collectionDAO, - SearchRepository searchRepository) { + SearchRepository searchRepository, + DataInsightsSearchInterface searchInterface) { if (backfill.isPresent()) { Long oldestPossibleTimestamp = TimestampUtils.getStartOfDayTimestamp( @@ -95,6 +101,8 @@ public DataAssetsWorkflow( this.searchRepository = searchRepository; this.collectionDAO = collectionDAO; this.entityTypes = entityTypes; + this.searchInterface = searchInterface; + this.dataInsightsSearchConfiguration = searchInterface.readDataInsightsSearchConfiguration(); } private void initialize() { @@ -146,6 +154,10 @@ public void process() throws SearchIndexException { deleteDataBeforeInserting(getDataStreamName(source.getEntityType())); contextData.put(DATA_STREAM_KEY, getDataStreamName(source.getEntityType())); contextData.put(ENTITY_TYPE_KEY, source.getEntityType()); + contextData.put( + ENTITY_TYPE_FIELDS_KEY, + searchInterface.getEntityAttributeFields( + dataInsightsSearchConfiguration, source.getEntityType())); while (!source.isDone().get()) { try { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/workflows/dataAssets/processors/DataInsightsEntityEnricherProcessor.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/workflows/dataAssets/processors/DataInsightsEntityEnricherProcessor.java index a1714dc07eb3..c54ada72491c 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/workflows/dataAssets/processors/DataInsightsEntityEnricherProcessor.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/workflows/dataAssets/processors/DataInsightsEntityEnricherProcessor.java @@ -3,6 +3,7 @@ import static org.openmetadata.schema.EntityInterface.ENTITY_TYPE_TO_CLASS_MAP; import static org.openmetadata.service.apps.bundles.insights.utils.TimestampUtils.END_TIMESTAMP_KEY; import static org.openmetadata.service.apps.bundles.insights.utils.TimestampUtils.START_TIMESTAMP_KEY; +import static org.openmetadata.service.apps.bundles.insights.workflows.dataAssets.DataAssetsWorkflow.ENTITY_TYPE_FIELDS_KEY; import static org.openmetadata.service.workflows.searchIndex.ReindexingUtil.ENTITY_TYPE_KEY; import static org.openmetadata.service.workflows.searchIndex.ReindexingUtil.TIMESTAMP_KEY; import static org.openmetadata.service.workflows.searchIndex.ReindexingUtil.getUpdatedStats; @@ -141,6 +142,8 @@ private Map enrichEntity( Long endTimestamp = (Long) entityVersionMap.get("endTimestamp"); Map entityMap = JsonUtils.getMap(entity); + entityMap.keySet().retainAll((List) contextData.get(ENTITY_TYPE_FIELDS_KEY)); + String entityType = (String) contextData.get(ENTITY_TYPE_KEY); List> interfaces = List.of(entity.getClass().getInterfaces()); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/util/OpenMetadataOperations.java b/openmetadata-service/src/main/java/org/openmetadata/service/util/OpenMetadataOperations.java index c23c0d8860e8..c48661206a2b 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/util/OpenMetadataOperations.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/util/OpenMetadataOperations.java @@ -4,6 +4,7 @@ import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; import static org.openmetadata.service.Entity.ADMIN_USER_NAME; import static org.openmetadata.service.Entity.FIELD_OWNERS; +import static org.openmetadata.service.apps.bundles.insights.utils.TimestampUtils.timestampToString; import static org.openmetadata.service.formatter.decorators.MessageDecorator.getDateStringEpochMilli; import static org.openmetadata.service.jdbi3.UserRepository.AUTH_MECHANISM_FIELD; import static org.openmetadata.service.util.AsciiTable.printOpenMetadataText; @@ -30,6 +31,7 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Scanner; import java.util.Set; import java.util.concurrent.Callable; @@ -49,6 +51,8 @@ import org.openmetadata.schema.entity.app.AppSchedule; import org.openmetadata.schema.entity.app.CreateApp; import org.openmetadata.schema.entity.app.ScheduleTimeline; +import org.openmetadata.schema.entity.applications.configuration.internal.BackfillConfiguration; +import org.openmetadata.schema.entity.applications.configuration.internal.DataInsightsAppConfig; import org.openmetadata.schema.entity.services.ingestionPipelines.IngestionPipeline; import org.openmetadata.schema.entity.teams.User; import org.openmetadata.schema.services.connections.metadata.AuthProvider; @@ -513,6 +517,104 @@ private int executeSearchReindexApp( return result; } + @Command( + name = "reindexdi", + description = "Re Indexes data insights into search engine from command line.") + public Integer reIndexDI( + @Option( + names = {"-b", "--batch-size"}, + defaultValue = "100", + description = "Number of records to process in each batch.") + int batchSize, + @Option( + names = {"--recreate-indexes"}, + defaultValue = "true", + description = "Flag to determine if indexes should be recreated.") + boolean recreateIndexes, + @Option( + names = {"--start-date"}, + description = "Start Date to backfill from.") + String startDate, + @Option( + names = {"--end-date"}, + description = "End Date to backfill to.") + String endDate) { + try { + LOG.info( + "Running Reindexing with Batch Size: {}, Recreate-Index: {}, Start Date: {}, End Date: {}.", + batchSize, + recreateIndexes, + startDate, + endDate); + parseConfig(); + CollectionRegistry.initialize(); + ApplicationHandler.initialize(config); + CollectionRegistry.getInstance().loadSeedData(jdbi, config, null, null, null, true); + ApplicationHandler.initialize(config); + AppScheduler.initialize(config, collectionDAO, searchRepository); + String appName = "DataInsightsApplication"; + return executeDataInsightsReindexApp( + appName, batchSize, recreateIndexes, getBackfillConfiguration(startDate, endDate)); + } catch (Exception e) { + LOG.error("Failed to reindex due to ", e); + return 1; + } + } + + private BackfillConfiguration getBackfillConfiguration(String startDate, String endDate) { + BackfillConfiguration backfillConfiguration = new BackfillConfiguration(); + backfillConfiguration.withEnabled(false); + + if (startDate != null) { + backfillConfiguration.withEnabled(true); + backfillConfiguration.withStartDate(startDate); + backfillConfiguration.withEndDate( + Objects.requireNonNullElseGet( + endDate, () -> timestampToString(System.currentTimeMillis(), "yyyy-MM-dd"))); + } + return backfillConfiguration; + } + + private int executeDataInsightsReindexApp( + String appName, + int batchSize, + boolean recreateIndexes, + BackfillConfiguration backfillConfiguration) { + AppRepository appRepository = (AppRepository) Entity.getEntityRepository(Entity.APPLICATION); + App originalDataInsightsApp = + appRepository.getByName(null, appName, appRepository.getFields("id")); + + DataInsightsAppConfig storedConfig = + JsonUtils.convertValue( + originalDataInsightsApp.getAppConfiguration(), DataInsightsAppConfig.class); + + DataInsightsAppConfig updatedConfig = + JsonUtils.deepCopy(storedConfig, DataInsightsAppConfig.class); + updatedConfig + .withBatchSize(batchSize) + .withRecreateDataAssetsIndex(recreateIndexes) + .withBackfillConfiguration(backfillConfiguration); + + // Update the data insights app with the new configurations + App updatedDataInsightsApp = JsonUtils.deepCopy(originalDataInsightsApp, App.class); + updatedDataInsightsApp.withAppConfiguration(updatedConfig); + JsonPatch patch = JsonUtils.getJsonPatch(originalDataInsightsApp, updatedDataInsightsApp); + + appRepository.patch(null, originalDataInsightsApp.getId(), "admin", patch); + + // Trigger Application + long currentTime = System.currentTimeMillis(); + AppScheduler.getInstance().triggerOnDemandApplication(updatedDataInsightsApp); + + int result = waitAndReturnReindexingAppStatus(updatedDataInsightsApp, currentTime); + + // Re-patch with original configuration + JsonPatch repatch = JsonUtils.getJsonPatch(updatedDataInsightsApp, originalDataInsightsApp); + appRepository.patch(null, originalDataInsightsApp.getId(), "admin", repatch); + + return result; + } + @SneakyThrows private int waitAndReturnReindexingAppStatus(App searchIndexApp, long startTime) { AppRunRecord appRunRecord = null; diff --git a/openmetadata-service/src/main/resources/dataInsights/config.json b/openmetadata-service/src/main/resources/dataInsights/config.json new file mode 100644 index 000000000000..dcf608489841 --- /dev/null +++ b/openmetadata-service/src/main/resources/dataInsights/config.json @@ -0,0 +1,122 @@ +{ + "mappingFields": { + "common": [ + "id", + "description", + "displayName", + "name", + "deleted", + "version", + "owners", + "tags", + "followers", + "extension", + "votes", + "fullyQualifiedName", + "domain", + "dataProducts", + "certification" + ], + "table": [ + "tableType", + "columns", + "databaseSchema", + "database", + "service", + "serviceType" + ], + "storedProcedure": [ + "storedProcedureType", + "databaseSchema", + "database", + "service", + "serviceType" + ], + "databaseSchema": [ + "database", + "service", + "serviceType" + ], + "database": [ + "service", + "serviceType" + ], + "chart": [ + "service", + "serviceType", + "chartType" + ], + "dashboard": [ + "service", + "serviceType", + "dashboardType" + ], + "dashboardDataModel": [ + "service", + "serviceType", + "dataModelType", + "project", + "columns" + ], + "pipeline": [ + "service", + "serviceType", + "pipelineStatus", + "tasks" + ], + "topic": [ + "service", + "serviceType" + ], + "container": [ + "service", + "serviceType", + "numberOfObjects", + "size", + "fileFormats", + "parent", + "children", + "prefix" + ], + "searchIndex": [ + "service", + "serviceType", + "indexType", + "fields" + ], + "mlmodel": [ + "service", + "serviceType", + "mlStore", + "algorithm", + "mlFeatures", + "mlHyperParameters", + "target", + "dashboard", + "server" + ], + "dataProduct": [ + "experts", + "domain", + "assets" + ], + "glossaryTerm": [ + "synonyms", + "glossary", + "parent", + "children", + "relatedTerms", + "references", + "reviewers", + "status", + "usageCount", + "childrenCount" + ], + "tag": [ + "classification", + "parent", + "children", + "usageCount" + ] + } +} \ No newline at end of file diff --git a/openmetadata-service/src/main/resources/dataInsights/elasticsearch/indexMappingsTemplate.json b/openmetadata-service/src/main/resources/dataInsights/elasticsearch/indexMappingsTemplate.json index f8fdd93ff83c..19da7fc9120d 100644 --- a/openmetadata-service/src/main/resources/dataInsights/elasticsearch/indexMappingsTemplate.json +++ b/openmetadata-service/src/main/resources/dataInsights/elasticsearch/indexMappingsTemplate.json @@ -1,138 +1,16 @@ { "template": { - "settings": { - "analysis": { - "normalizer": { - "lowercase_normalizer": { - "type": "custom", - "char_filter": [], - "filter": [ - "lowercase" - ] - } - }, - "analyzer": { - "om_analyzer": { - "tokenizer": "letter", - "filter": [ - "lowercase", - "om_stemmer" - ] - }, - "om_ngram": { - "tokenizer": "ngram", - "min_gram": 3, - "max_gram": 10, - "filter": [ - "lowercase" - ] - } - }, - "filter": { - "om_stemmer": { - "type": "stemmer", - "name": "english" - } - } - } - }, + "settings": {}, "mappings": { "properties": { "@timestamp": { "type": "date" }, - "owners": { - "properties": { - "id": { - "type": "keyword", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 36 - } - } - }, - "type": { - "type": "keyword" - }, - "name": { - "type": "keyword", - "normalizer": "lowercase_normalizer", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "displayName": { - "type": "keyword", - "fields": { - "keyword": { - "type": "keyword", - "normalizer": "lowercase_normalizer", - "ignore_above": 256 - } - } - }, - "fullyQualifiedName": { - "type": "text" - }, - "description": { - "type": "text" - }, - "deleted": { - "type": "text" - }, - "href": { - "type": "text" - } - } - }, - "domain": { - "properties": { - "id": { - "type": "keyword", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 36 - } - } - }, - "type": { - "type": "keyword" - }, - "name": { - "type": "keyword", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "displayName": { - "type": "keyword", - "fields": { - "keyword": { - "type": "keyword", - "normalizer": "lowercase_normalizer", - "ignore_above": 256 - } - } - }, - "fullyQualifiedName": { + "id": { + "type": "text", + "fields": { + "keyword": { "type": "keyword" - }, - "description": { - "type": "text" - }, - "deleted": { - "type": "text" - }, - "href": { - "type": "text" } } } diff --git a/openmetadata-service/src/main/resources/dataInsights/opensearch/indexMappingsTemplate.json b/openmetadata-service/src/main/resources/dataInsights/opensearch/indexMappingsTemplate.json index f8fdd93ff83c..19da7fc9120d 100644 --- a/openmetadata-service/src/main/resources/dataInsights/opensearch/indexMappingsTemplate.json +++ b/openmetadata-service/src/main/resources/dataInsights/opensearch/indexMappingsTemplate.json @@ -1,138 +1,16 @@ { "template": { - "settings": { - "analysis": { - "normalizer": { - "lowercase_normalizer": { - "type": "custom", - "char_filter": [], - "filter": [ - "lowercase" - ] - } - }, - "analyzer": { - "om_analyzer": { - "tokenizer": "letter", - "filter": [ - "lowercase", - "om_stemmer" - ] - }, - "om_ngram": { - "tokenizer": "ngram", - "min_gram": 3, - "max_gram": 10, - "filter": [ - "lowercase" - ] - } - }, - "filter": { - "om_stemmer": { - "type": "stemmer", - "name": "english" - } - } - } - }, + "settings": {}, "mappings": { "properties": { "@timestamp": { "type": "date" }, - "owners": { - "properties": { - "id": { - "type": "keyword", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 36 - } - } - }, - "type": { - "type": "keyword" - }, - "name": { - "type": "keyword", - "normalizer": "lowercase_normalizer", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "displayName": { - "type": "keyword", - "fields": { - "keyword": { - "type": "keyword", - "normalizer": "lowercase_normalizer", - "ignore_above": 256 - } - } - }, - "fullyQualifiedName": { - "type": "text" - }, - "description": { - "type": "text" - }, - "deleted": { - "type": "text" - }, - "href": { - "type": "text" - } - } - }, - "domain": { - "properties": { - "id": { - "type": "keyword", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 36 - } - } - }, - "type": { - "type": "keyword" - }, - "name": { - "type": "keyword", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "displayName": { - "type": "keyword", - "fields": { - "keyword": { - "type": "keyword", - "normalizer": "lowercase_normalizer", - "ignore_above": 256 - } - } - }, - "fullyQualifiedName": { + "id": { + "type": "text", + "fields": { + "keyword": { "type": "keyword" - }, - "description": { - "type": "text" - }, - "deleted": { - "type": "text" - }, - "href": { - "type": "text" } } } diff --git a/openmetadata-service/src/main/resources/elasticsearch/en/pipeline_index_mapping.json b/openmetadata-service/src/main/resources/elasticsearch/en/pipeline_index_mapping.json index c79e23422519..3c0cd989c72c 100644 --- a/openmetadata-service/src/main/resources/elasticsearch/en/pipeline_index_mapping.json +++ b/openmetadata-service/src/main/resources/elasticsearch/en/pipeline_index_mapping.json @@ -204,6 +204,9 @@ } } }, + "startDate": { + "type": "text" + }, "tasks": { "properties": { "name": { @@ -234,6 +237,12 @@ }, "taskType": { "type": "text" + }, + "startDate": { + "type": "text" + }, + "endDate": { + "type": "text" } } }, diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/apps/AppsResourceTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/apps/AppsResourceTest.java index e14532595c78..69fe5eaa3c53 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/resources/apps/AppsResourceTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/apps/AppsResourceTest.java @@ -303,7 +303,7 @@ void validate_data_insights_workflow_is_correct_for_a_simple_case() Request request = new Request("GET", "di-data-assets-*/_search"); String payload = String.format( - "{\"query\":{\"bool\":{\"must\":{\"term\":{\"fullyQualifiedName.keyword\":\"%s\"}}}}}", + "{\"query\":{\"bool\":{\"must\":{\"term\":{\"fullyQualifiedName\":\"%s\"}}}}}", table.getFullyQualifiedName()); request.setJsonEntity(payload); response = searchClient.performRequest(request); diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/kpi/KpiResourceTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/kpi/KpiResourceTest.java index 32af712a0293..dd6af914e425 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/resources/kpi/KpiResourceTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/kpi/KpiResourceTest.java @@ -82,7 +82,11 @@ private void createDataAssetsDataStream() { String dataStreamName = String.format("%s-%s", "di-data-assets", dataAssetType).toLowerCase(); if (!searchInterface.dataAssetDataStreamExists(dataStreamName)) { - searchInterface.createDataAssetsDataStream(dataStreamName); + searchInterface.createDataAssetsDataStream( + dataStreamName, + dataAssetType, + getSearchRepository().getIndexMapping(dataAssetType), + "en"); } } } catch (IOException ex) { From 4abe64087ae4910966a5cc4c48f4edefc364d0e7 Mon Sep 17 00:00:00 2001 From: Pranita Fulsundar Date: Thu, 23 Jan 2025 18:56:46 +0530 Subject: [PATCH 151/294] fix: edit description permission for domain owner (#19475) * fix: edit description permission for domain owner * fix: usage of permissions prop * test: fix unit test and add e2e test for edit description * refactor: remove editCustomAttributePermission and viewAllPermission props * fix: e2e test for edit description * fix: playwright tests * fix: update e2e test for edit description (cherry picked from commit 4059df8843afe472f27b3712a2a7ef562f1f5f10) --- .../ui/playwright/e2e/Pages/Domains.spec.ts | 83 ++++++++++++++++++- .../ui/playwright/support/domain/Domain.ts | 25 ++++++ .../ui/playwright/support/domain/SubDomain.ts | 25 ++++++ .../DataProductsDetailsPage.component.tsx | 7 +- .../DomainDetailsPage.component.tsx | 43 ++++++---- .../DocumentationTab.component.tsx | 67 +++++++-------- .../DocumentationTab.interface.ts | 4 +- .../DocumentationTab.test.tsx | 6 +- 8 files changed, 192 insertions(+), 68 deletions(-) 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 c53c42e60b83..e2f13affea7e 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 @@ -10,7 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import test, { expect } from '@playwright/test'; +import base, { expect, Page } from '@playwright/test'; import { Operation } from 'fast-json-patch'; import { get } from 'lodash'; import { SidebarItem } from '../../constant/sidebar'; @@ -40,11 +40,53 @@ import { import { sidebarClick } from '../../utils/sidebar'; import { performUserLogin, visitUserProfilePage } from '../../utils/user'; -test.describe('Domains', () => { - test.use({ storageState: 'playwright/.auth/admin.json' }); +const user = new UserClass(); + +const domain = new Domain(); + +const test = base.extend<{ + page: Page; + userPage: Page; +}>({ + page: async ({ browser }, use) => { + const { page } = await performAdminLogin(browser); + await use(page); + await page.close(); + }, + userPage: async ({ browser }, use) => { + const page = await browser.newPage(); + await user.login(page); + await use(page); + await page.close(); + }, +}); +test.describe('Domains', () => { test.slow(true); + test.beforeAll('Setup pre-requests', async ({ browser }) => { + const { apiContext, afterAction } = await performAdminLogin(browser); + await user.create(apiContext); + + await domain.create(apiContext); + + await domain.patch({ + apiContext, + patchData: [ + { + op: 'add', + path: '/owners/0', + value: { + id: user.responseData.id, + type: 'user', + }, + }, + ], + }); + + await afterAction(); + }); + test.beforeEach('Visit home page', async ({ page }) => { await redirectToHomePage(page); }); @@ -389,6 +431,41 @@ test.describe('Domains', () => { await afterAction(); } }); + + test('Domain owner should able to edit description of domain', async ({ + page, + userPage, + }) => { + const { afterAction, apiContext } = await getApiContext(page); + try { + await sidebarClick(userPage, SidebarItem.DOMAIN); + await selectDomain(userPage, domain.data); + + await expect(userPage.getByTestId('edit-description')).toBeInViewport(); + + await userPage.getByTestId('edit-description').click(); + + await expect(userPage.getByTestId('editor')).toBeInViewport(); + + const descriptionInputBox = '.om-block-editor[contenteditable="true"]'; + + await userPage.fill(descriptionInputBox, 'test description'); + + await userPage.getByTestId('save').click(); + + await userPage.waitForTimeout(3000); + + const descriptionBox = '.om-block-editor[contenteditable="false"]'; + + await expect(userPage.locator(descriptionBox)).toHaveText( + 'test description' + ); + } finally { + await domain?.delete(apiContext); + await user.delete(apiContext); + } + await afterAction(); + }); }); test.describe('Domains Rbac', () => { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/domain/Domain.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/domain/Domain.ts index e3cbee7ed962..93a982f08abc 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/domain/Domain.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/domain/Domain.ts @@ -11,6 +11,7 @@ * limitations under the License. */ import { APIRequestContext } from '@playwright/test'; +import { Operation } from 'fast-json-patch'; import { uuid } from '../../utils/common'; type UserTeamRef = { @@ -72,4 +73,28 @@ export class Domain { return response.body; } + + async patch({ + apiContext, + patchData, + }: { + apiContext: APIRequestContext; + patchData: Operation[]; + }) { + const response = await apiContext.patch( + `/api/v1/domains/${this.responseData?.id}`, + { + data: patchData, + headers: { + 'Content-Type': 'application/json-patch+json', + }, + } + ); + + this.responseData = await response.json(); + + return { + entity: this.responseData, + }; + } } diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/domain/SubDomain.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/domain/SubDomain.ts index dabe1cafb841..41d5b09c9b32 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/domain/SubDomain.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/domain/SubDomain.ts @@ -11,6 +11,7 @@ * limitations under the License. */ import { APIRequestContext } from '@playwright/test'; +import { Operation } from 'fast-json-patch'; import { uuid } from '../../utils/common'; import { Domain } from './Domain'; @@ -76,4 +77,28 @@ export class SubDomain { return response.body; } + + async patch({ + apiContext, + patchData, + }: { + apiContext: APIRequestContext; + patchData: Operation[]; + }) { + const response = await apiContext.patch( + `/api/v1/domains/${this.responseData?.id}`, + { + data: patchData, + headers: { + 'Content-Type': 'application/json-patch+json', + }, + } + ); + + this.responseData = await response.json(); + + return { + entity: this.responseData, + }; + } } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx index b57c43f75036..bf3513bd515e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx @@ -411,14 +411,9 @@ const DataProductsDetailsPage = ({ children: ( onUpdate(data as DataProduct) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetailsPage/DomainDetailsPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetailsPage/DomainDetailsPage.component.tsx index f9fe1a18d423..58953c2f9e50 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetailsPage/DomainDetailsPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetailsPage/DomainDetailsPage.component.tsx @@ -122,7 +122,7 @@ const DomainDetailsPage = ({ }: DomainDetailsPageProps) => { const { t } = useTranslation(); const [form] = useForm(); - const { getEntityPermission } = usePermissionProvider(); + const { getEntityPermission, permissions } = usePermissionProvider(); const history = useHistory(); const { tab: activeTab, version } = useParams<{ tab: string; version: string }>(); @@ -202,21 +202,29 @@ const DomainDetailsPage = ({ }, [domainPermission]); const addButtonContent = [ - { - label: t('label.asset-plural'), - key: '1', - onClick: () => setAssetModalVisible(true), - }, - { - label: t('label.sub-domain-plural'), - key: '2', - onClick: () => setShowAddSubDomainModal(true), - }, - { - label: t('label.data-product-plural'), - key: '3', - onClick: () => setShowAddDataProductModal(true), - }, + ...(domainPermission.Create + ? [ + { + label: t('label.asset-plural'), + key: '1', + onClick: () => setAssetModalVisible(true), + }, + { + label: t('label.sub-domain-plural'), + key: '2', + onClick: () => setShowAddSubDomainModal(true), + }, + ] + : []), + ...(permissions.dataProduct.Create + ? [ + { + label: t('label.data-product-plural'), + key: '3', + onClick: () => setShowAddDataProductModal(true), + }, + ] + : []), ]; const fetchSubDomains = useCallback(async () => { @@ -511,6 +519,7 @@ const DomainDetailsPage = ({ onUpdate(data as Domain)} /> ), @@ -667,7 +676,7 @@ const DomainDetailsPage = ({
- {!isVersionsView && domainPermission.Create && ( + {!isVersionsView && addButtonContent.length > 0 && ( { const { t } = useTranslation(); - const { permissions } = usePermissionProvider(); const [isDescriptionEditable, setIsDescriptionEditable] = useState(false); const [editDomainType, setEditDomainType] = useState(false); @@ -72,40 +67,40 @@ const DocumentationTab = ({ ? ResourceEntity.DOMAIN : ResourceEntity.DATA_PRODUCT; - const { editDescriptionPermission, editOwnerPermission, editAllPermission } = - useMemo(() => { - if (isVersionsView) { - return { - editDescriptionPermission: false, - editOwnerPermission: false, - editAllPermission: false, - }; - } + const { + editDescriptionPermission, + editOwnerPermission, + editAllPermission, + editCustomAttributePermission, + viewAllPermission, + } = useMemo(() => { + if (isVersionsView) { + return { + editDescriptionPermission: false, + editOwnerPermission: false, + editAllPermission: false, + editCustomAttributePermission: false, + }; + } - const editDescription = checkPermission( - Operation.EditDescription, - resourceType, - permissions - ); + const editDescription = permissions?.EditDescription; - const editOwner = checkPermission( - Operation.EditOwners, - resourceType, - permissions - ); + const editOwner = permissions?.EditOwners; - const editAll = checkPermission( - Operation.EditAll, - resourceType, - permissions - ); + const editAll = permissions?.EditAll; - return { - editDescriptionPermission: editDescription || editAll, - editOwnerPermission: editOwner || editAll, - editAllPermission: editAll, - }; - }, [permissions, isVersionsView, resourceType]); + const editCustomAttribute = permissions?.EditCustomFields; + + const viewAll = permissions?.ViewAll; + + return { + editDescriptionPermission: editAll || editDescription, + editOwnerPermission: editAll || editOwner, + editAllPermission: editAll, + editCustomAttributePermission: editAll || editCustomAttribute, + viewAllPermission: viewAll, + }; + }, [permissions, isVersionsView, resourceType]); const description = useMemo( () => diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainTabs/DocumentationTab/DocumentationTab.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainTabs/DocumentationTab/DocumentationTab.interface.ts index e10500799b54..faf3bb363808 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainTabs/DocumentationTab/DocumentationTab.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainTabs/DocumentationTab/DocumentationTab.interface.ts @@ -10,6 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { OperationPermission } from '../../../../context/PermissionProvider/PermissionProvider.interface'; import { DataProduct } from '../../../../generated/entity/domains/dataProduct'; import { Domain } from '../../../../generated/entity/domains/domain'; @@ -19,8 +20,7 @@ export interface DocumentationTabProps { isVersionsView?: boolean; type?: DocumentationEntity; onExtensionUpdate?: (updatedDataProduct: DataProduct) => Promise; - editCustomAttributePermission?: boolean; - viewAllPermission?: boolean; + permissions?: OperationPermission; } export enum DocumentationEntity { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainTabs/DocumentationTab/DocumentationTab.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainTabs/DocumentationTab/DocumentationTab.test.tsx index f2fe4e09640d..dbdd8f3222ba 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainTabs/DocumentationTab/DocumentationTab.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainTabs/DocumentationTab/DocumentationTab.test.tsx @@ -14,6 +14,7 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import { MOCK_DOMAIN } from '../../../../mocks/Domains.mock'; +import { MOCK_PERMISSIONS } from '../../../../mocks/Glossary.mock'; import DocumentationTab from './DocumentationTab.component'; // Mock the onUpdate function @@ -23,6 +24,7 @@ const defaultProps = { domain: MOCK_DOMAIN, onUpdate: mockOnUpdate, isVersionsView: false, + permissions: MOCK_PERMISSIONS, }; jest.mock('../../../common/EntityDescription/DescriptionV1', () => { @@ -33,10 +35,6 @@ jest.mock('../../../common/ProfilePicture/ProfilePicture', () => jest.fn().mockReturnValue(<>ProfilePicture) ); -jest.mock('../../../../utils/PermissionsUtils', () => ({ - checkPermission: jest.fn().mockReturnValue(true), -})); - describe('DocumentationTab', () => { it('should render the initial content', () => { const { getByTestId } = render(, { From b179984bfaa6e122a1b5e87fe680aa4b11083f01 Mon Sep 17 00:00:00 2001 From: Ashish Gupta Date: Thu, 23 Jan 2025 19:54:41 +0530 Subject: [PATCH 152/294] fix the description renderer in team and user import (#19492) (cherry picked from commit 6c381b1ce1a985ac14d2c29fe0b381a1ed05e650) --- .../TeamImportResult.component.tsx | 15 +++++++-------- .../UserImportResult.component.tsx | 15 +++++++-------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamImportResult/TeamImportResult.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamImportResult/TeamImportResult.component.tsx index 065c56715d82..5bef047c865a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamImportResult/TeamImportResult.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamImportResult/TeamImportResult.component.tsx @@ -19,6 +19,7 @@ import { ReactComponent as FailBadgeIcon } from '../../../../assets/svg/fail-bad import { ReactComponent as SuccessBadgeIcon } from '../../../../assets/svg/success-badge.svg'; import { Status } from '../../../../generated/type/csvImportResult'; import { parseCSV } from '../../../../utils/EntityImport/EntityImportUtils'; +import RichTextEditorPreviewerV1 from '../../../common/RichTextEditor/RichTextEditorPreviewerV1'; import Table from '../../../common/Table/Table'; import { TeamCSVRecord, @@ -102,14 +103,12 @@ export const TeamImportResult = ({ width: 300, render: (description: TeamCSVRecord['description']) => { return ( - - {description || '--'} - + ); }, }, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/UserImportResult/UserImportResult.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/UserImportResult/UserImportResult.component.tsx index 046bb0f7f2ae..2bfba84e8b74 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/UserImportResult/UserImportResult.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/UserImportResult/UserImportResult.component.tsx @@ -19,6 +19,7 @@ import { ReactComponent as FailBadgeIcon } from '../../../../assets/svg/fail-bad import { ReactComponent as SuccessBadgeIcon } from '../../../../assets/svg/success-badge.svg'; import { Status } from '../../../../generated/type/csvImportResult'; import { parseCSV } from '../../../../utils/EntityImport/EntityImportUtils'; +import RichTextEditorPreviewerV1 from '../../../common/RichTextEditor/RichTextEditorPreviewerV1'; import Table from '../../../common/Table/Table'; import { UserCSVRecord, @@ -102,14 +103,12 @@ export const UserImportResult = ({ width: 300, render: (description: string) => { return ( - - {description || '--'} - + ); }, }, From 8077c50454ae2d1c104735bb971849c5773222a8 Mon Sep 17 00:00:00 2001 From: Mohit Yadav <105265192+mohityadav766@users.noreply.github.com> Date: Fri, 24 Jan 2025 10:38:45 +0530 Subject: [PATCH 153/294] Move Recreate Out of executors (#19501) * Move Recreate Out of executors * Add Entities option in Operations * Fix Reindexing for null list for referred columns (cherry picked from commit 907eb7cb0739be69d4a7274c12f4dea8bacfeedb) --- .../bundles/searchIndex/SearchIndexApp.java | 30 ++--- .../service/search/indexes/SearchIndex.java | 103 +++++++++--------- .../service/util/OpenMetadataOperations.java | 19 +++- 3 files changed, 82 insertions(+), 70 deletions(-) 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 1bb8cb283618..49d23f87b74b 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 @@ -116,6 +116,7 @@ public void startApp(JobExecutionContext jobExecutionContext) { jobData.setRecreateIndex(false); } + reCreateIndexes(jobData.getEntities()); performReindex(jobExecutionContext); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); @@ -243,7 +244,6 @@ private void submitProducerTask( jobExecutor.submit( () -> { try { - reCreateIndexes(entityType); int totalEntityRecords = getTotalEntityRecords(entityType); Source source = createSource(entityType); int loadPerThread = calculateNumberOfThreads(totalEntityRecords); @@ -434,20 +434,22 @@ private void sendUpdates(JobExecutionContext jobExecutionContext) { } } - private void reCreateIndexes(String entityType) throws SearchIndexException { - if (Boolean.FALSE.equals(jobData.getRecreateIndex())) { - LOG.debug("RecreateIndex is false. Skipping index recreation for '{}'.", entityType); - return; - } + private void reCreateIndexes(Set entities) throws SearchIndexException { + for (String entityType : entities) { + if (Boolean.FALSE.equals(jobData.getRecreateIndex())) { + LOG.debug("RecreateIndex is false. Skipping index recreation for '{}'.", entityType); + return; + } - try { - IndexMapping indexType = searchRepository.getIndexMapping(entityType); - searchRepository.deleteIndex(indexType); - searchRepository.createIndex(indexType); - LOG.info("Recreated index for entityType '{}'.", entityType); - } catch (Exception e) { - LOG.error("Failed to recreate index for entityType '{}'.", entityType, e); - throw new SearchIndexException(e); + try { + IndexMapping indexType = searchRepository.getIndexMapping(entityType); + searchRepository.deleteIndex(indexType); + searchRepository.createIndex(indexType); + LOG.info("Recreated index for entityType '{}'.", entityType); + } catch (Exception e) { + LOG.error("Failed to recreate index for entityType '{}'.", entityType, e); + throw new SearchIndexException(e); + } } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/indexes/SearchIndex.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/indexes/SearchIndex.java index 250d0aff53dc..902366cea84e 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/indexes/SearchIndex.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/indexes/SearchIndex.java @@ -1,5 +1,6 @@ package org.openmetadata.service.search.indexes; +import static org.openmetadata.common.utils.CommonUtil.listOrEmpty; import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; import static org.openmetadata.schema.type.Include.ALL; import static org.openmetadata.service.Entity.FIELD_DESCRIPTION; @@ -182,59 +183,57 @@ private static void processConstraints( Table relatedEntity, List> constraints, Boolean updateForeignTableIndex) { - if (!nullOrEmpty(entity.getTableConstraints())) { - for (TableConstraint tableConstraint : entity.getTableConstraints()) { - if (!tableConstraint - .getConstraintType() - .value() - .equalsIgnoreCase(TableConstraint.ConstraintType.FOREIGN_KEY.value())) { - continue; - } - int columnIndex = 0; - for (String referredColumn : tableConstraint.getReferredColumns()) { - String relatedEntityFQN = getParentFQN(referredColumn); - String destinationIndexName = null; - try { - if (updateForeignTableIndex) { - relatedEntity = getEntityByName(Entity.TABLE, relatedEntityFQN, "*", ALL); - IndexMapping destinationIndexMapping = - Entity.getSearchRepository() - .getIndexMapping(relatedEntity.getEntityReference().getType()); - destinationIndexName = - destinationIndexMapping.getIndexName( - Entity.getSearchRepository().getClusterAlias()); - } - Map relationshipsMap = buildRelationshipsMap(entity, relatedEntity); - int relatedEntityIndex = - checkRelatedEntity( - entity.getFullyQualifiedName(), - relatedEntity.getFullyQualifiedName(), - constraints); - if (relatedEntityIndex >= 0) { - updateExistingConstraint( - entity, - tableConstraint, - constraints.get(relatedEntityIndex), - destinationIndexName, - relatedEntity, - referredColumn, - columnIndex, - updateForeignTableIndex); - } else { - addNewConstraint( - entity, - tableConstraint, - constraints, - relationshipsMap, - destinationIndexName, - relatedEntity, - referredColumn, - columnIndex, - updateForeignTableIndex); - } - columnIndex++; - } catch (EntityNotFoundException ex) { + for (TableConstraint tableConstraint : listOrEmpty(entity.getTableConstraints())) { + if (!tableConstraint + .getConstraintType() + .value() + .equalsIgnoreCase(TableConstraint.ConstraintType.FOREIGN_KEY.value())) { + continue; + } + int columnIndex = 0; + for (String referredColumn : listOrEmpty(tableConstraint.getReferredColumns())) { + String relatedEntityFQN = getParentFQN(referredColumn); + String destinationIndexName = null; + try { + if (updateForeignTableIndex) { + relatedEntity = getEntityByName(Entity.TABLE, relatedEntityFQN, "*", ALL); + IndexMapping destinationIndexMapping = + Entity.getSearchRepository() + .getIndexMapping(relatedEntity.getEntityReference().getType()); + destinationIndexName = + destinationIndexMapping.getIndexName( + Entity.getSearchRepository().getClusterAlias()); + } + Map relationshipsMap = buildRelationshipsMap(entity, relatedEntity); + int relatedEntityIndex = + checkRelatedEntity( + entity.getFullyQualifiedName(), + relatedEntity.getFullyQualifiedName(), + constraints); + if (relatedEntityIndex >= 0) { + updateExistingConstraint( + entity, + tableConstraint, + constraints.get(relatedEntityIndex), + destinationIndexName, + relatedEntity, + referredColumn, + columnIndex, + updateForeignTableIndex); + } else { + addNewConstraint( + entity, + tableConstraint, + constraints, + relationshipsMap, + destinationIndexName, + relatedEntity, + referredColumn, + columnIndex, + updateForeignTableIndex); } + columnIndex++; + } catch (EntityNotFoundException ex) { } } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/util/OpenMetadataOperations.java b/openmetadata-service/src/main/java/org/openmetadata/service/util/OpenMetadataOperations.java index c48661206a2b..abce8e277050 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/util/OpenMetadataOperations.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/util/OpenMetadataOperations.java @@ -424,10 +424,17 @@ public Integer reIndex( names = {"--retries"}, defaultValue = "3", description = "Maximum number of retries for failed search requests.") - int retries) { + int retries, + @Option( + names = {"--entities"}, + defaultValue = "'all'", + description = + "Entities to reindex. Passing --entities='table,dashboard' will reindex table and dashboard entities. Passing nothing will reindex everything.") + String entityStr) { try { LOG.info( - "Running Reindexing with Batch Size: {}, Payload Size: {}, Recreate-Index: {}, Producer threads: {}, Consumer threads: {}, Queue Size: {}, Back-off: {}, Max Back-off: {}, Max Requests: {}, Retries: {}", + "Running Reindexing with Entities:{} , Batch Size: {}, Payload Size: {}, Recreate-Index: {}, Producer threads: {}, Consumer threads: {}, Queue Size: {}, Back-off: {}, Max Back-off: {}, Max Requests: {}, Retries: {}", + entityStr, batchSize, payloadSize, recreateIndexes, @@ -445,8 +452,11 @@ public Integer reIndex( ApplicationHandler.initialize(config); AppScheduler.initialize(config, collectionDAO, searchRepository); String appName = "SearchIndexingApplication"; + Set entities = + new HashSet<>(Arrays.asList(entityStr.substring(1, entityStr.length() - 1).split(","))); return executeSearchReindexApp( appName, + entities, batchSize, payloadSize, recreateIndexes, @@ -465,6 +475,7 @@ public Integer reIndex( private int executeSearchReindexApp( String appName, + Set entities, int batchSize, long payloadSize, boolean recreateIndexes, @@ -485,6 +496,7 @@ private int executeSearchReindexApp( EventPublisherJob updatedJob = JsonUtils.deepCopy(storedJob, EventPublisherJob.class); updatedJob + .withEntities(entities) .withBatchSize(batchSize) .withPayLoadSize(payloadSize) .withRecreateIndex(recreateIndexes) @@ -494,8 +506,7 @@ private int executeSearchReindexApp( .withInitialBackoff(backOff) .withMaxBackoff(maxBackOff) .withMaxConcurrentRequests(maxRequests) - .withMaxRetries(retries) - .withEntities(Set.of("all")); + .withMaxRetries(retries); // Update the search index app with the new configurations App updatedSearchIndexApp = JsonUtils.deepCopy(originalSearchIndexApp, App.class); From 17b0f34e44e3c8a3dce21fac196374d26dc51050 Mon Sep 17 00:00:00 2001 From: Shrushti Polekar Date: Fri, 24 Jan 2025 10:58:51 +0530 Subject: [PATCH 154/294] fix failing e2e test for customize page landing (#19490) (cherry picked from commit b3d00077ccd20f812b44979d008e9dec14fd166f) --- .../ui/playwright/e2e/Flow/CustomizeLandingPage.spec.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/CustomizeLandingPage.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/CustomizeLandingPage.spec.ts index b0cba878881f..462deb32659a 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/CustomizeLandingPage.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/CustomizeLandingPage.spec.ts @@ -251,6 +251,9 @@ test.describe('Customize Landing Page Flow', () => { // Check if all widgets are present on landing page await redirectToHomePage(adminPage); + // Ensures the page is fully loaded + await adminPage.waitForLoadState('networkidle'); + await checkAllDefaultWidgets(adminPage); } ); From 88315e2c84fbde159aa265af872cc5ba1f2b1882 Mon Sep 17 00:00:00 2001 From: Ayush Shah Date: Fri, 17 Jan 2025 14:29:36 +0530 Subject: [PATCH 155/294] MINOR: Typo for datetime attribute (#19420) --- ingestion/src/metadata/clients/aws_client.py | 40 ++++++++++++++------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/ingestion/src/metadata/clients/aws_client.py b/ingestion/src/metadata/clients/aws_client.py index ca40ff1bb679..e550c41e22aa 100644 --- a/ingestion/src/metadata/clients/aws_client.py +++ b/ingestion/src/metadata/clients/aws_client.py @@ -11,6 +11,7 @@ """ Module containing AWS Client """ +import datetime from enum import Enum from functools import partial from typing import Any, Callable, Dict, Optional, Type, TypeVar @@ -47,11 +48,22 @@ class AWSAssumeRoleException(Exception): """ +class AWSAssumeRoleCredentialResponse(BaseModel): + AccessKeyId: str = Field() + SecretAccessKey: str = Field() + SessionToken: Optional[str] = Field( + default=None, + ) + Expiration: Optional[datetime.datetime] = None + + class AWSAssumeRoleCredentialWrapper(BaseModel): - accessKeyId: str = Field(alias="access_key") - secretAccessKey: CustomSecretStr = Field(alias="secret_key") - sessionToken: Optional[str] = Field(default=None, alias="token") - expiryTime: Optional[str] = Field(alias="expiry_time") + accessKeyId: str = Field() + secretAccessKey: CustomSecretStr = Field() + sessionToken: Optional[str] = Field( + default=None, + ) + expiryTime: Optional[str] = Field() AWSAssumeRoleCredentialFormat = TypeVar( @@ -102,12 +114,14 @@ def get_assume_role_config( ) if resp: - credentials = resp.get("Credentials", {}) + credentials: AWSAssumeRoleCredentialResponse = ( + AWSAssumeRoleCredentialResponse(**resp.get("Credentials", {})) + ) creds_wrapper = AWSAssumeRoleCredentialWrapper( - accessKeyId=credentials.get("AccessKeyId"), - secretAccessKey=credentials.get("SecretAccessKey"), - sessionToken=credentials.get("SessionToken"), - expiryTime=credentials.get("Expiration").isformat(), + accessKeyId=credentials.AccessKeyId, + secretAccessKey=credentials.SecretAccessKey, + sessionToken=credentials.SessionToken, + expiryTime=credentials.Expiration.isoformat(), ) if return_type == Dict: return creds_wrapper.model_dump(by_alias=True) @@ -143,9 +157,11 @@ def _get_session( return Session( aws_access_key_id=aws_access_key_id, - aws_secret_access_key=aws_secret_access_key.get_secret_value() - if aws_secret_access_key - else None, + aws_secret_access_key=( + aws_secret_access_key.get_secret_value() + if aws_secret_access_key + else None + ), aws_session_token=aws_session_token, region_name=aws_region, profile_name=profile, From 427a06cfa82d91fde18a143a728fee5badfab697 Mon Sep 17 00:00:00 2001 From: Ayush Shah Date: Sat, 18 Jan 2025 19:41:30 +0530 Subject: [PATCH 156/294] MINOR: BigQuery Improvement, Hive Partitioned Tables, Nonetype issue resolved (#19429) --- .../source/database/bigquery/metadata.py | 25 +++++++++++++++++++ .../database/bigquery/profiler/profiler.py | 1 + ingestion/src/metadata/sampler/partition.py | 3 ++- 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/ingestion/src/metadata/ingestion/source/database/bigquery/metadata.py b/ingestion/src/metadata/ingestion/source/database/bigquery/metadata.py index 078853aca599..310cb2036019 100644 --- a/ingestion/src/metadata/ingestion/source/database/bigquery/metadata.py +++ b/ingestion/src/metadata/ingestion/source/database/bigquery/metadata.py @@ -667,6 +667,31 @@ def get_table_partition_details( database = self.context.get().database table = self.client.get_table(fqn._build(database, schema_name, table_name)) columns = inspector.get_columns(table_name, schema_name, db_name=database) + if hasattr(table, "external_data_configuration") and hasattr( + table.external_data_configuration, "hive_partitioning" + ): + # Ingesting External Hive Partitioned Tables + from google.cloud.bigquery.external_config import ( # pylint: disable=import-outside-toplevel + HivePartitioningOptions, + ) + + partition_details: HivePartitioningOptions = ( + table.external_data_configuration.hive_partitioning + ) + return True, TablePartition( + columns=[ + PartitionColumnDetails( + columnName=self._get_partition_column_name( + columns=columns, + partition_field_name=field, + ), + interval=str(partition_details._properties.get("mode")), + intervalType=PartitionIntervalTypes.OTHER, + ) + for field in partition_details._properties.get("fields") + ] + ) + if table.time_partitioning is not None: if table.time_partitioning.field: table_partition = TablePartition( diff --git a/ingestion/src/metadata/ingestion/source/database/bigquery/profiler/profiler.py b/ingestion/src/metadata/ingestion/source/database/bigquery/profiler/profiler.py index 2cd0f225b31d..750ae61fb20d 100644 --- a/ingestion/src/metadata/ingestion/source/database/bigquery/profiler/profiler.py +++ b/ingestion/src/metadata/ingestion/source/database/bigquery/profiler/profiler.py @@ -24,6 +24,7 @@ def _compute_system_metrics( return self.system_metrics_computer.get_system_metrics( table=runner.dataset, usage_location=self.service_connection_config.usageLocation, + runner=runner, ) def initialize_system_metrics_computer(self) -> BigQuerySystemMetricsComputer: diff --git a/ingestion/src/metadata/sampler/partition.py b/ingestion/src/metadata/sampler/partition.py index e62ad94a0496..c2eb76f9833e 100644 --- a/ingestion/src/metadata/sampler/partition.py +++ b/ingestion/src/metadata/sampler/partition.py @@ -49,7 +49,7 @@ def validate_athena_injected_partitioning( column_partitions: Optional[List[PartitionColumnDetails]] = table_partitions.columns if not column_partitions: - raise RuntimeError("Table parition is set but no columns are defined.") + raise RuntimeError("Table partition is set but no columns are defined.") for column_partition in column_partitions: if column_partition.intervalType == PartitionIntervalTypes.INJECTED: @@ -163,6 +163,7 @@ def _handle_bigquery_partition( partitionIntegerRangeStart=1, partitionIntegerRangeEnd=10000, ) + # TODO: Allow External Hive Partitioning for profiler raise TypeError( f"Unsupported partition type {partition.intervalType}. Skipping table" ) From b1086b68813e605282de42407dcd2f6846680b62 Mon Sep 17 00:00:00 2001 From: Abdallah Serghine <76706155+KylixSerg@users.noreply.github.com> Date: Thu, 23 Jan 2025 08:06:19 +0100 Subject: [PATCH 157/294] MINOR: fix model dump dict key names (#19484) (#19485) Co-authored-by: Abdallah Serghine --- ingestion/src/metadata/clients/aws_client.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/ingestion/src/metadata/clients/aws_client.py b/ingestion/src/metadata/clients/aws_client.py index e550c41e22aa..751f64da61dc 100644 --- a/ingestion/src/metadata/clients/aws_client.py +++ b/ingestion/src/metadata/clients/aws_client.py @@ -58,12 +58,13 @@ class AWSAssumeRoleCredentialResponse(BaseModel): class AWSAssumeRoleCredentialWrapper(BaseModel): - accessKeyId: str = Field() - secretAccessKey: CustomSecretStr = Field() - sessionToken: Optional[str] = Field( - default=None, - ) - expiryTime: Optional[str] = Field() + accessKeyId: str = Field(alias="access_key") + secretAccessKey: CustomSecretStr = Field(alias="secret_key") + sessionToken: Optional[str] = Field(default=None, alias="token") + expiryTime: Optional[str] = Field(alias="expiry_time") + + class Config: + populate_by_name = True AWSAssumeRoleCredentialFormat = TypeVar( From f61f62919a8cf5a8961cb8e0143e54c2cf05a094 Mon Sep 17 00:00:00 2001 From: olof-nn Date: Wed, 22 Jan 2025 13:26:01 +0100 Subject: [PATCH 158/294] ISSUE-19454: Fixes broken looker lineage (#19456) * ISSUE-19454: Fixes the broken lineage in looker when backticks enclosed table refs * refactor * use isort * Update ingestion/src/metadata/ingestion/source/dashboard/looker/metadata.py Co-authored-by: Mayur Singal <39544459+ulixius9@users.noreply.github.com> --------- Co-authored-by: Mayur Singal <39544459+ulixius9@users.noreply.github.com> --- .../source/dashboard/looker/metadata.py | 52 ++++++++++++------- .../unit/topology/dashboard/test_looker.py | 29 +++++++++-- 2 files changed, 58 insertions(+), 23 deletions(-) diff --git a/ingestion/src/metadata/ingestion/source/dashboard/looker/metadata.py b/ingestion/src/metadata/ingestion/source/dashboard/looker/metadata.py index 4cf1858227f9..64bf13d38f7d 100644 --- a/ingestion/src/metadata/ingestion/source/dashboard/looker/metadata.py +++ b/ingestion/src/metadata/ingestion/source/dashboard/looker/metadata.py @@ -24,7 +24,18 @@ import traceback from datetime import datetime from pathlib import Path -from typing import Dict, Iterable, List, Optional, Sequence, Set, Type, Union, cast +from typing import ( + Dict, + Iterable, + List, + Optional, + Sequence, + Set, + Type, + Union, + cast, + get_args, +) import giturlparse import lkml @@ -91,7 +102,7 @@ from metadata.generated.schema.type.usageRequest import UsageRequest from metadata.ingestion.api.models import Either from metadata.ingestion.api.steps import InvalidSourceException -from metadata.ingestion.lineage.models import ConnectionTypeDialectMapper +from metadata.ingestion.lineage.models import ConnectionTypeDialectMapper, Dialect from metadata.ingestion.lineage.parser import LineageParser from metadata.ingestion.ometa.ometa_api import OpenMetadata from metadata.ingestion.source.dashboard.dashboard_service import ( @@ -108,7 +119,6 @@ LookMlView, ViewName, ) -from metadata.ingestion.source.dashboard.looker.parser import LkmlParser from metadata.ingestion.source.dashboard.looker.utils import _clone_repo from metadata.readers.file.api_reader import ReadersCredentials from metadata.readers.file.base import Reader @@ -121,7 +131,6 @@ logger = ingestion_logger() - LIST_DASHBOARD_FIELDS = ["id", "title"] IMPORTED_PROJECTS_DIR = "imported_projects" @@ -178,7 +187,7 @@ def __init__( self._explores_cache = {} self._repo_credentials: Optional[ReadersCredentials] = None self._reader_class: Optional[Type[Reader]] = None - self._project_parsers: Optional[Dict[str, LkmlParser]] = None + self._project_parsers: Optional[Dict[str, BulkLkmlParser]] = None self._main_lookml_repo: Optional[LookMLRepo] = None self._main__lookml_manifest: Optional[LookMLManifest] = None self._view_data_model: Optional[DashboardDataModel] = None @@ -260,7 +269,7 @@ def prepare(self): self._main__lookml_manifest = self.__read_manifest(credentials) @property - def parser(self) -> Optional[Dict[str, LkmlParser]]: + def parser(self) -> Optional[Dict[str, BulkLkmlParser]]: if self.repository_credentials: return self._project_parsers return None @@ -282,7 +291,7 @@ def parser(self, all_lookml_models: Sequence[LookmlModel]) -> None: """ if self.repository_credentials: all_projects: Set[str] = {model.project_name for model in all_lookml_models} - self._project_parsers: Dict[str, LkmlParser] = { + self._project_parsers: Dict[str, BulkLkmlParser] = { project_name: BulkLkmlParser( reader=self.reader(Path(self._main_lookml_repo.path)) ) @@ -325,7 +334,7 @@ def repository_credentials(self) -> Optional[ReadersCredentials]: """ if not self._repo_credentials: if self.service_connection.gitCredentials and isinstance( - self.service_connection.gitCredentials, ReadersCredentials + self.service_connection.gitCredentials, get_args(ReadersCredentials) ): self._repo_credentials = self.service_connection.gitCredentials @@ -572,10 +581,12 @@ def add_view_lineage( if view.sql_table_name: sql_table_name = self._render_table_name(view.sql_table_name) - source_table_name = self._clean_table_name(sql_table_name) - # View to the source is only there if we are informing the dbServiceNames for db_service_name in db_service_names or []: + dialect = self._get_db_dialect(db_service_name) + source_table_name = self._clean_table_name(sql_table_name, dialect) + + # View to the source is only there if we are informing the dbServiceNames yield self.build_lineage_request( source=source_table_name, db_service_name=db_service_name, @@ -587,15 +598,9 @@ def add_view_lineage( if not sql_query: return for db_service_name in db_service_names or []: - db_service = self.metadata.get_by_name( - DatabaseService, db_service_name - ) - lineage_parser = LineageParser( sql_query, - ConnectionTypeDialectMapper.dialect_of( - db_service.connection.config.type.value - ), + self._get_db_dialect(db_service_name), timeout_seconds=30, ) if lineage_parser.source_tables: @@ -615,6 +620,12 @@ def add_view_lineage( ) ) + def _get_db_dialect(self, db_service_name) -> Dialect: + db_service = self.metadata.get_by_name(DatabaseService, db_service_name) + return ConnectionTypeDialectMapper.dialect_of( + db_service.connection.config.type.value + ) + def get_dashboards_list(self) -> List[DashboardBase]: """ Get List of all dashboards @@ -718,7 +729,7 @@ def get_project_name(self, dashboard_details: LookerDashboard) -> Optional[str]: return None @staticmethod - def _clean_table_name(table_name: str) -> str: + def _clean_table_name(table_name: str, dialect: Dialect = Dialect.ANSI) -> str: """ sql_table_names might be renamed when defining an explore. E.g., customers as cust @@ -726,7 +737,10 @@ def _clean_table_name(table_name: str) -> str: :return: clean table name """ - return table_name.lower().split(" as ")[0].strip() + clean_table_name = table_name.lower().split(" as ")[0].strip() + if dialect == Dialect.BIGQUERY: + clean_table_name = clean_table_name.strip("`") + return clean_table_name @staticmethod def _render_table_name(table_name: str) -> str: diff --git a/ingestion/tests/unit/topology/dashboard/test_looker.py b/ingestion/tests/unit/topology/dashboard/test_looker.py index b5a2b2b0ab46..9acdc55111f8 100644 --- a/ingestion/tests/unit/topology/dashboard/test_looker.py +++ b/ingestion/tests/unit/topology/dashboard/test_looker.py @@ -47,6 +47,7 @@ from metadata.generated.schema.type.usageDetails import UsageDetails, UsageStats from metadata.generated.schema.type.usageRequest import UsageRequest from metadata.ingestion.api.steps import InvalidSourceException +from metadata.ingestion.lineage.models import Dialect from metadata.ingestion.ometa.ometa_api import OpenMetadata from metadata.ingestion.source.dashboard.dashboard_service import DashboardUsage from metadata.ingestion.source.dashboard.looker.metadata import LookerSource @@ -292,13 +293,33 @@ def test_clean_table_name(self): """ Check table cleaning """ - self.assertEqual(self.looker._clean_table_name("MY_TABLE"), "my_table") + self.assertEqual( + self.looker._clean_table_name("MY_TABLE", Dialect.MYSQL), "my_table" + ) - self.assertEqual(self.looker._clean_table_name(" MY_TABLE "), "my_table") + self.assertEqual( + self.looker._clean_table_name(" MY_TABLE ", Dialect.REDSHIFT), "my_table" + ) - self.assertEqual(self.looker._clean_table_name(" my_table"), "my_table") + self.assertEqual( + self.looker._clean_table_name(" my_table", Dialect.SNOWFLAKE), "my_table" + ) - self.assertEqual(self.looker._clean_table_name("TABLE AS ALIAS"), "table") + self.assertEqual( + self.looker._clean_table_name("TABLE AS ALIAS", Dialect.BIGQUERY), "table" + ) + + self.assertEqual( + self.looker._clean_table_name( + "`project_id.dataset_id.table_id` AS ALIAS", Dialect.BIGQUERY + ), + "project_id.dataset_id.table_id", + ) + + self.assertEqual( + self.looker._clean_table_name("`db.schema.table`", Dialect.POSTGRES), + "`db.schema.table`", + ) def test_render_table_name(self): """ From 4b33077ae6cf28678e76bc82da5daa6b3418351e Mon Sep 17 00:00:00 2001 From: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> Date: Mon, 20 Jan 2025 23:31:56 +0530 Subject: [PATCH 159/294] Fix(ui): refresh call concurrency for multiple browser tabs (#19303) * fix(ui): refresh auth token for multi browser tabs * update refresh logic * fix multiple tab issue * fix tests * added tests * fix ingestion bot failure * fix sonar cloud * update test description * remove unused code and reset test on after all * bump playwright * avoid running refresh tests as it's been flaky for postgres * revert playwright version bump changes * Put 500 status --------- Co-authored-by: mohitdeuex (cherry picked from commit 00a37c61803e6e47e8806bc8ebfa6eda3d857c54) --- .../AuthenticationCodeFlowHandler.java | 1 + .../resources/ui/playwright/constant/login.ts | 1 + .../ui/playwright/e2e/Pages/Login.spec.ts | 61 +++- .../resources/ui/playwright/utils/common.ts | 3 +- .../ui/src/components/AppBar/Appbar.tsx | 3 +- .../AppAuthenticators/Auth0Authenticator.tsx | 4 +- .../BasicAuthAuthenticator.tsx | 17 +- .../GenericAuthenticator.tsx | 10 +- .../AppAuthenticators/OidcAuthenticator.tsx | 6 +- .../AppAuthenticators/OktaAuthenticator.tsx | 3 +- .../AppAuthenticators/SamlAuthenticator.tsx | 15 +- .../Auth0Callback/Auth0Callback.test.tsx | 5 +- .../Auth0Callback/Auth0Callback.tsx | 3 +- .../Auth/AuthProviders/AuthProvider.tsx | 128 ++++---- .../Auth/AuthProviders/BasicAuthProvider.tsx | 21 +- .../Auth/AuthProviders/OktaAuthProvider.tsx | 3 +- .../ui/src/hooks/useApplicationStore.ts | 276 ++++++++---------- .../ui/src/interface/store.interface.ts | 10 - .../ui/src/pages/SamlCallback/index.tsx | 4 +- .../ui/src/pages/SwaggerPage/index.test.tsx | 5 +- .../ui/src/pages/SwaggerPage/index.tsx | 3 +- .../Auth/TokenService/TokenServiceUtil.ts | 80 +++-- .../ui/src/utils/AuthProvider.util.ts | 3 +- .../ui/src/utils/LocalStorageUtils.ts | 24 +- 24 files changed, 396 insertions(+), 293 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/AuthenticationCodeFlowHandler.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/AuthenticationCodeFlowHandler.java index de95bcce7765..b1d38d09933d 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/AuthenticationCodeFlowHandler.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/AuthenticationCodeFlowHandler.java @@ -642,6 +642,7 @@ private ClientAuthenticationMethod firstSupportedMethod( @SneakyThrows public static void getErrorMessage(HttpServletResponse resp, Exception e) { + resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); resp.setContentType("text/html; charset=UTF-8"); LOG.error("[Auth Callback Servlet] Failed in Auth Login : {}", e.getMessage()); resp.getOutputStream() diff --git a/openmetadata-ui/src/main/resources/ui/playwright/constant/login.ts b/openmetadata-ui/src/main/resources/ui/playwright/constant/login.ts index 1d2923e25127..d71e9ae86146 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/constant/login.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/constant/login.ts @@ -11,6 +11,7 @@ * limitations under the License. */ export const JWT_EXPIRY_TIME_MAP = { + '3 minutes': 180, '1 hour': 3600, '2 hours': 7200, '3 hours': 10800, diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Login.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Login.spec.ts index 12eb7b164490..0d77592eac68 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Login.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Login.spec.ts @@ -11,26 +11,53 @@ * limitations under the License. */ import { expect, test } from '@playwright/test'; -import { LOGIN_ERROR_MESSAGE } from '../../constant/login'; +import { JWT_EXPIRY_TIME_MAP, LOGIN_ERROR_MESSAGE } from '../../constant/login'; import { UserClass } from '../../support/user/UserClass'; import { performAdminLogin } from '../../utils/admin'; +import { redirectToHomePage } from '../../utils/common'; +import { updateJWTTokenExpiryTime } from '../../utils/login'; +import { visitUserProfilePage } from '../../utils/user'; const user = new UserClass(); const CREDENTIALS = user.data; const invalidEmail = 'userTest@openmetadata.org'; const invalidPassword = 'testUsers@123'; +test.describe.configure({ + // 5 minutes max for refresh token tests + timeout: 5 * 60 * 1000, +}); + test.describe('Login flow should work properly', () => { test.afterAll('Cleanup', async ({ browser }) => { const { apiContext, afterAction, page } = await performAdminLogin(browser); const response = await page.request.get( `/api/v1/users/name/${user.getUserName()}` ); + + // reset token expiry to 4 hours + await updateJWTTokenExpiryTime(apiContext, JWT_EXPIRY_TIME_MAP['4 hours']); + user.responseData = await response.json(); await user.delete(apiContext); await afterAction(); }); + test.beforeAll( + 'Update token timer to be 3 minutes for new token created', + async ({ browser }) => { + const { apiContext, afterAction } = await performAdminLogin(browser); + + // update expiry for 3 mins + await updateJWTTokenExpiryTime( + apiContext, + JWT_EXPIRY_TIME_MAP['3 minutes'] + ); + + await afterAction(); + } + ); + test('Signup and Login with signed up credentials', async ({ page }) => { await page.goto('/'); @@ -111,4 +138,36 @@ test.describe('Login flow should work properly', () => { await page.getByRole('button', { name: 'Submit' }).click(); await page.locator('[data-testid="go-back-button"]').click(); }); + + test.fixme('Refresh should work', async ({ browser }) => { + const browserContext = await browser.newContext(); + const { apiContext, afterAction } = await performAdminLogin(browser); + const page1 = await browserContext.newPage(), + page2 = await browserContext.newPage(); + + const testUser = new UserClass(); + await testUser.create(apiContext); + + await afterAction(); + + await test.step('Login and wait for refresh call is made', async () => { + // User login + + await testUser.login(page1); + await redirectToHomePage(page1); + await redirectToHomePage(page2); + + const refreshCall = page1.waitForResponse('**/refresh', { + timeout: 3 * 60 * 1000, + }); + + await refreshCall; + + await redirectToHomePage(page1); + + await visitUserProfilePage(page1, testUser.responseData.name); + await redirectToHomePage(page2); + await visitUserProfilePage(page2, testUser.responseData.name); + }); + }); }); 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 08bc68ec4d18..bff8a10b1792 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts @@ -41,8 +41,7 @@ export const NAME_MAX_LENGTH_VALIDATION_ERROR = export const getToken = async (page: Page) => { return page.evaluate( () => - JSON.parse(localStorage.getItem('om-session') ?? '{}')?.state - ?.oidcIdToken ?? '' + JSON.parse(localStorage.getItem('om-session') ?? '{}')?.oidcIdToken ?? '' ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/AppBar/Appbar.tsx b/openmetadata-ui/src/main/resources/ui/src/components/AppBar/Appbar.tsx index cd95f7c754ba..910a8530252f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/AppBar/Appbar.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/AppBar/Appbar.tsx @@ -26,6 +26,7 @@ import { isTourRoute, } from '../../utils/AuthProvider.util'; import { addToRecentSearched } from '../../utils/CommonUtils'; +import { getOidcToken } from '../../utils/LocalStorageUtils'; import searchClassBase from '../../utils/SearchClassBase'; import NavBar from '../NavBar/NavBar'; import './app-bar.style.less'; @@ -37,7 +38,7 @@ const Appbar: React.FC = (): JSX.Element => { const { isTourOpen, updateTourPage, updateTourSearch, tourSearchValue } = useTourProvider(); - const { isAuthenticated, searchCriteria, getOidcToken, trySilentSignIn } = + const { isAuthenticated, searchCriteria, trySilentSignIn } = useApplicationStore(); const parsedQueryString = Qs.parse( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/Auth0Authenticator.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/Auth0Authenticator.tsx index d7a876634081..44d14efad402 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/Auth0Authenticator.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/Auth0Authenticator.tsx @@ -22,6 +22,7 @@ import { useTranslation } from 'react-i18next'; import { AuthProvider } from '../../../generated/settings/settings'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; +import { setOidcToken } from '../../../utils/LocalStorageUtils'; import { AuthenticatorRef } from '../AuthProviders/AuthProvider.interface'; interface Props { @@ -31,8 +32,7 @@ interface Props { const Auth0Authenticator = forwardRef( ({ children, onLogoutSuccess }: Props, ref) => { - const { setIsAuthenticated, authConfig, setOidcToken } = - useApplicationStore(); + const { setIsAuthenticated, authConfig } = useApplicationStore(); const { t } = useTranslation(); const { loginWithRedirect, getAccessTokenSilently, getIdTokenClaims } = useAuth0(); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/BasicAuthAuthenticator.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/BasicAuthAuthenticator.tsx index 2faba2a762ef..0f2583f8b224 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/BasicAuthAuthenticator.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/BasicAuthAuthenticator.tsx @@ -26,6 +26,11 @@ import { } from '../../../rest/auth-API'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; +import { + getRefreshToken, + setOidcToken, + setRefreshToken, +} from '../../../utils/LocalStorageUtils'; import Loader from '../../common/Loader/Loader'; import { useBasicAuth } from '../AuthProviders/BasicAuthProvider'; @@ -40,9 +45,7 @@ const BasicAuthenticator = forwardRef( const { setIsAuthenticated, authConfig, - getRefreshToken, - setRefreshToken, - setOidcToken, + isApplicationLoading, } = useApplicationStore(); @@ -54,7 +57,13 @@ const BasicAuthenticator = forwardRef( authConfig?.provider !== AuthProvider.Basic && authConfig?.provider !== AuthProvider.LDAP ) { - Promise.reject(t('message.authProvider-is-not-basic')); + return Promise.reject( + new Error(t('message.authProvider-is-not-basic')) + ); + } + + if (!refreshToken) { + return Promise.reject(new Error(t('message.no-token-available'))); } const response = await getAccessTokenOnExpiry({ diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/GenericAuthenticator.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/GenericAuthenticator.tsx index f70c1757f732..f732fa3c2d30 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/GenericAuthenticator.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/GenericAuthenticator.tsx @@ -20,15 +20,11 @@ import { useHistory } from 'react-router-dom'; import { ROUTES } from '../../../constants/constants'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { logoutUser, renewToken } from '../../../rest/LoginAPI'; +import { setOidcToken } from '../../../utils/LocalStorageUtils'; export const GenericAuthenticator = forwardRef( ({ children }: { children: ReactNode }, ref) => { - const { - setIsAuthenticated, - setIsSigningUp, - removeOidcToken, - setOidcToken, - } = useApplicationStore(); + const { setIsAuthenticated, setIsSigningUp } = useApplicationStore(); const history = useHistory(); const handleLogin = () => { @@ -42,7 +38,7 @@ export const GenericAuthenticator = forwardRef( await logoutUser(); history.push(ROUTES.SIGNIN); - removeOidcToken(); + setOidcToken(''); setIsAuthenticated(false); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/OidcAuthenticator.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/OidcAuthenticator.tsx index 290e89cdd2c1..74eb1f3597a5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/OidcAuthenticator.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/OidcAuthenticator.tsx @@ -27,6 +27,8 @@ import { ROUTES } from '../../../constants/constants'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import useCustomLocation from '../../../hooks/useCustomLocation/useCustomLocation'; import SignInPage from '../../../pages/LoginPage/SignInPage'; +import TokenService from '../../../utils/Auth/TokenService/TokenServiceUtil'; +import { setOidcToken } from '../../../utils/LocalStorageUtils'; import { showErrorToast } from '../../../utils/ToastUtils'; import Loader from '../../common/Loader/Loader'; import { @@ -71,7 +73,6 @@ const OidcAuthenticator = forwardRef( updateAxiosInterceptors, currentUser, newUser, - setOidcToken, isApplicationLoading, } = useApplicationStore(); const history = useHistory(); @@ -105,6 +106,9 @@ const OidcAuthenticator = forwardRef( // On success update token in store and update axios interceptors setOidcToken(user.id_token); updateAxiosInterceptors(); + // Clear the refresh token in progress flag + // Since refresh token request completes with a callback + TokenService.getInstance().clearRefreshInProgress(); }; const handleSilentSignInFailure = (error: unknown) => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/OktaAuthenticator.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/OktaAuthenticator.tsx index 759677906a84..8ada4d6e1275 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/OktaAuthenticator.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/OktaAuthenticator.tsx @@ -20,6 +20,7 @@ import React, { } from 'react'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; +import { setOidcToken } from '../../../utils/LocalStorageUtils'; import { AuthenticatorRef } from '../AuthProviders/AuthProvider.interface'; interface Props { @@ -30,7 +31,7 @@ interface Props { const OktaAuthenticator = forwardRef( ({ children, onLogoutSuccess }: Props, ref) => { const { oktaAuth } = useOktaAuth(); - const { setIsAuthenticated, setOidcToken } = useApplicationStore(); + const { setIsAuthenticated } = useApplicationStore(); const login = async () => { oktaAuth.signInWithRedirect(); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/SamlAuthenticator.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/SamlAuthenticator.tsx index 37a1ffd4dc59..7aa037356a8e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/SamlAuthenticator.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/SamlAuthenticator.tsx @@ -36,6 +36,12 @@ import { showErrorToast } from '../../../utils/ToastUtils'; import { ROUTES } from '../../../constants/constants'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { AccessTokenResponse, refreshSAMLToken } from '../../../rest/auth-API'; +import { + getOidcToken, + getRefreshToken, + setOidcToken, + setRefreshToken, +} from '../../../utils/LocalStorageUtils'; import { AuthenticatorRef } from '../AuthProviders/AuthProvider.interface'; interface Props { @@ -45,14 +51,7 @@ interface Props { const SamlAuthenticator = forwardRef( ({ children, onLogoutSuccess }: Props, ref) => { - const { - setIsAuthenticated, - authConfig, - getOidcToken, - getRefreshToken, - setRefreshToken, - setOidcToken, - } = useApplicationStore(); + const { setIsAuthenticated, authConfig } = useApplicationStore(); const config = authConfig?.samlConfiguration as SamlSSOClientConfig; const handleSilentSignIn = async (): Promise => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppCallbacks/Auth0Callback/Auth0Callback.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppCallbacks/Auth0Callback/Auth0Callback.test.tsx index 935c9f1a50d1..a95a10a38434 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppCallbacks/Auth0Callback/Auth0Callback.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppCallbacks/Auth0Callback/Auth0Callback.test.tsx @@ -53,11 +53,14 @@ jest.mock('../../../../hooks/useApplicationStore', () => { useApplicationStore: jest.fn(() => ({ authConfig: {}, handleSuccessfulLogin: mockHandleSuccessfulLogin, - setOidcToken: jest.fn(), })), }; }); +jest.mock('../../../../utils/LocalStorageUtils', () => ({ + setOidcToken: jest.fn(), +})); + describe('Test Auth0Callback component', () => { afterEach(() => { jest.clearAllMocks(); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppCallbacks/Auth0Callback/Auth0Callback.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppCallbacks/Auth0Callback/Auth0Callback.tsx index 5bbff633223e..d5752ef8ed7e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppCallbacks/Auth0Callback/Auth0Callback.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppCallbacks/Auth0Callback/Auth0Callback.tsx @@ -16,11 +16,12 @@ import { t } from 'i18next'; import React, { VFC } from 'react'; import { useApplicationStore } from '../../../../hooks/useApplicationStore'; +import { setOidcToken } from '../../../../utils/LocalStorageUtils'; import { OidcUser } from '../../AuthProviders/AuthProvider.interface'; const Auth0Callback: VFC = () => { const { isAuthenticated, user, getIdTokenClaims, error } = useAuth0(); - const { handleSuccessfulLogin, setOidcToken } = useApplicationStore(); + const { handleSuccessfulLogin } = useApplicationStore(); if (isAuthenticated) { getIdTokenClaims() .then((token) => { 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 207ac9c71ae2..161f2a2dec8d 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 { debounce, isEmpty, isNil, isNumber } from 'lodash'; +import { isEmpty, isNil, isNumber } from 'lodash'; import Qs from 'qs'; import React, { ComponentType, @@ -72,7 +72,12 @@ import { isProtectedRoute, prepareUserProfileFromClaims, } from '../../../utils/AuthProvider.util'; -import { getOidcToken } from '../../../utils/LocalStorageUtils'; +import { + getOidcToken, + getRefreshToken, + setOidcToken, + setRefreshToken, +} from '../../../utils/LocalStorageUtils'; import { getPathNameFromWindowLocation } from '../../../utils/RouterUtils'; import { escapeESReservedCharacters } from '../../../utils/StringsUtils'; import { showErrorToast, showInfoToast } from '../../../utils/ToastUtils'; @@ -110,7 +115,9 @@ const isEmailVerifyField = 'isEmailVerified'; let requestInterceptor: number | null = null; let responseInterceptor: number | null = null; -let failedLoggedInUserRequest: boolean | null; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let pendingRequests: any[] = []; export const AuthProvider = ({ childComponentType, @@ -130,14 +137,11 @@ export const AuthProvider = ({ jwtPrincipalClaimsMapping, setJwtPrincipalClaims, setJwtPrincipalClaimsMapping, - removeRefreshToken, - removeOidcToken, - getRefreshToken, isApplicationLoading, setApplicationLoading, } = useApplicationStore(); const { updateDomains, updateDomainLoading } = useDomainStore(); - const tokenService = useRef(); + const tokenService = useRef(TokenService.getInstance()); const location = useCustomLocation(); const history = useHistory(); @@ -176,7 +180,7 @@ export const AuthProvider = ({ removeSession(); // remove the refresh token on logout - removeRefreshToken(); + setRefreshToken(''); setApplicationLoading(false); @@ -184,14 +188,6 @@ export const AuthProvider = ({ history.push(ROUTES.SIGNIN); }, [timeoutId]); - useEffect(() => { - if (authenticatorRef.current?.renewIdToken) { - tokenService.current = new TokenService( - authenticatorRef.current?.renewIdToken - ); - } - }, [authenticatorRef.current?.renewIdToken]); - const fetchDomainList = useCallback(async () => { try { updateDomainLoading(true); @@ -228,7 +224,7 @@ export const AuthProvider = ({ const resetUserDetails = (forceLogout = false) => { setCurrentUser({} as User); - removeOidcToken(); + setOidcToken(''); setIsAuthenticated(false); setApplicationLoading(false); clearTimeout(timeoutId); @@ -268,39 +264,6 @@ export const AuthProvider = ({ } }; - /** - * This method will try to signIn silently when token is about to expire - * if it's not succeed then it will proceed for logout - */ - const trySilentSignIn = async (forceLogout?: boolean) => { - const pathName = getPathNameFromWindowLocation(); - // Do not try silent sign in for SignIn or SignUp route - if ( - [ROUTES.SIGNIN, ROUTES.SIGNUP, ROUTES.SILENT_CALLBACK].includes(pathName) - ) { - return; - } - - 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 - startTokenExpiryTimer(); - - // Retry the failed request after successful silent signIn - if (failedLoggedInUserRequest) { - await getLoggedInUserDetails(); - failedLoggedInUserRequest = null; - } - } else if (forceLogout) { - resetUserDetails(true); - } - } - }; - /** * It will set an timer for 5 mins before Token will expire * If time if less then 5 mins then it will try to SilentSignIn @@ -326,13 +289,25 @@ export const AuthProvider = ({ // If token is about to expire then start silentSignIn // else just set timer to try for silentSignIn before token expires clearTimeout(timeoutId); - const timerId = setTimeout(() => { - trySilentSignIn(); - }, timeoutExpiry); + + const timerId = setTimeout( + tokenService.current?.refreshToken, + timeoutExpiry + ); setTimeoutId(Number(timerId)); } }; + useEffect(() => { + if (authenticatorRef.current?.renewIdToken) { + tokenService.current.updateRenewToken( + authenticatorRef.current?.renewIdToken + ); + // After every refresh success, start timer again + tokenService.current.updateRefreshSuccessCallback(startTokenExpiryTimer); + } + }, [authenticatorRef.current?.renewIdToken]); + /** * Performs cleanup around timers * Clean silentSignIn activities if going on @@ -542,13 +517,48 @@ export const AuthProvider = ({ if (error.response) { const { status } = error.response; if (status === ClientErrors.UNAUTHORIZED) { - // store the failed request for retry after successful silent signIn - if (error.config.url === '/users/loggedInUser') { - failedLoggedInUserRequest = true; + if (error.config.url === '/users/refresh') { + return Promise.reject(error as Error); } handleStoreProtectedRedirectPath(); - // try silent signIn if token is about to expire - debounce(() => trySilentSignIn(true), 100); + + // If 401 error and refresh is not in progress, trigger the refresh + if (!tokenService.current?.isTokenUpdateInProgress()) { + // Start the refresh process + return new Promise((resolve, reject) => { + // Add this request to the pending queue + pendingRequests.push({ + resolve, + reject, + config: error.config, + }); + + // Refresh the token and retry the requests in the queue + tokenService.current.refreshToken().then((token) => { + if (token) { + // Retry the pending requests + initializeAxiosInterceptors(); + pendingRequests.forEach(({ resolve, reject, config }) => { + axiosClient(config).then(resolve).catch(reject); + }); + } else { + resetUserDetails(true); + } + }); + + // Clear the queue after retrying + pendingRequests = []; + }); + } else { + // If refresh is in progress, queue the request + return new Promise((resolve, reject) => { + pendingRequests.push({ + resolve, + reject, + config: error.config, + }); + }); + } } } @@ -721,7 +731,6 @@ export const AuthProvider = ({ onLoginHandler, onLogoutHandler, handleSuccessfulLogin, - trySilentSignIn, handleFailedLogin, updateAxiosInterceptors: initializeAxiosInterceptors, }); @@ -734,7 +743,6 @@ export const AuthProvider = ({ onLoginHandler, onLogoutHandler, handleSuccessfulLogin, - trySilentSignIn, handleFailedLogin, updateAxiosInterceptors: initializeAxiosInterceptors, }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/BasicAuthProvider.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/BasicAuthProvider.tsx index ff6d0a01749f..11b972671450 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/BasicAuthProvider.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/BasicAuthProvider.tsx @@ -40,7 +40,13 @@ import { import { resetWebAnalyticSession } from '../../../utils/WebAnalyticsUtils'; import { toLower } from 'lodash'; -import { useApplicationStore } from '../../../hooks/useApplicationStore'; +import { extractDetailsFromToken } from '../../../utils/AuthProvider.util'; +import { + getOidcToken, + getRefreshToken, + setOidcToken, + setRefreshToken, +} from '../../../utils/LocalStorageUtils'; import { OidcUser } from './AuthProvider.interface'; export interface BasicAuthJWTPayload extends JwtPayload { @@ -90,13 +96,7 @@ const BasicAuthProvider = ({ onLoginFailure, }: BasicAuthProps) => { const { t } = useTranslation(); - const { - setRefreshToken, - setOidcToken, - getOidcToken, - removeOidcToken, - getRefreshToken, - } = useApplicationStore(); + const [loginError, setLoginError] = useState(null); const history = useHistory(); @@ -182,10 +182,11 @@ const BasicAuthProvider = ({ const handleLogout = async () => { const token = getOidcToken(); const refreshToken = getRefreshToken(); - if (token) { + const isExpired = extractDetailsFromToken(token).isExpired; + if (token && !isExpired) { try { await logoutUser({ token, refreshToken }); - removeOidcToken(); + setOidcToken(''); history.push(ROUTES.SIGNIN); } catch (error) { showErrorToast(error as AxiosError); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/OktaAuthProvider.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/OktaAuthProvider.tsx index c90e9b4a634c..1944d170d1df 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/OktaAuthProvider.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/OktaAuthProvider.tsx @@ -20,6 +20,7 @@ import React, { useMemo, } from 'react'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; +import { setOidcToken } from '../../../utils/LocalStorageUtils'; import { OidcUser } from './AuthProvider.interface'; interface Props { @@ -31,7 +32,7 @@ export const OktaAuthProvider: FunctionComponent = ({ children, onLoginSuccess, }: Props) => { - const { authConfig, setOidcToken } = useApplicationStore(); + const { authConfig } = useApplicationStore(); const { clientId, issuer, redirectUri, scopes, pkce } = authConfig as OktaAuthOptions; diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useApplicationStore.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useApplicationStore.ts index 59103e09162d..734f3c255ae1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useApplicationStore.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useApplicationStore.ts @@ -11,7 +11,6 @@ * limitations under the License. */ import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; import { AuthenticationConfigurationWithScope } from '../components/Auth/AuthProviders/AuthProvider.interface'; import { EntityUnion } from '../components/Explore/ExplorePage.interface'; import { AuthenticationConfiguration } from '../generated/configuration/authenticationConfiguration'; @@ -28,164 +27,133 @@ import { getThemeConfig } from '../utils/ThemeUtils'; export const OM_SESSION_KEY = 'om-session'; -export const useApplicationStore = create()( - persist( - (set, get) => ({ - isApplicationLoading: false, - theme: getThemeConfig(), - applicationConfig: { - customTheme: getThemeConfig(), - } as UIThemePreference, - currentUser: undefined, - newUser: undefined, - isAuthenticated: Boolean(getOidcToken()), - authConfig: undefined, - authorizerConfig: undefined, - isSigningUp: false, - jwtPrincipalClaims: [], - jwtPrincipalClaimsMapping: [], - userProfilePics: {}, - cachedEntityData: {}, - selectedPersona: {} as EntityReference, - oidcIdToken: '', - refreshTokenKey: '', - searchCriteria: '', - inlineAlertDetails: undefined, - applications: [], - appPreferences: {}, +export const useApplicationStore = create()((set, get) => ({ + isApplicationLoading: false, + theme: getThemeConfig(), + applicationConfig: { + customTheme: getThemeConfig(), + } as UIThemePreference, + currentUser: undefined, + newUser: undefined, + isAuthenticated: Boolean(getOidcToken()), + authConfig: undefined, + authorizerConfig: undefined, + isSigningUp: false, + jwtPrincipalClaims: [], + jwtPrincipalClaimsMapping: [], + userProfilePics: {}, + cachedEntityData: {}, + selectedPersona: {} as EntityReference, + searchCriteria: '', + inlineAlertDetails: undefined, + applications: [], + appPreferences: {}, - setInlineAlertDetails: (inlineAlertDetails) => { - set({ inlineAlertDetails }); - }, + setInlineAlertDetails: (inlineAlertDetails) => { + set({ inlineAlertDetails }); + }, - setHelperFunctionsRef: (helperFunctions: HelperFunctions) => { - set({ ...helperFunctions }); - }, + setHelperFunctionsRef: (helperFunctions: HelperFunctions) => { + set({ ...helperFunctions }); + }, - setSelectedPersona: (persona: EntityReference) => { - set({ selectedPersona: persona }); - }, + setSelectedPersona: (persona: EntityReference) => { + set({ selectedPersona: persona }); + }, - setApplicationConfig: (config: UIThemePreference) => { - set({ applicationConfig: config, theme: config.customTheme }); - }, - setCurrentUser: (user) => { - set({ currentUser: user }); - }, - setAuthConfig: (authConfig: AuthenticationConfigurationWithScope) => { - set({ authConfig }); - }, - setAuthorizerConfig: (authorizerConfig: AuthorizerConfiguration) => { - set({ authorizerConfig }); - }, - setJwtPrincipalClaims: ( - claims: AuthenticationConfiguration['jwtPrincipalClaims'] - ) => { - set({ jwtPrincipalClaims: claims }); - }, - setJwtPrincipalClaimsMapping: ( - claimMapping: AuthenticationConfiguration['jwtPrincipalClaimsMapping'] - ) => { - set({ jwtPrincipalClaimsMapping: claimMapping }); - }, - setIsAuthenticated: (authenticated: boolean) => { - set({ isAuthenticated: authenticated }); - }, - setIsSigningUp: (signingUp: boolean) => { - set({ isSigningUp: signingUp }); - }, + setApplicationConfig: (config: UIThemePreference) => { + set({ applicationConfig: config, theme: config.customTheme }); + }, + setCurrentUser: (user) => { + set({ currentUser: user }); + }, + setAuthConfig: (authConfig: AuthenticationConfigurationWithScope) => { + set({ authConfig }); + }, + setAuthorizerConfig: (authorizerConfig: AuthorizerConfiguration) => { + set({ authorizerConfig }); + }, + setJwtPrincipalClaims: ( + claims: AuthenticationConfiguration['jwtPrincipalClaims'] + ) => { + set({ jwtPrincipalClaims: claims }); + }, + setJwtPrincipalClaimsMapping: ( + claimMapping: AuthenticationConfiguration['jwtPrincipalClaimsMapping'] + ) => { + set({ jwtPrincipalClaimsMapping: claimMapping }); + }, + setIsAuthenticated: (authenticated: boolean) => { + set({ isAuthenticated: authenticated }); + }, + setIsSigningUp: (signingUp: boolean) => { + set({ isSigningUp: signingUp }); + }, - setApplicationLoading: (loading: boolean) => { - set({ isApplicationLoading: loading }); - }, + setApplicationLoading: (loading: boolean) => { + set({ isApplicationLoading: loading }); + }, - onLoginHandler: () => { - // This is a placeholder function that will be replaced by the actual function - }, - onLogoutHandler: () => { - // This is a placeholder function that will be replaced by the actual function - }, + onLoginHandler: () => { + // This is a placeholder function that will be replaced by the actual function + }, + onLogoutHandler: () => { + // This is a placeholder function that will be replaced by the actual function + }, - handleSuccessfulLogin: () => { - // This is a placeholder function that will be replaced by the actual function - }, - handleFailedLogin: () => { - // This is a placeholder function that will be replaced by the actual function - }, - updateAxiosInterceptors: () => { - // This is a placeholder function that will be replaced by the actual function - }, - trySilentSignIn: (forceLogout?: boolean) => { - if (forceLogout) { - // This is a placeholder function that will be replaced by the actual function - } - }, - updateCurrentUser: (user) => { - set({ currentUser: user }); - }, - updateUserProfilePics: ({ id, user }: { id: string; user: User }) => { - set({ - userProfilePics: { ...get()?.userProfilePics, [id]: user }, - }); - }, - updateCachedEntityData: ({ - id, - entityDetails, - }: { - id: string; - entityDetails: EntityUnion; - }) => { - set({ - cachedEntityData: { - ...get()?.cachedEntityData, - [id]: entityDetails, - }, - }); - }, - updateNewUser: (user) => { - set({ newUser: user }); - }, - getRefreshToken: () => { - return get()?.refreshTokenKey; - }, - setRefreshToken: (refreshToken) => { - set({ refreshTokenKey: refreshToken }); - }, - setAppPreferences: ( - preferences: Partial - ) => { - set((state) => ({ - appPreferences: { - ...state.appPreferences, - ...preferences, - }, - })); - }, - getOidcToken: () => { - return get()?.oidcIdToken; - }, - setOidcToken: (oidcToken) => { - set({ oidcIdToken: oidcToken }); - }, - removeOidcToken: () => { - set({ oidcIdToken: '' }); - }, - removeRefreshToken: () => { - set({ refreshTokenKey: '' }); - }, - updateSearchCriteria: (criteria) => { - set({ searchCriteria: criteria }); - }, - setApplicationsName: (applications: string[]) => { - set({ applications: applications }); - }, - }), - { - name: OM_SESSION_KEY, // name of item in the storage (must be unique) - partialize: (state) => ({ - oidcIdToken: state.oidcIdToken, - refreshTokenKey: state.refreshTokenKey, - }), + handleSuccessfulLogin: () => { + // This is a placeholder function that will be replaced by the actual function + }, + handleFailedLogin: () => { + // This is a placeholder function that will be replaced by the actual function + }, + updateAxiosInterceptors: () => { + // This is a placeholder function that will be replaced by the actual function + }, + trySilentSignIn: (forceLogout?: boolean) => { + if (forceLogout) { + // This is a placeholder function that will be replaced by the actual function } - ) -); + }, + updateCurrentUser: (user) => { + set({ currentUser: user }); + }, + updateUserProfilePics: ({ id, user }: { id: string; user: User }) => { + set({ + userProfilePics: { ...get()?.userProfilePics, [id]: user }, + }); + }, + updateCachedEntityData: ({ + id, + entityDetails, + }: { + id: string; + entityDetails: EntityUnion; + }) => { + set({ + cachedEntityData: { + ...get()?.cachedEntityData, + [id]: entityDetails, + }, + }); + }, + updateNewUser: (user) => { + set({ newUser: user }); + }, + setAppPreferences: ( + preferences: Partial + ) => { + set((state) => ({ + appPreferences: { + ...state.appPreferences, + ...preferences, + }, + })); + }, + updateSearchCriteria: (criteria) => { + set({ searchCriteria: criteria }); + }, + setApplicationsName: (applications: string[]) => { + set({ applications: applications }); + }, +})); diff --git a/openmetadata-ui/src/main/resources/ui/src/interface/store.interface.ts b/openmetadata-ui/src/main/resources/ui/src/interface/store.interface.ts index 656665f13f29..575919b84d0d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/interface/store.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/interface/store.interface.ts @@ -37,7 +37,6 @@ export interface HelperFunctions { handleSuccessfulLogin: (user: OidcUser) => Promise; handleFailedLogin: () => void; updateAxiosInterceptors: () => void; - trySilentSignIn: (forceLogout?: boolean) => Promise; } export interface AppPreferences { @@ -53,8 +52,6 @@ export interface ApplicationStore userProfilePics: Record; cachedEntityData: Record; selectedPersona: EntityReference; - oidcIdToken: string; - refreshTokenKey: string; authConfig?: AuthenticationConfigurationWithScope; applicationConfig?: UIThemePreference; searchCriteria: ExploreSearchIndex | ''; @@ -81,13 +78,6 @@ export interface ApplicationStore id: string; entityDetails: EntityUnion; }) => void; - - getRefreshToken: () => string; - setRefreshToken: (refreshToken: string) => void; - getOidcToken: () => string; - setOidcToken: (oidcToken: string) => void; - removeOidcToken: () => void; - removeRefreshToken: () => void; updateSearchCriteria: (criteria: ExploreSearchIndex | '') => void; trySilentSignIn: (forceLogout?: boolean) => void; setApplicationsName: (applications: string[]) => void; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/SamlCallback/index.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/SamlCallback/index.tsx index ad702786bdc5..dede89feccb7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/SamlCallback/index.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/SamlCallback/index.tsx @@ -19,12 +19,12 @@ import Loader from '../../components/common/Loader/Loader'; import { REFRESH_TOKEN_KEY } from '../../constants/constants'; import { useApplicationStore } from '../../hooks/useApplicationStore'; import useCustomLocation from '../../hooks/useCustomLocation/useCustomLocation'; +import { setOidcToken, setRefreshToken } from '../../utils/LocalStorageUtils'; const cookieStorage = new CookieStorage(); const SamlCallback = () => { - const { handleSuccessfulLogin, setOidcToken, setRefreshToken } = - useApplicationStore(); + const { handleSuccessfulLogin } = useApplicationStore(); const location = useCustomLocation(); const { t } = useTranslation(); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/SwaggerPage/index.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/SwaggerPage/index.test.tsx index 054ad776553c..edb3e0f6f4e2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/SwaggerPage/index.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/SwaggerPage/index.test.tsx @@ -23,9 +23,12 @@ jest.mock('./RapiDocReact', () => { )); }); +jest.mock('../../utils/LocalStorageUtils', () => ({ + getOidcToken: jest.fn().mockReturnValue('fakeToken'), +})); + jest.mock('../../hooks/useApplicationStore', () => ({ useApplicationStore: jest.fn().mockImplementation(() => ({ - getOidcToken: () => 'fakeToken', theme: { primaryColor: '#9c27b0', }, diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/SwaggerPage/index.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/SwaggerPage/index.tsx index cfa8fbae6593..4fc9d7702c2f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/SwaggerPage/index.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/SwaggerPage/index.tsx @@ -17,11 +17,12 @@ import { TEXT_BODY_COLOR, } from '../../constants/constants'; import { useApplicationStore } from '../../hooks/useApplicationStore'; +import { getOidcToken } from '../../utils/LocalStorageUtils'; import RapiDocReact from './RapiDocReact'; import './swagger.less'; const SwaggerPage = () => { - const { getOidcToken, theme } = useApplicationStore(); + const { theme } = useApplicationStore(); const idToken = getOidcToken(); return ( 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 7174f6c9c15f..0cce69fdc5ce 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 @@ -16,21 +16,23 @@ import { AccessTokenResponse } from '../../../rest/auth-API'; import { extractDetailsFromToken } from '../../AuthProvider.util'; import { getOidcToken } from '../../LocalStorageUtils'; +const REFRESH_IN_PROGRESS_KEY = 'refreshInProgress'; // Key to track if refresh is in progress + type RenewTokenCallback = () => | Promise | Promise | Promise; +const REFRESHED_KEY = 'tokenRefreshed'; + class TokenService { - channel: BroadcastChannel; - renewToken: RenewTokenCallback; - tokeUpdateInProgress: boolean; + renewToken: RenewTokenCallback | null = null; + refreshSuccessCallback: (() => void) | null = null; + private static _instance: TokenService; - constructor(renewToken: RenewTokenCallback) { - this.channel = new BroadcastChannel('auth_channel'); - this.renewToken = renewToken; - this.channel.onmessage = this.handleTokenUpdate.bind(this); - this.tokeUpdateInProgress = false; + constructor() { + this.clearRefreshInProgress(); + this.refreshToken = this.refreshToken.bind(this); } // This method will update token across tabs on receiving message to the channel @@ -41,18 +43,40 @@ class TokenService { data: { type, token }, } = event; if (type === 'TOKEN_UPDATE' && token) { - if (typeof token !== 'string') { - useApplicationStore.getState().setOidcToken(token.accessToken); - useApplicationStore.getState().setRefreshToken(token.refreshToken); - useApplicationStore.getState().updateAxiosInterceptors(); - } else { - useApplicationStore.getState().setOidcToken(token); - } + // Token is updated in localStorage hence no need to pass it + this.refreshSuccessCallback && this.refreshSuccessCallback(); + } + } + + // Singleton instance of TokenService + static getInstance() { + if (!TokenService._instance) { + TokenService._instance = new TokenService(); } + + return TokenService._instance; + } + + public updateRenewToken(renewToken: RenewTokenCallback) { + this.renewToken = renewToken; + } + + public updateRefreshSuccessCallback(callback: () => void) { + window.addEventListener('storage', (event) => { + if (event.key === REFRESHED_KEY && event.newValue === 'true') { + callback(); // Notify the tab that the token was refreshed + // Clear once notified + localStorage.removeItem(REFRESHED_KEY); + } + }); } // Refresh the token if it is expired async refreshToken() { + if (this.isTokenUpdateInProgress()) { + return; + } + const token = getOidcToken(); const { isExpired, timeoutExpiry } = extractDetailsFromToken(token); @@ -61,11 +85,12 @@ class TokenService { // Logic to refresh the token const newToken = await this.fetchNewToken(); // To update all the tabs on updating channel token - this.channel.postMessage({ type: 'TOKEN_UPDATE', token: newToken }); + // Notify all tabs that the token has been refreshed + localStorage.setItem(REFRESHED_KEY, 'true'); return newToken; } else { - return token; + return null; } } @@ -74,26 +99,39 @@ class TokenService { let response: string | AccessTokenResponse | null | void = null; if (typeof this.renewToken === 'function') { try { - this.tokeUpdateInProgress = true; + this.setRefreshInProgress(); response = await this.renewToken(); } catch (error) { // 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(); + this.clearRefreshInProgress(); } // Do nothing } finally { - this.tokeUpdateInProgress = false; + // If response is not null then clear the refresh flag + // For Callback based refresh token, response will be void + response && this.clearRefreshInProgress(); } } return response; } - // Tracker for any ongoing token update + // Set refresh in progress (used by the tab that initiates the refresh) + setRefreshInProgress() { + localStorage.setItem(REFRESH_IN_PROGRESS_KEY, 'true'); + } + + // Clear the refresh flag (used after refresh is complete) + clearRefreshInProgress() { + localStorage.removeItem(REFRESH_IN_PROGRESS_KEY); + } + + // Check if a refresh is already in progress (used by other tabs) isTokenUpdateInProgress() { - return this.tokeUpdateInProgress; + return localStorage.getItem(REFRESH_IN_PROGRESS_KEY) === 'true'; } } 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 c4c5877febfc..cc1398d29ec0 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 @@ -34,8 +34,8 @@ import { ClientType, } from '../generated/configuration/authenticationConfiguration'; import { AuthProvider } from '../generated/settings/settings'; -import { useApplicationStore } from '../hooks/useApplicationStore'; import { isDev } from './EnvironmentUtils'; +import { setOidcToken } from './LocalStorageUtils'; const cookieStorage = new CookieStorage(); @@ -423,7 +423,6 @@ export const prepareUserProfileFromClaims = ({ export const parseMSALResponse = (response: AuthenticationResult): OidcUser => { // Call your API with the access token and return the data you need to save in state const { idToken, scopes, account } = response; - const { setOidcToken } = useApplicationStore.getState(); const user = { id_token: idToken, diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/LocalStorageUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/LocalStorageUtils.ts index 1360f1bc62f7..a2408d0f1975 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/LocalStorageUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/LocalStorageUtils.ts @@ -14,7 +14,27 @@ import { OM_SESSION_KEY } from '../hooks/useApplicationStore'; export const getOidcToken = (): string => { return ( - JSON.parse(localStorage.getItem(OM_SESSION_KEY) ?? '{}')?.state - ?.oidcIdToken ?? '' + JSON.parse(localStorage.getItem(OM_SESSION_KEY) ?? '{}')?.oidcIdToken ?? '' ); }; + +export const setOidcToken = (token: string) => { + const session = JSON.parse(localStorage.getItem(OM_SESSION_KEY) ?? '{}'); + + session.oidcIdToken = token; + localStorage.setItem(OM_SESSION_KEY, JSON.stringify(session)); +}; + +export const getRefreshToken = (): string => { + return ( + JSON.parse(localStorage.getItem(OM_SESSION_KEY) ?? '{}')?.refreshTokenKey ?? + '' + ); +}; + +export const setRefreshToken = (token: string) => { + const session = JSON.parse(localStorage.getItem(OM_SESSION_KEY) ?? '{}'); + + session.refreshTokenKey = token; + localStorage.setItem(OM_SESSION_KEY, JSON.stringify(session)); +}; From a2a4625b9b050f1b3015a63d5747f155d00c9863 Mon Sep 17 00:00:00 2001 From: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> Date: Fri, 24 Jan 2025 11:16:19 +0530 Subject: [PATCH 160/294] fix(ui): infinite loading for refresh attempted on app visit (#19491) * fix(ui): infinite loading for refresh attempted on app visit * add tests for refresh logic * added test for validating after refresh behavior * improve test for refresh scenario (cherry picked from commit 1e7377c2e4e664c1948df77fc87c298386a0062b) --- .../ui/playwright/e2e/Pages/Login.spec.ts | 47 +++++++++++++++---- .../Auth/AuthProviders/AuthProvider.tsx | 6 +-- 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Login.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Login.spec.ts index 0d77592eac68..b5251478dbef 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Login.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Login.spec.ts @@ -12,9 +12,10 @@ */ import { expect, test } from '@playwright/test'; import { JWT_EXPIRY_TIME_MAP, LOGIN_ERROR_MESSAGE } from '../../constant/login'; +import { AdminClass } from '../../support/user/AdminClass'; import { UserClass } from '../../support/user/UserClass'; import { performAdminLogin } from '../../utils/admin'; -import { redirectToHomePage } from '../../utils/common'; +import { clickOutside, redirectToHomePage } from '../../utils/common'; import { updateJWTTokenExpiryTime } from '../../utils/login'; import { visitUserProfilePage } from '../../utils/user'; @@ -139,7 +140,7 @@ test.describe('Login flow should work properly', () => { await page.locator('[data-testid="go-back-button"]').click(); }); - test.fixme('Refresh should work', async ({ browser }) => { + test('Refresh should work', async ({ browser }) => { const browserContext = await browser.newContext(); const { apiContext, afterAction } = await performAdminLogin(browser); const page1 = await browserContext.newPage(), @@ -148,8 +149,6 @@ test.describe('Login flow should work properly', () => { const testUser = new UserClass(); await testUser.create(apiContext); - await afterAction(); - await test.step('Login and wait for refresh call is made', async () => { // User login @@ -157,11 +156,8 @@ test.describe('Login flow should work properly', () => { await redirectToHomePage(page1); await redirectToHomePage(page2); - const refreshCall = page1.waitForResponse('**/refresh', { - timeout: 3 * 60 * 1000, - }); - - await refreshCall; + // Need to wait until refresh happens and update the storage + await page1.waitForTimeout(3 * 60 * 1000); await redirectToHomePage(page1); @@ -169,5 +165,38 @@ test.describe('Login flow should work properly', () => { await redirectToHomePage(page2); await visitUserProfilePage(page2, testUser.responseData.name); }); + + await afterAction(); + }); + + test('accessing app with expired token should do auto renew token', async ({ + browser, + }) => { + const browserContext = await browser.newContext(); + + // Create new page and validate access + const page1 = await browserContext.newPage(); + const page2 = await browserContext.newPage(); + + const admin = new AdminClass(); + await admin.login(page1); + + await redirectToHomePage(page1); + await page1.getByTestId('dropdown-profile').click(); + await page1.waitForLoadState('networkidle'); + await clickOutside(page1); + + await expect(page1.getByTestId('user-name')).toContainText(/admin/i); + + // Wait for token expiry + await page2.waitForTimeout(3 * 60 * 1000); + + await redirectToHomePage(page2); + + await page2.getByTestId('dropdown-profile').click(); + await page2.waitForLoadState('networkidle'); + await clickOutside(page2); + + await expect(page2.getByTestId('user-name')).toContainText(/admin/i); }); }); 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 161f2a2dec8d..7ed56792f836 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 @@ -541,13 +541,13 @@ export const AuthProvider = ({ pendingRequests.forEach(({ resolve, reject, config }) => { axiosClient(config).then(resolve).catch(reject); }); + + // Clear the queue after retrying + pendingRequests = []; } else { resetUserDetails(true); } }); - - // Clear the queue after retrying - pendingRequests = []; }); } else { // If refresh is in progress, queue the request From c7371a5fa315faeb69345826bd17f0666c291fc3 Mon Sep 17 00:00:00 2001 From: Suman Maharana Date: Fri, 24 Jan 2025 13:35:53 +0530 Subject: [PATCH 161/294] Fix Duplicate table constraints (#19502) (cherry picked from commit 94ed7e3c1aa2451049d3dcb13bc5b992092d5dd4) --- .../ingestion/source/database/common_db_source.py | 8 ++++++-- .../source/database/sql_column_handler.py | 14 +++++++++----- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/ingestion/src/metadata/ingestion/source/database/common_db_source.py b/ingestion/src/metadata/ingestion/source/database/common_db_source.py index 663b21628cbc..f5345aaafdd8 100644 --- a/ingestion/src/metadata/ingestion/source/database/common_db_source.py +++ b/ingestion/src/metadata/ingestion/source/database/common_db_source.py @@ -681,7 +681,7 @@ def _get_foreign_constraints( foreign_constraint = self._prepare_foreign_constraints( supports_database, column, table_name, schema_name, db_name, columns ) - if foreign_constraint: + if foreign_constraint and foreign_constraint not in foreign_constraints: foreign_constraints.append(foreign_constraint) return foreign_constraints @@ -705,7 +705,11 @@ def update_table_constraints( ) if foreign_table_constraints: if table_constraints: - table_constraints.extend(foreign_table_constraints) + table_constraints.extend( + constraint + for constraint in foreign_table_constraints + if constraint not in table_constraints + ) else: table_constraints = foreign_table_constraints return table_constraints diff --git a/ingestion/src/metadata/ingestion/source/database/sql_column_handler.py b/ingestion/src/metadata/ingestion/source/database/sql_column_handler.py index 6fc98ff1859f..f309ef0d6973 100644 --- a/ingestion/src/metadata/ingestion/source/database/sql_column_handler.py +++ b/ingestion/src/metadata/ingestion/source/database/sql_column_handler.py @@ -246,12 +246,16 @@ def get_columns_and_constraints( # pylint: disable=too-many-locals if len(col) == 1: column_level_unique_constraints.add(col[0]) else: - table_constraints.append( - TableConstraint( - constraintType=ConstraintType.UNIQUE, - columns=col, + if not any( + tc.constraintType == ConstraintType.UNIQUE and tc.columns == col + for tc in table_constraints + ): + table_constraints.append( + TableConstraint( + constraintType=ConstraintType.UNIQUE, + columns=col, + ) ) - ) if len(pk_columns) > 1: table_constraints.append( TableConstraint( From 581dfa41c044f905f1260a84bee0977738f36be1 Mon Sep 17 00:00:00 2001 From: Akash Verma <138790903+akashverma0786@users.noreply.github.com> Date: Fri, 24 Jan 2025 13:44:55 +0530 Subject: [PATCH 162/294] Fix: Updated queries causing arithmetic overflow error (#19505) Co-authored-by: Akash Verma (cherry picked from commit 68a0e6af58e2f2c2497ed4149bf8e41ba2b309b1) --- .../ingestion/source/database/mssql/queries.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ingestion/src/metadata/ingestion/source/database/mssql/queries.py b/ingestion/src/metadata/ingestion/source/database/mssql/queries.py index d9bab948e080..0ed3f06da060 100644 --- a/ingestion/src/metadata/ingestion/source/database/mssql/queries.py +++ b/ingestion/src/metadata/ingestion/source/database/mssql/queries.py @@ -20,8 +20,8 @@ db.NAME database_name, t.text query_text, s.last_execution_time start_time, - DATEADD(s, s.total_elapsed_time/1000, s.last_execution_time) end_time, - s.total_elapsed_time/1000 duration, + DATEADD(s, s.last_elapsed_time/1000000, s.last_execution_time) end_time, + s.last_elapsed_time/1000000 duration, NULL schema_name, NULL query_type, NULL user_name, @@ -209,7 +209,7 @@ WITH SP_HISTORY (start_time, end_time, procedure_name, query_text) AS ( select s.last_execution_time start_time, - DATEADD(s, s.total_elapsed_time/1000, s.last_execution_time) end_time, + DATEADD(s, s.last_elapsed_time/1000000, s.last_execution_time) end_time, OBJECT_NAME(object_id, database_id) as procedure_name, text as query_text from sys.dm_exec_procedure_stats s @@ -222,8 +222,8 @@ db.NAME database_name, t.text query_text, s.last_execution_time start_time, - DATEADD(s, s.total_elapsed_time/1000, s.last_execution_time) end_time, - s.total_elapsed_time/1000 duration, + DATEADD(s, s.last_elapsed_time/1000000, s.last_execution_time) end_time, + s.last_elapsed_time/1000000 duration, case when t.text LIKE '%%MERGE%%' then 'MERGE' when t.text LIKE '%%UPDATE%%' then 'UPDATE' From edc66eab24c68144576af8f2f5a2f3cc8083c92c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E5=8F=AF=E8=80=90?= <46134044+sdhzwc@users.noreply.github.com> Date: Fri, 24 Jan 2025 17:04:02 +0800 Subject: [PATCH 163/294] fix(ui) fix retention period 'Cancel' international display issue (#19404) Co-authored-by: Ashish Gupta (cherry picked from commit 03429148b374918ab7c832fed2d5999e3ecc20b4) --- .../Database/RetentionPeriod/RetentionPeriod.component.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/RetentionPeriod/RetentionPeriod.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/RetentionPeriod/RetentionPeriod.component.tsx index 83a1699ef053..b0f8ef72ecb1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/RetentionPeriod/RetentionPeriod.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/RetentionPeriod/RetentionPeriod.component.tsx @@ -143,6 +143,7 @@ const RetentionPeriod = ({ Date: Fri, 24 Jan 2025 16:54:50 +0530 Subject: [PATCH 164/294] fix the glossary column width sizes for the resizable columns (#19497) * fix the glossary column width sizes to the previous one * move the colum logic to utils file and added test for it (cherry picked from commit 182b8ab864fc44cf3c2fbf2a4ae739f97beff32d) --- .../GlossaryTermTab.component.tsx | 37 +++++++++++++++---- .../ui/src/components/common/Table/Table.tsx | 13 ++++--- .../resources/ui/src/utils/CommonUtils.tsx | 13 +++++++ .../ui/src/utils/GlossaryUtils.test.ts | 29 +++++++++++++++ .../resources/ui/src/utils/GlossaryUtils.tsx | 16 ++++++++ 5 files changed, 94 insertions(+), 14 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx index f45e2b3fdd18..7f90651e9d42 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx @@ -33,7 +33,13 @@ import { AxiosError } from 'axios'; import classNames from 'classnames'; import { compare } from 'fast-json-patch'; import { cloneDeep, isEmpty, isUndefined } from 'lodash'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { useTranslation } from 'react-i18next'; @@ -77,6 +83,7 @@ import { buildTree, findExpandableKeysForArray, findGlossaryTermByFqn, + glossaryTermTableColumnsWidth, StatusClass, } from '../../../utils/GlossaryUtils'; import { getGlossaryPath } from '../../../utils/RouterUtils'; @@ -102,6 +109,8 @@ const GlossaryTermTab = ({ onEditGlossaryTerm, className, }: GlossaryTermTabProps) => { + const tableRef = useRef(null); + const [tableWidth, setTableWidth] = useState(0); const { activeGlossary, glossaryChildTerms, setGlossaryChildTerms } = useGlossaryStore(); const { t } = useTranslation(); @@ -147,6 +156,11 @@ const GlossaryTermTab = ({ return null; }, [isGlossary, activeGlossary]); + const tableColumnsWidth = useMemo( + () => glossaryTermTableColumnsWidth(tableWidth, permissions.Create), + [permissions.Create, tableWidth] + ); + const columns = useMemo(() => { const data: ColumnsType = [ { @@ -155,7 +169,7 @@ const GlossaryTermTab = ({ key: 'name', className: 'glossary-name-column', ellipsis: true, - width: 200, + width: tableColumnsWidth.name, render: (_, record) => { const name = getEntityName(record); @@ -184,7 +198,7 @@ const GlossaryTermTab = ({ title: t('label.description'), dataIndex: 'description', key: 'description', - width: isGlossary ? 250 : 650, + width: tableColumnsWidth.description, render: (description: string) => description.trim() ? ( ( { return isEmpty(synonyms) ? (
{NO_DATA_PLACEHOLDER}
@@ -235,7 +249,7 @@ const GlossaryTermTab = ({ title: t('label.owner-plural'), dataIndex: 'owners', key: 'owners', - width: 150, + width: tableColumnsWidth.owners, render: (owners: EntityReference[]) => , }, { @@ -245,7 +259,7 @@ const GlossaryTermTab = ({ // this check is added to the width, since the last column is optional and to maintain // the re-sizing of the column should not be affected the others columns width sizes. ...(permissions.Create && { - width: 100, + width: tableColumnsWidth.status, }), render: (_, record) => { const status = record.status ?? Status.Approved; @@ -311,7 +325,7 @@ const GlossaryTermTab = ({ } return data; - }, [permissions, isGlossary]); + }, [permissions, tableColumnsWidth]); const defaultCheckedList = useMemo( () => @@ -796,6 +810,12 @@ const GlossaryTermTab = ({ return expandedRowKeys.length === expandableKeys.length; }, [expandedRowKeys, expandableKeys]); + useEffect(() => { + if (tableRef.current) { + setTableWidth(tableRef.current.offsetWidth); + } + }, []); + if (termsLoading) { return ; } @@ -905,6 +925,7 @@ const GlossaryTermTab = ({ expandable={expandableConfig} loading={isTableLoading} pagination={false} + ref={tableRef} rowKey="fullyQualifiedName" size="small" onHeaderRow={onTableHeader} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/Table/Table.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/Table/Table.tsx index ec80a3752ee9..cc5a59f9ad02 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/Table/Table.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/Table/Table.tsx @@ -11,7 +11,7 @@ * limitations under the License. */ import { SpinProps, Table as AntdTable, TableProps } from 'antd'; -import React, { useMemo } from 'react'; +import React, { forwardRef, Ref, useMemo } from 'react'; import { useAntdColumnResize } from 'react-antd-column-resize'; import { getTableExpandableConfig } from '../../../utils/TableUtils'; import Loader from '../Loader/Loader'; @@ -21,10 +21,10 @@ interface TableComponentProps extends TableProps { } // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types -const Table = ({ - loading, - ...rest -}: TableComponentProps) => { +const Table = ( + { loading, ...rest }: TableComponentProps, + ref: Ref | null | undefined +) => { const { resizableColumns, components, tableWidth } = useAntdColumnResize( () => ({ columns: rest.columns || [], minWidth: 150 }), [rest.columns] @@ -95,9 +95,10 @@ const Table = ({ ...rest.locale, emptyText: isLoading ? null : rest.locale?.emptyText, }} + ref={ref} {...resizingTableProps} /> ); }; -export default Table; +export default forwardRef>(Table); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/CommonUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/CommonUtils.tsx index 542d66bf51a1..1fb81a9d09db 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/CommonUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/CommonUtils.tsx @@ -907,3 +907,16 @@ const hexToRgba = (hex: string, opacity: number): string => { return `rgba(${r}, ${g}, ${b}, ${opacity.toFixed(2)})`; }; + +/** + * Provide the calculated percentage value from the number provided + * @param value - value on which percentage will be calculated + * @param percentageValue - PercentageValue like 20, 35 or 50 + * @returns {number} - value derived after calculating percentage, like for 1000 on 10% = 100 + */ +export const calculatePercentageFromValue = ( + value: number, + percentageValue: number +) => { + return (value * percentageValue) / 100; +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.test.ts index 066663638f4f..c3a3b5cebd53 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.test.ts @@ -28,6 +28,7 @@ import { findExpandableKeys, findExpandableKeysForArray, getQueryFilterToExcludeTerm, + glossaryTermTableColumnsWidth, } from './GlossaryUtils'; describe('Glossary Utils', () => { @@ -353,3 +354,31 @@ describe('Glossary Utils - findAndUpdateNested', () => { expect(updatedTerms).toEqual(terms); }); }); + +describe('Glossary Utils - glossaryTermTableColumnsWidth', () => { + it('should return columnsWidth object based on Table width', () => { + const columnWidthObject = glossaryTermTableColumnsWidth(1000, true); + + expect(columnWidthObject).toEqual({ + description: 210, + name: 400, + owners: 170, + reviewers: 330, + status: 120, + synonyms: 330, + }); + }); + + it('should return columnsWidth object based on Table width when not having create permission', () => { + const columnWidthObject = glossaryTermTableColumnsWidth(1000, false); + + expect(columnWidthObject).toEqual({ + description: 330, + name: 400, + owners: 170, + reviewers: 330, + status: 120, + synonyms: 330, + }); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.tsx index ecf5a1e46c42..90d89006ae26 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.tsx @@ -22,6 +22,7 @@ import { FQN_SEPARATOR_CHAR } from '../constants/char.constants'; import { EntityType } from '../enums/entity.enum'; import { Glossary } from '../generated/entity/data/glossary'; import { GlossaryTerm, Status } from '../generated/entity/data/glossaryTerm'; +import { calculatePercentageFromValue } from './CommonUtils'; import { getEntityName } from './EntityUtils'; import Fqn from './Fqn'; import { getGlossaryPath } from './RouterUtils'; @@ -370,3 +371,18 @@ export const findAndUpdateNested = ( return term; }); }; + +export const glossaryTermTableColumnsWidth = ( + tableWidth: number, + havingCreatePermission: boolean +) => ({ + name: calculatePercentageFromValue(tableWidth, 40), + description: calculatePercentageFromValue( + tableWidth, + havingCreatePermission ? 21 : 33 + ), + reviewers: calculatePercentageFromValue(tableWidth, 33), + synonyms: calculatePercentageFromValue(tableWidth, 33), + owners: calculatePercentageFromValue(tableWidth, 17), + status: calculatePercentageFromValue(tableWidth, 12), +}); From 8e3f4a8c72cda79236104eb418a540fc9e9603ea Mon Sep 17 00:00:00 2001 From: Pranita Fulsundar Date: Fri, 24 Jan 2025 20:35:22 +0530 Subject: [PATCH 165/294] fix(ui): hide description tooltip for tag edit mode (#19463) * fix: remove tooltip for tag edit mode * refactor: remove comments * fix: tooltip logic for non edit mode * fix: hide tooltip for tag edit mode * refactor: remove prop for TagsV1 component * style: move cursor-pointer property to Tag * fix: unit test failure * fix: mock implementations for unit test * revert test case changes (cherry picked from commit c817785bb1ab52385086437e5fb7af545d58096b) --- .../Tag/TagsV1/TagsV1.component.tsx | 25 ++++++++++++------- .../components/Tag/TagsV1/TagsV1.interface.ts | 1 + .../AsyncSelectList/AsyncSelectList.tsx | 1 + .../AsyncSelectList/TreeAsyncSelectList.tsx | 1 + 4 files changed, 19 insertions(+), 9 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Tag/TagsV1/TagsV1.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Tag/TagsV1/TagsV1.component.tsx index 1eba4884903e..450782471a3d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Tag/TagsV1/TagsV1.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Tag/TagsV1/TagsV1.component.tsx @@ -41,6 +41,7 @@ const TagsV1 = ({ tooltipOverride, tagType, size, + isEditTags, }: TagsV1Props) => { const color = useMemo( () => (isVersionPage ? undefined : tag.style?.color), @@ -144,7 +145,8 @@ const TagsV1 = ({ ), }, 'tag-chip tag-chip-content', - size + size, + 'cursor-pointer' )} data-testid="tags" style={ @@ -185,14 +187,19 @@ const TagsV1 = ({ } return ( - - {tagChip} - + <> + {isEditTags ? ( + tagChip + ) : ( + + {tagChip} + + )} + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Tag/TagsV1/TagsV1.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Tag/TagsV1/TagsV1.interface.ts index e66686e9c7fe..d73d707f8caf 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Tag/TagsV1/TagsV1.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Tag/TagsV1/TagsV1.interface.ts @@ -31,4 +31,5 @@ export type TagsV1Props = { tooltipOverride?: string; tagType?: TagSource; size?: SelectProps['size']; + isEditTags?: boolean; }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/AsyncSelectList.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/AsyncSelectList.tsx index c10ba5908001..3bd1621e228d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/AsyncSelectList.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/AsyncSelectList.tsx @@ -248,6 +248,7 @@ const AsyncSelectList: FC = ({ return ( > = ({ return ( Date: Mon, 27 Jan 2025 11:20:25 +0530 Subject: [PATCH 166/294] GEN -19341 : Improvement - Avoid usage of CONCAT in WHERE clause (#19510) * Improvement : Avoid usage of CONCAT in WHERE clause * fix tests * remove usage of @BindFqnConcat * remove usage of @BindFqnConcat * fix test * fix test (cherry picked from commit 82198f81266799e65e308e13b9c16a7029dec301) --- .../service/jdbi3/CollectionDAO.java | 256 +++++++++++++----- .../service/util/jdbi/BindConcat.java | 80 ++++++ 2 files changed, 261 insertions(+), 75 deletions(-) create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/util/jdbi/BindConcat.java diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java index 0e42b907331c..86437cc72752 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java @@ -152,6 +152,7 @@ import org.openmetadata.service.util.EntityUtil; import org.openmetadata.service.util.FullyQualifiedName; import org.openmetadata.service.util.JsonUtils; +import org.openmetadata.service.util.jdbi.BindConcat; import org.openmetadata.service.util.jdbi.BindFQN; import org.openmetadata.service.util.jdbi.BindListFQN; import org.openmetadata.service.util.jdbi.BindUUID; @@ -728,20 +729,27 @@ void insert( @SqlQuery( "SELECT id, extension, json " + "FROM entity_extension " - + "WHERE id IN () AND extension LIKE CONCAT(:extensionPrefix, '.%') " + + "WHERE id IN () AND extension LIKE :extension " + "ORDER BY id, extension") @RegisterRowMapper(ExtensionRecordWithIdMapper.class) List getExtensionsBatch( - @BindList("ids") List ids, @Bind("extensionPrefix") String extensionPrefix); + @BindList("ids") List ids, + @BindConcat( + value = "extension", + parts = {":extensionPrefix", ".%"}) + String extensionPrefix); @SqlQuery( "SELECT id, extension, json, jsonschema " + "FROM entity_extension " - + "WHERE extension LIKE CONCAT(:extensionPrefix, '%') " + + "WHERE extension LIKE :extension " + "ORDER BY id, extension") @RegisterRowMapper(ExtensionWithIdAndSchemaRowMapper.class) List getExtensionsByPrefixBatch( - @Bind("extensionPrefix") String extensionPrefix); + @BindConcat( + value = "extension", + parts = {":extensionPrefix", "%"}) + String extensionPrefix); @Transaction @ConnectionAwareSqlBatch( @@ -1465,16 +1473,25 @@ default List listThreadsByEntityLink( @SqlQuery( "SELECT json FROM thread_entity " + "AND hash_id in (SELECT fromFQNHash FROM field_relationship WHERE " - + "(:fqnPrefixHash IS NULL OR toFQNHash LIKE CONCAT(:fqnPrefixHash, '.%') OR toFQNHash=:fqnPrefixHash) AND fromType='THREAD' AND " - + "(:toType IS NULL OR toType LIKE CONCAT(:toType, '.%') OR toType=:toType) AND relation= :relation) " + + "(:fqnPrefixHash IS NULL OR toFQNHash LIKE :concatFqnPrefixHash OR toFQNHash=:fqnPrefixHash) AND fromType='THREAD' AND " + + "(:toType IS NULL OR toType LIKE :concatToType OR toType=:toType) AND relation= :relation) " + "AND (:userName IS NULL OR MD5(id) in (SELECT toFQNHash FROM field_relationship WHERE " + " ((fromType='user' AND fromFQNHash= :userName) OR" + " (fromType='team' AND fromFQNHash IN ())) AND toType='THREAD' AND relation= :filterRelation) )" + "ORDER BY createdAt DESC " + "LIMIT :limit") List listThreadsByEntityLink( - @BindFQN("fqnPrefixHash") String fqnPrefixHash, - @Bind("toType") String toType, + @BindConcat( + value = "concatFqnPrefixHash", + original = "fqnPrefixHash", + parts = {":fqnPrefixHash", ".%"}, + hash = true) + String fqnPrefixHash, + @BindConcat( + value = "concatToType", + original = "toType", + parts = {":toType", ".%"}) + String toType, @Bind("limit") int limit, @Bind("relation") int relation, @BindFQN("userName") String userName, @@ -1505,14 +1522,23 @@ default int listCountThreadsByEntityLink( @SqlQuery( "SELECT count(id) FROM thread_entity " + "AND hash_id in (SELECT fromFQNHash FROM field_relationship WHERE " - + "(:fqnPrefixHash IS NULL OR toFQNHash LIKE CONCAT(:fqnPrefixHash, '.%') OR toFQNHash=:fqnPrefixHash) AND fromType='THREAD' AND " - + "(:toType IS NULL OR toType LIKE CONCAT(:toType, '.%') OR toType=:toType) AND relation= :relation) " + + "(:fqnPrefixHash IS NULL OR toFQNHash LIKE :concatFqnPrefixHash OR toFQNHash=:fqnPrefixHash) AND fromType='THREAD' AND " + + "(:toType IS NULL OR toType LIKE :concatToType OR toType=:toType) AND relation= :relation) " + "AND (:userName IS NULL OR id in (SELECT toFQNHash FROM field_relationship WHERE " + " ((fromType='user' AND fromFQNHash= :userName) OR" + " (fromType='team' AND fromFQNHash IN ())) AND toType='THREAD' AND relation= :filterRelation) )") int listCountThreadsByEntityLink( - @BindFQN("fqnPrefixHash") String fqnPrefixHash, - @Bind("toType") String toType, + @BindConcat( + value = "concatFqnPrefixHash", + original = "fqnPrefixHash", + parts = {":fqnPrefixHash", ".%"}, + hash = true) + String fqnPrefixHash, + @BindConcat( + value = "concatToType", + original = "toType", + parts = {":toType", ".%"}) + String toType, @Bind("relation") int relation, @Bind("userName") String userName, @BindList("teamNames") List teamNames, @@ -1534,9 +1560,9 @@ int listCountThreadsByEntityLink( + " WHERE hash_id IN ( " + " SELECT fromFQNHash FROM field_relationship " + " WHERE " - + " (:fqnPrefixHash IS NULL OR toFQNHash LIKE CONCAT(:fqnPrefixHash, '.%') OR toFQNHash = :fqnPrefixHash) " + + " (:fqnPrefixHash IS NULL OR toFQNHash LIKE :concatFqnPrefixHash OR toFQNHash = :fqnPrefixHash) " + " AND fromType = 'THREAD' " - + " AND (:toType IS NULL OR toType LIKE CONCAT(:toType, '.%') OR toType = :toType) " + + " AND (:toType IS NULL OR toType LIKE :concatToType OR toType = :toType) " + " AND relation = 3 " + " ) " + " UNION " @@ -1548,8 +1574,17 @@ int listCountThreadsByEntityLink( @RegisterRowMapper(ThreadCountFieldMapper.class) List> listCountByEntityLink( @BindUUID("entityId") UUID entityId, - @BindFQN("fqnPrefixHash") String fqnPrefixHash, - @Bind("toType") String toType); + @BindConcat( + value = "concatFqnPrefixHash", + original = "fqnPrefixHash", + parts = {":fqnPrefixHash", ".%"}, + hash = true) + String fqnPrefixHash, + @BindConcat( + value = "concatToType", + original = "toType", + parts = {":toType", ".%"}) + String toType); @ConnectionAwareSqlQuery( value = @@ -1759,18 +1794,31 @@ int listCountThreadsByMentions( @SqlQuery( "SELECT json FROM thread_entity " + "AND MD5(id) in (SELECT fromFQNHash FROM field_relationship WHERE " - + "(:fqnPrefixHash IS NULL OR toFQNHash LIKE CONCAT(:fqnPrefixHash, '.%') OR toFQNHash=:fqnPrefixHash) AND fromType='THREAD' AND " - + "((:toType1 IS NULL OR toType LIKE CONCAT(:toType1, '.%') OR toType=:toType1) OR " - + "(:toType2 IS NULL OR toType LIKE CONCAT(:toType2, '.%') OR toType=:toType2)) AND relation= :relation)" + + "(:fqnPrefixHash IS NULL OR toFQNHash LIKE :concatFqnPrefixHash OR toFQNHash=:fqnPrefixHash) AND fromType='THREAD' AND " + + "((:toType1 IS NULL OR toType LIKE :concatToType1 OR toType=:toType1) OR " + + "(:toType2 IS NULL OR toType LIKE :concatToType2 OR toType=:toType2)) AND relation= :relation)" + "AND (:userName IS NULL OR MD5(id) in (SELECT toFQNHash FROM field_relationship WHERE " + " ((fromType='user' AND fromFQNHash= :userName) OR" + " (fromType='team' AND fromFQNHash IN ())) AND toType='THREAD' AND relation= :filterRelation) )" + "ORDER BY createdAt DESC " + "LIMIT :limit") List listThreadsByGlossaryAndTerms( - @BindFQN("fqnPrefixHash") String fqnPrefixHash, - @Bind("toType1") String toType1, - @Bind("toType2") String toType2, + @BindConcat( + value = "concatFqnPrefixHash", + original = "fqnPrefixHash", + parts = {":fqnPrefixHash", ".%"}, + hash = true) + String fqnPrefixHash, + @BindConcat( + value = "concatToType1", + original = "toType1", + parts = {":toType1", ".%"}) + String toType1, + @BindConcat( + value = "concatToType2", + original = "toType2", + parts = {":toType2", ".%"}) + String toType2, @Bind("limit") int limit, @Bind("relation") int relation, @BindFQN("userName") String userName, @@ -1801,9 +1849,9 @@ default List> listCountThreadsByGlossaryAndTerms( + " WHERE te.hash_id IN ( " + " SELECT fr.fromFQNHash " + " FROM field_relationship fr " - + " WHERE (:fqnPrefixHash IS NULL OR fr.toFQNHash LIKE CONCAT(:fqnPrefixHash, '.%') OR fr.toFQNHash = :fqnPrefixHash) " + + " WHERE (:fqnPrefixHash IS NULL OR fr.toFQNHash LIKE :concatFqnPrefixHash OR fr.toFQNHash = :fqnPrefixHash) " + " AND fr.fromType = 'THREAD' " - + " AND (:toType1 IS NULL OR fr.toType LIKE CONCAT(:toType1, '.%') OR fr.toType = :toType1) " + + " AND (:toType1 IS NULL OR fr.toType LIKE :concatToType1 OR fr.toType = :toType1) " + " AND fr.relation = 3 " + " ) " + " UNION " @@ -1814,9 +1862,9 @@ default List> listCountThreadsByGlossaryAndTerms( + " SELECT fr.fromFQNHash " + " FROM field_relationship fr " + " JOIN thread_entity te2 ON te2.hash_id = fr.fromFQNHash WHERE fr.fromFQNHash = te.hash_id AND te2.type = 'Task' " - + " AND (:fqnPrefixHash IS NULL OR fr.toFQNHash LIKE CONCAT(:fqnPrefixHash, '.%') OR fr.toFQNHash = :fqnPrefixHash) " + + " AND (:fqnPrefixHash IS NULL OR fr.toFQNHash LIKE :concatFqnPrefixHash OR fr.toFQNHash = :fqnPrefixHash) " + " AND fr.fromType = 'THREAD' " - + " AND (:toType2 IS NULL OR fr.toType LIKE CONCAT(:toType2, '.%') OR fr.toType = :toType2) " + + " AND (:toType2 IS NULL OR fr.toType LIKE :concatToType2 OR fr.toType = :toType2) " + " AND fr.relation = 3 " + " ) " + ") AS combined_results WHERE combined_results.type is not NULL " @@ -1824,9 +1872,22 @@ default List> listCountThreadsByGlossaryAndTerms( @RegisterRowMapper(ThreadCountFieldMapper.class) List> listCountThreadsByGlossaryAndTerms( @BindUUID("entityId") UUID entityId, - @BindFQN("fqnPrefixHash") String fqnPrefixHash, - @Bind("toType1") String toType1, - @Bind("toType2") String toType2); + @BindConcat( + value = "concatFqnPrefixHash", + original = "fqnPrefixHash", + parts = {":fqnPrefixHash", ".%"}, + hash = true) + String fqnPrefixHash, + @BindConcat( + value = "concatToType1", + original = "toType1", + parts = {":toType1", ".%"}) + String toType1, + @BindConcat( + value = "concatToType2", + original = "toType2", + parts = {":toType2", ".%"}) + String toType2); @SqlQuery("select id from thread_entity where entityId = :entityId") List findByEntityId(@Bind("entityId") String entityId); @@ -1931,11 +1992,15 @@ List> findFrom( @SqlQuery( "SELECT fromFQN, toFQN, json FROM field_relationship WHERE " - + "fromFQNHash LIKE CONCAT(:fqnPrefixHash, '%') AND fromType = :fromType AND toType = :toType " + + "fromFQNHash LIKE :concatFqnPrefixHash AND fromType = :fromType AND toType = :toType " + "AND relation = :relation") @RegisterRowMapper(ToFieldMapper.class) List> listToByPrefix( - @BindFQN("fqnPrefixHash") String fqnPrefixHash, + @BindConcat( + value = "concatFqnPrefixHash", + parts = {":fqnPrefixHash", "%"}, + hash = true) + String fqnPrefixHash, @Bind("fromType") String fromType, @Bind("toType") String toType, @Bind("relation") int relation); @@ -1961,13 +2026,17 @@ List> listBidirectional( @SqlQuery( "SELECT fromFQN, toFQN, json FROM field_relationship WHERE " - + "fromFQNHash LIKE CONCAT(:fqnPrefixHash, '%') AND fromType = :type AND toType = :otherType AND relation = :relation " + + "fromFQNHash LIKE :concatFqnPrefixHash AND fromType = :type AND toType = :otherType AND relation = :relation " + "UNION " + "SELECT toFQN, fromFQN, json FROM field_relationship WHERE " - + "toFQNHash LIKE CONCAT(:fqnPrefixHash, '%') AND toType = :type AND fromType = :otherType AND relation = :relation") + + "toFQNHash LIKE :concatFqnPrefixHash AND toType = :type AND fromType = :otherType AND relation = :relation") @RegisterRowMapper(ToFieldMapper.class) List> listBidirectionalByPrefix( - @BindFQN("fqnPrefixHash") String fqnPrefixHash, + @BindConcat( + value = "concatFqnPrefixHash", + parts = {":fqnPrefixHash", "%"}, + hash = true) + String fqnPrefixHash, @Bind("type") String type, @Bind("otherType") String otherType, @Bind("relation") int relation); @@ -2459,12 +2528,12 @@ default int listCount(ListFilter filter) { String directChildrenOf = filter.getQueryParam("directChildrenOf"); if (!nullOrEmpty(directChildrenOf)) { - filter.queryParams.put( - "directChildrenOfHash", FullyQualifiedName.buildHash(directChildrenOf)); - condition = - String.format( - " %s AND fqnHash = CONCAT(:directChildrenOfHash, '.', MD5(CASE WHEN name LIKE '%%.%%' THEN CONCAT('\"', name, '\"') ELSE name END)) ", - condition); + String parentFqnHash = FullyQualifiedName.buildHash(directChildrenOf); + filter.queryParams.put("fqnHashSingleLevel", parentFqnHash + ".%"); + filter.queryParams.put("fqnHashNestedLevel", parentFqnHash + ".%.%"); + + condition += + " AND fqnHash LIKE :fqnHashSingleLevel AND fqnHash NOT LIKE :fqnHashNestedLevel"; } return listCount(getTableName(), getNameHashColumn(), filter.getQueryParams(), condition); @@ -2477,12 +2546,12 @@ default List listBefore( String directChildrenOf = filter.getQueryParam("directChildrenOf"); if (!nullOrEmpty(directChildrenOf)) { - filter.queryParams.put( - "directChildrenOfHash", FullyQualifiedName.buildHash(directChildrenOf)); - condition = - String.format( - " %s AND fqnHash = CONCAT(:directChildrenOfHash, '.', MD5(CASE WHEN name LIKE '%%.%%' THEN CONCAT('\"', name, '\"') ELSE name END)) ", - condition); + String parentFqnHash = FullyQualifiedName.buildHash(directChildrenOf); + filter.queryParams.put("fqnHashSingleLevel", parentFqnHash + ".%"); + filter.queryParams.put("fqnHashNestedLevel", parentFqnHash + ".%.%"); + + condition += + " AND fqnHash LIKE :fqnHashSingleLevel AND fqnHash NOT LIKE :fqnHashNestedLevel"; } return listBefore( @@ -2495,19 +2564,24 @@ default List listAfter(ListFilter filter, int limit, String afterName, S String directChildrenOf = filter.getQueryParam("directChildrenOf"); if (!nullOrEmpty(directChildrenOf)) { - filter.queryParams.put( - "directChildrenOfHash", FullyQualifiedName.buildHash(directChildrenOf)); - condition = - String.format( - " %s AND fqnHash = CONCAT(:directChildrenOfHash, '.', MD5(CASE WHEN name LIKE '%%.%%' THEN CONCAT('\"', name, '\"') ELSE name END)) ", - condition); + String parentFqnHash = FullyQualifiedName.buildHash(directChildrenOf); + filter.queryParams.put("fqnHashSingleLevel", parentFqnHash + ".%"); + filter.queryParams.put("fqnHashNestedLevel", parentFqnHash + ".%.%"); + + condition += + " AND fqnHash LIKE :fqnHashSingleLevel AND fqnHash NOT LIKE :fqnHashNestedLevel"; } return listAfter( getTableName(), filter.getQueryParams(), condition, limit, afterName, afterId); } - @SqlQuery("select json FROM glossary_term_entity where fqnhash LIKE CONCAT(:fqnhash, '.%')") - List getNestedTerms(@BindFQN("fqnhash") String fqnhash); + @SqlQuery("select json FROM glossary_term_entity where fqnhash LIKE :concatFqnhash ") + List getNestedTerms( + @BindConcat( + value = "concatFqnhash", + parts = {":fqnhash", ".%"}, + hash = true) + String fqnhash); } interface IngestionPipelineDAO extends EntityDAO { @@ -3138,8 +3212,13 @@ default List listAfter(ListFilter filter, int limit, String afterName, S afterId); } - @SqlQuery("select json FROM tag where fqnhash LIKE CONCAT(:fqnhash, '.%')") - List getTagsStartingWithPrefix(@BindFQN("fqnhash") String fqnhash); + @SqlQuery("select json FROM tag where fqnhash LIKE :concatFqnhash") + List getTagsStartingWithPrefix( + @BindConcat( + value = "concatFqnhash", + parts = {":fqnhash", ".%"}, + hash = true) + String fqnhash); } @RegisterRowMapper(TagLabelMapper.class) @@ -3168,10 +3247,11 @@ default List getTags(String targetFQN) { default Map> getTagsByPrefix( String targetFQNPrefix, String postfix, boolean requiresFqnHash) { - String fqnHash = + String targetFQNPrefixHash = requiresFqnHash ? FullyQualifiedName.buildHash(targetFQNPrefix) : targetFQNPrefix; Map> resultSet = new LinkedHashMap<>(); - List> tags = getTagsInternalByPrefix(fqnHash, postfix); + List> tags = + getTagsInternalByPrefix(new String[] {targetFQNPrefixHash, postfix}); tags.forEach( pair -> { String targetHash = pair.getLeft(); @@ -3217,7 +3297,7 @@ List getTagsInternalBatch( + " ON ta.fqnHash = tu.tagFQNHash " + " WHERE tu.source = 0 " + ") AS combined_data " - + "WHERE combined_data.targetFQNHash LIKE CONCAT(:targetFQNHashPrefix, :postfix)", + + "WHERE combined_data.targetFQNHash LIKE :targetFQNHash", connectionType = MYSQL) @ConnectionAwareSqlQuery( value = @@ -3233,11 +3313,14 @@ List getTagsInternalBatch( + " JOIN tag_usage AS tu ON ta.fqnHash = tu.tagFQNHash " + " WHERE tu.source = 0 " + ") AS combined_data " - + "WHERE combined_data.targetFQNHash LIKE CONCAT(:targetFQNHashPrefix, :postfix)", + + "WHERE combined_data.targetFQNHash LIKE :targetFQNHash", connectionType = POSTGRES) @RegisterRowMapper(TagLabelRowMapperWithTargetFqnHash.class) List> getTagsInternalByPrefix( - @Bind("targetFQNHashPrefix") String targetFQNHashPrefix, @Bind("postfix") String postfix); + @BindConcat( + value = "targetFQNHash", + parts = {":targetFQNHashPrefix", ":postfix"}) + String... targetFQNHash); @SqlQuery("SELECT * FROM tag_usage") @Deprecated(since = "Release 1.1") @@ -3246,17 +3329,29 @@ List> getTagsInternalByPrefix( @SqlQuery( "SELECT COUNT(*) FROM tag_usage " - + "WHERE (tagFQNHash LIKE CONCAT(:tagFqnHash, '.%') OR tagFQNHash = :tagFqnHash) " + + "WHERE (tagFQNHash LIKE :concatTagFQNHash OR tagFQNHash = :tagFqnHash) " + "AND source = :source") - int getTagCount(@Bind("source") int source, @BindFQN("tagFqnHash") String tagFqnHash); + int getTagCount( + @Bind("source") int source, + @BindConcat( + value = "concatTagFQNHash", + original = "tagFqnHash", + parts = {":tagFqnHash", ".%"}, + hash = true) + String tagFqnHash); @SqlUpdate("DELETE FROM tag_usage where targetFQNHash = :targetFQNHash") void deleteTagsByTarget(@BindFQN("targetFQNHash") String targetFQNHash); @SqlUpdate( - "DELETE FROM tag_usage where tagFQNHash = :tagFqnHash AND targetFQNHash LIKE CONCAT(:targetFQNHash, '%')") + "DELETE FROM tag_usage where tagFQNHash = :tagFqnHash AND targetFQNHash LIKE :targetFQNHash") void deleteTagsByTagAndTargetEntity( - @BindFQN("tagFqnHash") String tagFqnHash, @BindFQN("targetFQNHash") String targetFQNHash); + @BindFQN("tagFqnHash") String tagFqnHash, + @BindConcat( + value = "targetFQNHash", + parts = {":targetFQNHashPrefix", "%"}, + hash = true) + String targetFQNHashPrefix); @SqlUpdate("DELETE FROM tag_usage where tagFQNHash = :tagFQNHash AND source = :source") void deleteTagLabels(@Bind("source") int source, @BindFQN("tagFQNHash") String tagFQNHash); @@ -3265,8 +3360,14 @@ void deleteTagsByTagAndTargetEntity( void deleteTagLabelsByFqn(@BindFQN("tagFQNHash") String tagFQNHash); @SqlUpdate( - "DELETE FROM tag_usage where targetFQNHash = :targetFQNHash OR targetFQNHash LIKE CONCAT(:targetFQNHash, '.%')") - void deleteTagLabelsByTargetPrefix(@BindFQN("targetFQNHash") String targetFQNHash); + "DELETE FROM tag_usage where targetFQNHash = :targetFQNHash OR targetFQNHash LIKE :concatTargetFQNHash") + void deleteTagLabelsByTargetPrefix( + @BindConcat( + value = "concatTargetFQNHash", + original = "targetFQNHash", + parts = {":targetFQNHashPrefix", ".%"}, + hash = true) + String targetFQNHashPrefix); @Deprecated(since = "Release 1.1") @ConnectionAwareSqlUpdate( @@ -3348,9 +3449,14 @@ void renameInternal( @RegisterRowMapper(TagLabelMapper.class) List getTargetFQNHashForTag(@BindFQN("tagFQNHash") String tagFQNHash); - @SqlQuery("select targetFQNHash FROM tag_usage where tagFQNHash LIKE CONCAT(:tagFQNHash, '.%')") + @SqlQuery("select targetFQNHash FROM tag_usage where tagFQNHash LIKE :tagFQNHash") @RegisterRowMapper(TagLabelMapper.class) - List getTargetFQNHashForTagPrefix(@BindFQN("tagFQNHash") String tagFQNHash); + List getTargetFQNHashForTagPrefix( + @BindConcat( + value = "tagFQNHash", + parts = {":tagFQNHashPrefix", ".%"}, + hash = true) + String tagFQNHashPrefix); class TagLabelMapper implements RowMapper { @Override @@ -5521,8 +5627,8 @@ default List listBefore( if (fqnPrefix != null) { String fqnPrefixHash = FullyQualifiedName.buildHash(fqnPrefix); filter.queryParams.put("fqnPrefixHash", fqnPrefixHash); - String fqnCond = - " AND (fqnHash LIKE CONCAT(:fqnPrefixHash, '.%') OR fqnHash=:fqnPrefixHash)"; + filter.queryParams.put("concatFqnPrefixHash", fqnPrefixHash + ".%"); + String fqnCond = " AND (fqnHash LIKE :concatFqnPrefixHash OR fqnHash=:fqnPrefixHash)"; mysqlCondition.append(fqnCond); psqlCondition.append(fqnCond); } @@ -5560,8 +5666,8 @@ default List listAfter(ListFilter filter, int limit, String afterName, S if (fqnPrefix != null) { String fqnPrefixHash = FullyQualifiedName.buildHash(fqnPrefix); filter.queryParams.put("fqnPrefixHash", fqnPrefixHash); - String fqnCond = - " AND (fqnHash LIKE CONCAT(:fqnPrefixHash, '.%') OR fqnHash=:fqnPrefixHash)"; + filter.queryParams.put("concatFqnPrefixHash", fqnPrefixHash + ".%"); + String fqnCond = " AND (fqnHash LIKE :concatFqnPrefixHash OR fqnHash=:fqnPrefixHash)"; mysqlCondition.append(fqnCond); psqlCondition.append(fqnCond); } @@ -5598,8 +5704,8 @@ default int listCount(ListFilter filter) { if (fqnPrefix != null) { String fqnPrefixHash = FullyQualifiedName.buildHash(fqnPrefix); filter.queryParams.put("fqnPrefixHash", fqnPrefixHash); - String fqnCond = - " AND (fqnHash LIKE CONCAT(:fqnPrefixHash, '.%') OR fqnHash=:fqnPrefixHash)"; + filter.queryParams.put("concatFqnPrefixHash", fqnPrefixHash + ".%"); + String fqnCond = " AND (fqnHash LIKE :concatFqnPrefixHash OR fqnHash=:fqnPrefixHash)"; mysqlCondition.append(fqnCond); psqlCondition.append(fqnCond); } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/util/jdbi/BindConcat.java b/openmetadata-service/src/main/java/org/openmetadata/service/util/jdbi/BindConcat.java new file mode 100644 index 000000000000..733aa0515975 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/util/jdbi/BindConcat.java @@ -0,0 +1,80 @@ +package org.openmetadata.service.util.jdbi; + +import java.lang.annotation.Annotation; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.lang.reflect.Type; +import org.jdbi.v3.sqlobject.customizer.SqlStatementCustomizerFactory; +import org.jdbi.v3.sqlobject.customizer.SqlStatementCustomizingAnnotation; +import org.jdbi.v3.sqlobject.customizer.SqlStatementParameterCustomizer; +import org.openmetadata.service.util.FullyQualifiedName; + +/** Concatenate parts of a string to bind as a parameter, and avoid usage of CONCAT() in where clause */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.PARAMETER}) +@SqlStatementCustomizingAnnotation(BindConcat.Factory.class) +public @interface BindConcat { + String value(); // Name of the concatenated parameter to bind + + String original() default + ""; // Optional: Use when both the original and concatenated values are needed + + String[] parts() default {}; // Parts to concatenate (placeholders or static values) + + boolean hash() default false; // Optional: Apply FullyQualifiedName.buildHash if true + + class Factory implements SqlStatementCustomizerFactory { + + @Override + public SqlStatementParameterCustomizer createForParameter( + Annotation annotation, + Class sqlObjectType, + Method method, + Parameter param, + int index, + Type type) { + BindConcat bind = (BindConcat) annotation; + + return (stmt, arg) -> { + String[] partValues = + (arg instanceof String[]) ? (String[]) arg : new String[] {String.valueOf(arg)}; + StringBuilder concatenatedResult = new StringBuilder(); + boolean containsNull = false; + + for (int i = 0; i < bind.parts().length; i++) { + String part = bind.parts()[i]; + if (part.startsWith(":")) { // Dynamic value in argument list to replace placeholder + if (i >= partValues.length) + throw new IllegalArgumentException( + "Not enough values for placeholders in @BindConcat. Expected at least " + + (i + 1) + + " but got " + + partValues.length); + String dynamicValue = partValues[i]; + if (dynamicValue == null) { + containsNull = true; + break; + } + concatenatedResult.append( + bind.hash() ? FullyQualifiedName.buildHash(dynamicValue) : dynamicValue); + } else { // Static part of the string, defined directly in the annotation + concatenatedResult.append(part); + } + } + + String finalValue = containsNull ? null : concatenatedResult.toString(); + stmt.bind(bind.value(), finalValue); + if (!bind.original().isEmpty() && partValues.length > 0) { + String originalValue = partValues[0]; + stmt.bind( + bind.original(), + bind.hash() ? FullyQualifiedName.buildHash(originalValue) : originalValue); + } + }; + } + } +} From ef4200860f861208397aca1e3d8822aa0e9013a2 Mon Sep 17 00:00:00 2001 From: harshsoni2024 <64592571+harshsoni2024@users.noreply.github.com> Date: Mon, 27 Jan 2025 12:33:44 +0530 Subject: [PATCH 167/294] issue-19508: PowerBI tables, datamodel metadata missing (#19509) (cherry picked from commit ce6b4ace33dfdad327c75e757c78c365a9511662) --- .../source/dashboard/powerbi/client.py | 37 +-- .../source/dashboard/powerbi/metadata.py | 231 ++++++++++++------ 2 files changed, 165 insertions(+), 103 deletions(-) diff --git a/ingestion/src/metadata/ingestion/source/dashboard/powerbi/client.py b/ingestion/src/metadata/ingestion/source/dashboard/powerbi/client.py index bd4d24d28b64..381094e18010 100644 --- a/ingestion/src/metadata/ingestion/source/dashboard/powerbi/client.py +++ b/ingestion/src/metadata/ingestion/source/dashboard/powerbi/client.py @@ -127,13 +127,7 @@ def fetch_all_org_dashboards( List[PowerBIDashboard] """ try: - admin = "admin/" if self.config.useAdminApis else "" - response_data = self.client.get( - f"/myorg/{admin}groups/{group_id}/dashboards" - ) - if not response_data: - logger.debug(f"No dashboards found for workspace_id: {group_id}") - return None + response_data = self.client.get(f"/myorg/groups/{group_id}/dashboards") response = DashboardsResponse(**response_data) return response.value except Exception as exc: # pylint: disable=broad-except @@ -148,11 +142,7 @@ def fetch_all_org_reports(self, group_id: str) -> Optional[List[PowerBIReport]]: List[PowerBIReport] """ try: - admin = "admin/" if self.config.useAdminApis else "" - response_data = self.client.get(f"/myorg/{admin}groups/{group_id}/reports") - if not response_data: - logger.debug(f"No reports found for workspace_id: {group_id}") - return None + response_data = self.client.get(f"/myorg/groups/{group_id}/reports") response = ReportsResponse(**response_data) return response.value except Exception as exc: # pylint: disable=broad-except @@ -167,11 +157,7 @@ def fetch_all_org_datasets(self, group_id: str) -> Optional[List[Dataset]]: List[Dataset] """ try: - admin = "admin/" if self.config.useAdminApis else "" - response_data = self.client.get(f"/myorg/{admin}groups/{group_id}/datasets") - if not response_data: - logger.debug(f"No datasets found for workspace_id: {group_id}") - return None + response_data = self.client.get(f"/myorg/groups/{group_id}/datasets") response = DatasetResponse(**response_data) return response.value except Exception as exc: # pylint: disable=broad-except @@ -188,13 +174,9 @@ def fetch_all_org_tiles( List[Tile] """ try: - admin = "admin/" if self.config.useAdminApis else "" response_data = self.client.get( - f"/myorg/{admin}dashboards/{dashboard_id}/tiles" + f"/myorg/groups/{group_id}/dashboards/{dashboard_id}/tiles" ) - if not response_data: - logger.debug(f"No dashboard tiles found for workspace_id: {group_id}") - return None response = TilesResponse(**response_data) return response.value except Exception as exc: # pylint: disable=broad-except @@ -234,9 +216,6 @@ def fetch_all_workspaces(self) -> Optional[List[Group]]: entities_per_page = self.config.pagination_entity_per_page params_data = {"$top": "1"} response_data = self.client.get(api_url, data=params_data) - if not response_data: - logger.debug("No groups/workspaces found") - return None response = GroupsResponse(**response_data) count = response.odata_count indexes = math.ceil(count / entities_per_page) @@ -249,7 +228,10 @@ def fetch_all_workspaces(self) -> Optional[List[Group]]: } response_data = self.client.get(api_url, data=params_data) if not response_data: - logger.debug("No more groups/workspaces found") + logger.error( + "Error fetching workspaces between results: " + f"{str(index * entities_per_page)} - {str(entities_per_page)}" + ) continue response = GroupsResponse(**response_data) workspaces.extend(response.value) @@ -286,7 +268,6 @@ def initiate_workspace_scan( def fetch_workspace_scan_status( self, scan_id: str ) -> Optional[WorkSpaceScanResponse]: - # deprecated in favour to avoide bulk data prepare """Get Workspace scan status by id method Args: scan_id: @@ -305,7 +286,6 @@ def fetch_workspace_scan_status( return None def fetch_workspace_scan_result(self, scan_id: str) -> Optional[Workspaces]: - # deprecated in favour to avoide bulk data prepare """Get Workspace scan result by id method Args: scan_id: @@ -327,7 +307,6 @@ def wait_for_scan_complete(self, scan_id, timeout=180) -> bool: """ Method to poll the scan status endpoint until the timeout """ - # deprecated in favour to avoide bulk data prepare min_sleep_time = 3 if min_sleep_time > timeout: logger.info(f"Timeout is set to minimum sleep time: {timeout}") diff --git a/ingestion/src/metadata/ingestion/source/dashboard/powerbi/metadata.py b/ingestion/src/metadata/ingestion/source/dashboard/powerbi/metadata.py index db8158da8240..a918b46fd8a1 100644 --- a/ingestion/src/metadata/ingestion/source/dashboard/powerbi/metadata.py +++ b/ingestion/src/metadata/ingestion/source/dashboard/powerbi/metadata.py @@ -92,6 +92,17 @@ def __init__( self.datamodel_file_mappings = [] def prepare(self): + """ + - Since we get all the required info i.e. reports, dashboards, charts, datasets + with workflow scan approach, we are populating bulk data for workspace. + - Some individual APIs are not able to yield data with details. + """ + if self.service_connection.useAdminApis: + groups = self.get_admin_workspace_data() + else: + groups = self.get_org_workspace_data() + if groups: + self.workspace_data = self.get_filtered_workspaces(groups) return super().prepare() def close(self): @@ -117,6 +128,108 @@ def get_filtered_workspaces(self, groups: List[Group]) -> List[Group]: filtered_groups.append(group) return filtered_groups + def get_org_workspace_data(self) -> Optional[List[Group]]: + """ + fetch all the group workspace ids + """ + groups = self.client.api_client.fetch_all_workspaces() + for group in groups: + # add the dashboards to the groups + group.dashboards.extend( + self.client.api_client.fetch_all_org_dashboards(group_id=group.id) or [] + ) + for dashboard in group.dashboards: + # add the tiles to the dashboards + dashboard.tiles.extend( + self.client.api_client.fetch_all_org_tiles( + group_id=group.id, dashboard_id=dashboard.id + ) + or [] + ) + + # add the reports to the groups + group.reports.extend( + self.client.api_client.fetch_all_org_reports(group_id=group.id) or [] + ) + + # add the datasets to the groups + group.datasets.extend( + self.client.api_client.fetch_all_org_datasets(group_id=group.id) or [] + ) + for dataset in group.datasets: + # add the tables to the datasets + dataset.tables.extend( + self.client.api_client.fetch_dataset_tables( + group_id=group.id, dataset_id=dataset.id + ) + or [] + ) + return groups + + def get_admin_workspace_data(self) -> Optional[List[Group]]: + """ + fetch all the workspace ids + """ + groups = [] + workspaces = self.client.api_client.fetch_all_workspaces() + if workspaces: + workspace_id_list = [workspace.id for workspace in workspaces] + + # Start the scan of the available workspaces for dashboard metadata + workspace_paginated_list = [ + workspace_id_list[i : i + self.pagination_entity_per_page] + for i in range( + 0, len(workspace_id_list), self.pagination_entity_per_page + ) + ] + count = 1 + for workspace_ids_chunk in workspace_paginated_list: + logger.info( + f"Scanning {count}/{len(workspace_paginated_list)} set of workspaces" + ) + workspace_scan = self.client.api_client.initiate_workspace_scan( + workspace_ids_chunk + ) + if not workspace_scan: + logger.error( + f"Error initiating workspace scan for ids:{str(workspace_ids_chunk)}\n moving to next set of workspaces" + ) + count += 1 + continue + + # Keep polling the scan status endpoint to check if scan is succeeded + workspace_scan_status = self.client.api_client.wait_for_scan_complete( + scan_id=workspace_scan.id + ) + if not workspace_scan_status: + logger.error( + f"Max poll hit to scan status for scan_id: {workspace_scan.id}, moving to next set of workspaces" + ) + count += 1 + continue + + # Get scan result for successfull scan + response = self.client.api_client.fetch_workspace_scan_result( + scan_id=workspace_scan.id + ) + if not response: + logger.error( + f"Error getting workspace scan result for scan_id: {workspace_scan.id}" + ) + count += 1 + continue + groups.extend( + [ + active_workspace + for active_workspace in response.workspaces + if active_workspace.state == "Active" + ] + ) + count += 1 + else: + logger.error("Unable to fetch any PowerBI workspaces") + return groups or None + @classmethod def create( cls, config_dict, metadata: OpenMetadata, pipeline_name: Optional[str] = None @@ -129,51 +242,12 @@ def create( ) return cls(config, metadata) - def prepare_workspace_data(self, workspace: Group): - """prepare one workspace data at a time""" - # add the dashboards to the groups - workspace.dashboards.extend( - self.client.api_client.fetch_all_org_dashboards(group_id=workspace.id) or [] - ) - for dashboard in workspace.dashboards: - # add the tiles to the dashboards - dashboard.tiles.extend( - self.client.api_client.fetch_all_org_tiles( - group_id=workspace.id, dashboard_id=dashboard.id - ) - or [] - ) - - # add the reports to the groups - workspace.reports.extend( - self.client.api_client.fetch_all_org_reports(group_id=workspace.id) or [] - ) - - # add the datasets to the groups - workspace.datasets.extend( - self.client.api_client.fetch_all_org_datasets(group_id=workspace.id) or [] - ) - for dataset in workspace.datasets: - # add the tables to the datasets - dataset.tables.extend( - self.client.api_client.fetch_dataset_tables( - group_id=workspace.id, dataset_id=dataset.id - ) - or [] - ) - def get_dashboard(self) -> Any: """ Method to iterate through dashboard lists filter dashboards & yield dashboard details """ - # fetch all workspaces/groups & apply filter pattern - all_workspaces = self.client.api_client.fetch_all_workspaces() or [] - all_workspaces = self.get_filtered_workspaces(all_workspaces) - for workspace in all_workspaces: - # prepare additional data for specific workspace (datasets, reports, dashboards) - self.prepare_workspace_data(workspace) + for workspace in self.workspace_data: self.context.get().workspace = workspace - self.workspace_data.append(workspace) for dashboard in self.get_dashboards_list(): try: dashboard_details = self.get_dashboard_details(dashboard) @@ -257,45 +331,54 @@ def _get_chart_url( f"{workspace_id}/{chart_url_postfix}" ) - def yield_datamodel( - self, dashboard_details: Union[PowerBIDashboard, PowerBIReport] - ) -> Iterable[Either[CreateDashboardRequest]]: + def list_datamodels(self) -> Iterable[Dataset]: """ - Method to yield datamodel for each workspace + Get All the Powerbi Datasets """ - workspace_datasets = self.context.get().workspace.datasets - for dataset in workspace_datasets: - if filter_by_datamodel( - self.source_config.dataModelFilterPattern, dataset.name - ): - self.status.filter(dataset.name, "Data model filtered out.") - continue + if self.source_config.includeDataModels: try: - data_model_request = CreateDashboardDataModelRequest( - name=EntityName(dataset.id), - displayName=dataset.name, - description=Markdown(dataset.description) - if dataset.description - else None, - service=FullyQualifiedEntityName( - self.context.get().dashboard_service - ), - dataModelType=DataModelType.PowerBIDataModel.value, - serviceType=DashboardServiceType.PowerBI.value, - columns=self._get_column_info(dataset), - project=self.get_project_name(dashboard_details), - ) - yield Either(right=data_model_request) - self.register_record_datamodel(datamodel_request=data_model_request) + for workspace in self.workspace_data: + for dataset in workspace.datasets or []: + if filter_by_datamodel( + self.source_config.dataModelFilterPattern, dataset.name + ): + self.status.filter(dataset.name, "Data model filtered out.") + continue + yield dataset + except Exception as err: + logger.debug(traceback.format_exc()) + logger.error(f"Unexpected error fetching PowerBI datasets - {err}") - except Exception as exc: - yield Either( - left=StackTraceError( - name=dataset.name, - error=f"Error yielding Data Model [{dataset.name}]: {exc}", - stackTrace=traceback.format_exc(), - ) + def yield_bulk_datamodel( + self, dataset: Dataset + ) -> Iterable[Either[CreateDashboardDataModelRequest]]: + """ + Method to fetch DataModels in bulk + """ + try: + data_model_request = CreateDashboardDataModelRequest( + name=EntityName(dataset.id), + displayName=dataset.name, + description=Markdown(dataset.description) + if dataset.description + else None, + service=FullyQualifiedEntityName(self.context.get().dashboard_service), + dataModelType=DataModelType.PowerBIDataModel.value, + serviceType=DashboardServiceType.PowerBI.value, + columns=self._get_column_info(dataset), + project=self._fetch_dataset_workspace(dataset_id=dataset.id), + ) + yield Either(right=data_model_request) + self.register_record_datamodel(datamodel_request=data_model_request) + + except Exception as exc: + yield Either( + left=StackTraceError( + name=dataset.name, + error=f"Error yielding Data Model [{dataset.name}]: {exc}", + stackTrace=traceback.format_exc(), ) + ) def _get_child_columns(self, table: PowerBiTable) -> List[Column]: """ From 2d69786db0dea89afdd6dca05d50164435603d7a Mon Sep 17 00:00:00 2001 From: Teddy Date: Mon, 27 Jan 2025 08:04:49 +0100 Subject: [PATCH 168/294] fix: wrong dataset and project id in filter (#19522) (cherry picked from commit 8233af52d8979369f3b6e4cf69d288f01bf2ca39) --- .../ingestion/source/database/bigquery/profiler/system.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ingestion/src/metadata/ingestion/source/database/bigquery/profiler/system.py b/ingestion/src/metadata/ingestion/source/database/bigquery/profiler/system.py index bd04c112a682..64b9cf9c45ba 100644 --- a/ingestion/src/metadata/ingestion/source/database/bigquery/profiler/system.py +++ b/ingestion/src/metadata/ingestion/source/database/bigquery/profiler/system.py @@ -29,8 +29,8 @@ def get_kwargs(self, **kwargs): runner: QueryRunner = kwargs.get("runner") return { "table": runner.table_name, - "database": runner.session.get_bind().url.database, - "schema": runner.schema_name, + "project_id": runner.session.get_bind().url.host, + "dataset_id": runner.schema_name, "usage_location": kwargs.get("usage_location"), } From 7457c93b96f232e8af0405f0dfdedb060c427c56 Mon Sep 17 00:00:00 2001 From: Mayur Singal <39544459+ulixius9@users.noreply.github.com> Date: Mon, 27 Jan 2025 15:54:41 +0530 Subject: [PATCH 169/294] Fix #19198: di fix custom property filter (#19531) (cherry picked from commit eea4b65b801e2c0c06e9b17e81e16a519953d9ab) --- .../processors/DataInsightsEntityEnricherProcessor.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/workflows/dataAssets/processors/DataInsightsEntityEnricherProcessor.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/workflows/dataAssets/processors/DataInsightsEntityEnricherProcessor.java index c54ada72491c..840b7694fc5f 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/workflows/dataAssets/processors/DataInsightsEntityEnricherProcessor.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/workflows/dataAssets/processors/DataInsightsEntityEnricherProcessor.java @@ -227,7 +227,7 @@ private Map enrichEntity( } // Modify Custom Property key - Optional oCustomProperties = Optional.ofNullable(entityMap.remove("extension")); + Optional oCustomProperties = Optional.ofNullable(entityMap.get("extension")); oCustomProperties.ifPresent( o -> entityMap.put(String.format("%sCustomProperty", entityType), o)); From 5337840aa6178a9790eb847783204ca1c5ed1e3a Mon Sep 17 00:00:00 2001 From: Mohit Yadav <105265192+mohityadav766@users.noreply.github.com> Date: Fri, 20 Dec 2024 20:13:04 +0530 Subject: [PATCH 170/294] Fix EntityHierarchy Schema (#19104) * Fix Entity Schema * fix additionalProperties in entityHierarchy json --------- Co-authored-by: sonikashah Co-authored-by: sonika-shah <58761340+sonika-shah@users.noreply.github.com> --- .../elasticsearch/ElasticSearchClient.java | 20 ++++----- .../search/opensearch/OpenSearchClient.java | 20 ++++----- .../glossary/GlossaryTermResourceTest.java | 4 +- .../json/schema/type/entityHierarchy.json | 44 +++---------------- .../ui/src/generated/type/entityHierarchy.ts | 10 +++-- 5 files changed, 36 insertions(+), 62 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java index 8364d017d589..3813221fe59d 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java @@ -148,7 +148,7 @@ import org.openmetadata.schema.dataInsight.custom.DataInsightCustomChart; import org.openmetadata.schema.dataInsight.custom.DataInsightCustomChartResultList; import org.openmetadata.schema.dataInsight.custom.FormulaHolder; -import org.openmetadata.schema.entity.data.EntityHierarchy__1; +import org.openmetadata.schema.entity.data.EntityHierarchy; import org.openmetadata.schema.entity.data.Table; import org.openmetadata.schema.service.configuration.elasticsearch.ElasticSearchConfiguration; import org.openmetadata.schema.tests.DataQualityReport; @@ -592,21 +592,21 @@ public List buildSearchHierarchy(SearchRequest request, SearchResponse search return response; } - public List buildGlossaryTermSearchHierarchy(SearchResponse searchResponse) { - Map termMap = + public List buildGlossaryTermSearchHierarchy(SearchResponse searchResponse) { + Map termMap = new LinkedHashMap<>(); // termMap represent glossary terms - Map rootTerms = + Map rootTerms = new LinkedHashMap<>(); // rootTerms represent glossaries for (var hit : searchResponse.getHits().getHits()) { String jsonSource = hit.getSourceAsString(); - EntityHierarchy__1 term = JsonUtils.readValue(jsonSource, EntityHierarchy__1.class); - EntityHierarchy__1 glossaryInfo = + EntityHierarchy term = JsonUtils.readValue(jsonSource, EntityHierarchy.class); + EntityHierarchy glossaryInfo = JsonUtils.readTree(jsonSource).path("glossary").isMissingNode() ? null : JsonUtils.convertValue( - JsonUtils.readTree(jsonSource).path("glossary"), EntityHierarchy__1.class); + JsonUtils.readTree(jsonSource).path("glossary"), EntityHierarchy.class); if (glossaryInfo != null) { rootTerms.putIfAbsent(glossaryInfo.getFullyQualifiedName(), glossaryInfo); @@ -626,15 +626,15 @@ public List buildGlossaryTermSearchHierarchy(SearchResponse String termFQN = term.getFullyQualifiedName(); if (parentFQN != null && termMap.containsKey(parentFQN)) { - EntityHierarchy__1 parentTerm = termMap.get(parentFQN); - List children = parentTerm.getChildren(); + EntityHierarchy parentTerm = termMap.get(parentFQN); + List children = parentTerm.getChildren(); children.removeIf( child -> child.getFullyQualifiedName().equals(term.getFullyQualifiedName())); children.add(term); parentTerm.setChildren(children); } else { if (rootTerms.containsKey(termFQN)) { - EntityHierarchy__1 rootTerm = rootTerms.get(termFQN); + EntityHierarchy rootTerm = rootTerms.get(termFQN); rootTerm.setChildren(term.getChildren()); } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java index b1f76a2c1a95..a6baeed0facf 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java @@ -68,7 +68,7 @@ import org.openmetadata.schema.dataInsight.custom.DataInsightCustomChart; import org.openmetadata.schema.dataInsight.custom.DataInsightCustomChartResultList; import org.openmetadata.schema.dataInsight.custom.FormulaHolder; -import org.openmetadata.schema.entity.data.EntityHierarchy__1; +import org.openmetadata.schema.entity.data.EntityHierarchy; import org.openmetadata.schema.entity.data.Table; import org.openmetadata.schema.service.configuration.elasticsearch.ElasticSearchConfiguration; import org.openmetadata.schema.tests.DataQualityReport; @@ -584,21 +584,21 @@ public List buildSearchHierarchy(SearchRequest request, SearchResponse search return response; } - public List buildGlossaryTermSearchHierarchy(SearchResponse searchResponse) { - Map termMap = + public List buildGlossaryTermSearchHierarchy(SearchResponse searchResponse) { + Map termMap = new LinkedHashMap<>(); // termMap represent glossary terms - Map rootTerms = + Map rootTerms = new LinkedHashMap<>(); // rootTerms represent glossaries for (var hit : searchResponse.getHits().getHits()) { String jsonSource = hit.getSourceAsString(); - EntityHierarchy__1 term = JsonUtils.readValue(jsonSource, EntityHierarchy__1.class); - EntityHierarchy__1 glossaryInfo = + EntityHierarchy term = JsonUtils.readValue(jsonSource, EntityHierarchy.class); + EntityHierarchy glossaryInfo = JsonUtils.readTree(jsonSource).path("glossary").isMissingNode() ? null : JsonUtils.convertValue( - JsonUtils.readTree(jsonSource).path("glossary"), EntityHierarchy__1.class); + JsonUtils.readTree(jsonSource).path("glossary"), EntityHierarchy.class); if (glossaryInfo != null) { rootTerms.putIfAbsent(glossaryInfo.getFullyQualifiedName(), glossaryInfo); @@ -618,15 +618,15 @@ public List buildGlossaryTermSearchHierarchy(SearchResponse String termFQN = term.getFullyQualifiedName(); if (parentFQN != null && termMap.containsKey(parentFQN)) { - EntityHierarchy__1 parentTerm = termMap.get(parentFQN); - List children = parentTerm.getChildren(); + EntityHierarchy parentTerm = termMap.get(parentFQN); + List children = parentTerm.getChildren(); children.removeIf( child -> child.getFullyQualifiedName().equals(term.getFullyQualifiedName())); children.add(term); parentTerm.setChildren(children); } else { if (rootTerms.containsKey(termFQN)) { - EntityHierarchy__1 rootTerm = rootTerms.get(termFQN); + EntityHierarchy rootTerm = rootTerms.get(termFQN); rootTerm.setChildren(term.getChildren()); } } diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/glossary/GlossaryTermResourceTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/glossary/GlossaryTermResourceTest.java index 7b0e5f801514..6acb2757e488 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/resources/glossary/GlossaryTermResourceTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/glossary/GlossaryTermResourceTest.java @@ -75,7 +75,7 @@ import org.openmetadata.schema.api.data.CreateTable; import org.openmetadata.schema.api.data.TermReference; import org.openmetadata.schema.api.feed.ResolveTask; -import org.openmetadata.schema.entity.data.EntityHierarchy__1; +import org.openmetadata.schema.entity.data.EntityHierarchy; import org.openmetadata.schema.entity.data.Glossary; import org.openmetadata.schema.entity.data.GlossaryTerm; import org.openmetadata.schema.entity.data.GlossaryTerm.Status; @@ -874,7 +874,7 @@ public void test_buildGlossaryTermNestedHierarchy(TestInfo test) throws HttpResp GlossaryTerm childGlossaryTerm = createEntity(create, ADMIN_AUTH_HEADERS); String response = getResponseFormSearchWithHierarchy("glossary_term_search_index", "*childGlossaryTerm*"); - List glossaries = JsonUtils.readObjects(response, EntityHierarchy__1.class); + List glossaries = JsonUtils.readObjects(response, EntityHierarchy.class); boolean isChild = glossaries.stream() .filter(glossary -> "g1".equals(glossary.getName())) // Find glossary with name "g1" diff --git a/openmetadata-spec/src/main/resources/json/schema/type/entityHierarchy.json b/openmetadata-spec/src/main/resources/json/schema/type/entityHierarchy.json index 73fbe9afce77..859b97024b95 100644 --- a/openmetadata-spec/src/main/resources/json/schema/type/entityHierarchy.json +++ b/openmetadata-spec/src/main/resources/json/schema/type/entityHierarchy.json @@ -7,38 +7,12 @@ "type": "object", "javaType": "org.openmetadata.schema.entity.data.EntityHierarchy", "definitions": { - "EntityHierarchy": { - "type": "object", - "properties": { - "id": { - "description": "Unique identifier of an entity hierarchy instance.", - "$ref": "../type/basic.json#/definitions/uuid" - }, - "name": { - "description": "Preferred name for the entity hierarchy.", - "$ref": "../type/basic.json#/definitions/entityName" - }, - "displayName": { - "description": "Display name that identifies this hierarchy.", - "type": "string" - }, - "description": { - "description": "Description of the entity hierarchy.", - "$ref": "../type/basic.json#/definitions/markdown" - }, - "fullyQualifiedName": { - "description": "A unique name that identifies an entity within the hierarchy. It captures name hierarchy in the form of `rootEntity.childEntity`.", - "$ref": "../type/basic.json#/definitions/fullyQualifiedEntityName" - }, - "children": { - "description": "Other entities that are children of this entity.", - "type": "array", - "items": { - "$ref": "#/definitions/EntityHierarchy" - } - } + "entityHierarchyList": { + "type": "array", + "items": { + "$ref": "entityHierarchy.json" }, - "required": ["id", "name", "description"] + "default": [] } }, "properties": { @@ -64,12 +38,8 @@ }, "children": { "description": "Other entities that are children of this entity.", - "type": "array", - "items": { - "$ref": "#/definitions/EntityHierarchy" - } + "$ref" : "#/definitions/entityHierarchyList" } }, - "required": ["id", "name", "description"], - "additionalProperties": false + "required": ["id", "name", "description"] } diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/type/entityHierarchy.ts b/openmetadata-ui/src/main/resources/ui/src/generated/type/entityHierarchy.ts index 14ec82f949ff..50e646899932 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/type/entityHierarchy.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/type/entityHierarchy.ts @@ -10,9 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - - - /** +/** * This schema defines the entity hierarchy structure. */ export interface EntityHierarchy { @@ -41,8 +39,14 @@ export interface EntityHierarchy { * Preferred name for the entity hierarchy. */ name: string; + [property: string]: any; } +/** + * Other entities that are children of this entity. + * + * This schema defines the entity hierarchy structure. + */ export interface ChildElement { /** * Other entities that are children of this entity. From b813294bf95ad4599254ac1c96875e907d895fcc Mon Sep 17 00:00:00 2001 From: harshsoni2024 <64592571+harshsoni2024@users.noreply.github.com> Date: Mon, 27 Jan 2025 16:54:35 +0530 Subject: [PATCH 171/294] issue-16744: salesforce column description with toggle api (#19527) (cherry picked from commit b1d481f2f1461cef05998ead1084a59f0029199b) --- .../source/database/salesforce/metadata.py | 49 +++++++++++++++++-- .../unit/topology/database/test_salesforce.py | 16 +++--- 2 files changed, 55 insertions(+), 10 deletions(-) diff --git a/ingestion/src/metadata/ingestion/source/database/salesforce/metadata.py b/ingestion/src/metadata/ingestion/source/database/salesforce/metadata.py index 0e68053ab90c..e4c46c7532a5 100644 --- a/ingestion/src/metadata/ingestion/source/database/salesforce/metadata.py +++ b/ingestion/src/metadata/ingestion/source/database/salesforce/metadata.py @@ -12,7 +12,7 @@ Salesforce source ingestion """ import traceback -from typing import Any, Iterable, Optional, Tuple +from typing import Any, Iterable, List, Optional, Tuple from metadata.generated.schema.api.data.createDatabase import CreateDatabaseRequest from metadata.generated.schema.api.data.createDatabaseSchema import ( @@ -231,6 +231,29 @@ def get_table_description( ) return table_description if table_description else object_label + def get_table_column_description(self, table_name: str) -> Optional[List]: + """ + Method to get the all columns' (field) description for Salesforce with the Tooling API. + """ + all_column_description = None + try: + result = self.client.toolingexecute( + f"query/?q=SELECT+Description+FROM+FieldDefinition+WHERE+" + f"EntityDefinition.QualifiedApiName='{table_name}'" + ) + all_column_description = result["records"] + except KeyError as err: + logger.warning( + "Unable to get required key from Tooling API response for " + f"table [{table_name}]: {err}" + ) + except Exception as exc: + logger.debug(traceback.format_exc()) + logger.warning( + f"Unable to get column description with Tooling API for table [{table_name}]: {exc}" + ) + return all_column_description + def yield_table( self, table_name_and_type: Tuple[str, TableType] ) -> Iterable[Either[CreateTableRequest]]: @@ -245,7 +268,7 @@ def yield_table( f"sobjects/{table_name}/describe/", params=None, ) - columns = self.get_columns(salesforce_objects.get("fields", [])) + columns = self.get_columns(table_name, salesforce_objects.get("fields", [])) table_request = CreateTableRequest( name=EntityName(table_name), tableType=table_type, @@ -278,12 +301,26 @@ def yield_table( ) ) - def get_columns(self, salesforce_fields): + def get_columns(self, table_name: str, salesforce_fields: List): """ Method to handle column details """ row_order = 1 columns = [] + column_description_mapping = {} + all_column_description = self.get_table_column_description(table_name) + if all_column_description: + for item in all_column_description: + try: + if item.get("Description") is not None: + column_name = item["attributes"]["url"].split(".")[-1] + column_description_mapping.update( + {column_name: item["Description"]} + ) + except Exception as ex: + logger.debug( + f"Error creating column description mapping: {str(ex)}" + ) for column in salesforce_fields: col_constraint = None if column["nillable"]: @@ -292,11 +329,15 @@ def get_columns(self, salesforce_fields): col_constraint = Constraint.NOT_NULL if column["unique"]: col_constraint = Constraint.UNIQUE + if column_description_mapping.get(column["name"]): + column_description = column_description_mapping[column["name"]] + else: + column_description = column["label"] columns.append( Column( name=column["name"], - description=column["label"], + description=column_description, dataType=self.column_type(column["type"].upper()), dataTypeDisplay=column["type"], constraint=col_constraint, diff --git a/ingestion/tests/unit/topology/database/test_salesforce.py b/ingestion/tests/unit/topology/database/test_salesforce.py index 8c2ceb780f39..34dc639a7618 100644 --- a/ingestion/tests/unit/topology/database/test_salesforce.py +++ b/ingestion/tests/unit/topology/database/test_salesforce.py @@ -117,7 +117,7 @@ dataTypeDisplay="textarea", description="Contact Description", fullyQualifiedName=None, - tags=None, + tags=[], constraint=Constraint.NULL, ordinalPosition=1, jsonSchema=None, @@ -136,7 +136,7 @@ dataTypeDisplay="reference", description="Owner ID", fullyQualifiedName=None, - tags=None, + tags=[], constraint=Constraint.NOT_NULL, ordinalPosition=2, jsonSchema=None, @@ -155,7 +155,7 @@ dataTypeDisplay="phone", description="Phone", fullyQualifiedName=None, - tags=None, + tags=[], constraint=Constraint.NOT_NULL, ordinalPosition=3, jsonSchema=None, @@ -174,7 +174,7 @@ dataTypeDisplay="anytype", description="Created By ID", fullyQualifiedName=None, - tags=None, + tags=[], constraint=Constraint.NOT_NULL, ordinalPosition=4, jsonSchema=None, @@ -451,8 +451,12 @@ def __init__(self, methodName, salesforce, test_connection) -> None: "database_schema" ] = MOCK_DATABASE_SCHEMA - def test_table_column(self): - result = self.salesforce_source.get_columns(SALESFORCE_FIELDS) + @patch( + "metadata.ingestion.source.database.salesforce.metadata.SalesforceSource.get_table_column_description" + ) + def test_table_column(self, get_table_column_description): + get_table_column_description.return_value = None + result = self.salesforce_source.get_columns("TEST_TABLE", SALESFORCE_FIELDS) assert EXPECTED_COLUMN_VALUE == result def test_column_type(self): From c75d1a52145aef3f6c2faf5eef96ba189d32d9e7 Mon Sep 17 00:00:00 2001 From: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> Date: Fri, 10 Jan 2025 10:33:55 +0530 Subject: [PATCH 172/294] fix(ui): update glossary term table upon new term added (#19296) * fix(ui): update glossary term table upon new term added * fix the active tab getting change and added the playwright test --------- Co-authored-by: Ashish Gupta Co-authored-by: Aniket Katkar --- .../ui/playwright/e2e/Pages/Glossary.spec.ts | 86 +++++++++++++++++++ .../resources/ui/playwright/utils/glossary.ts | 33 +++++-- .../components/Glossary/useGlossary.store.ts | 26 ++++-- 3 files changed, 132 insertions(+), 13 deletions(-) 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 07871962aa48..b8e4f98eb7d8 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 @@ -48,6 +48,7 @@ import { confirmationDragAndDropGlossary, createDescriptionTaskForGlossary, createGlossary, + createGlossaryTerm, createGlossaryTerms, createTagTaskForGlossary, deleteGlossaryOrGlossaryTerm, @@ -1197,4 +1198,89 @@ test.describe('Glossary tests', () => { await afterAction(); } }); + + test('should check for glossary term pagination', async ({ browser }) => { + test.slow(true); + + const { page, afterAction, apiContext } = await performAdminLogin(browser); + const glossaries = []; + for (let i = 0; i < 60; i++) { + const glossary = new Glossary(`PW_GLOSSARY_TEST_${i + 1}`); + await glossary.create(apiContext); + glossaries.push(glossary); + } + + try { + await redirectToHomePage(page); + const glossaryRes = page.waitForResponse('/api/v1/glossaries?*'); + await sidebarClick(page, SidebarItem.GLOSSARY); + await glossaryRes; + + const glossaryAfterRes = page.waitForResponse( + '/api/v1/glossaries?*after=*' + ); + await page + .getByTestId('glossary-left-panel-scroller') + .scrollIntoViewIfNeeded(); + + const res = await glossaryAfterRes; + const json = await res.json(); + + const firstGlossaryName = json.data[0].displayName; + + await expect( + page.getByRole('menuitem', { name: firstGlossaryName }) + ).toBeVisible(); + + const lastGlossaryName = json.data[json.data.length - 1].displayName; + + await expect( + page.getByRole('menuitem', { name: lastGlossaryName }) + ).toBeVisible(); + } finally { + for (const glossary of glossaries) { + await glossary.delete(apiContext); + } + await afterAction(); + } + }); + + test('Add Glossary Term inside another Term', async ({ browser }) => { + const { page, afterAction, apiContext } = await performAdminLogin(browser); + const glossary1 = new Glossary(); + const glossaryTerm1 = new GlossaryTerm(glossary1); + const glossary2 = new Glossary(); + glossary2.data.terms = [new GlossaryTerm(glossary2)]; + + try { + await glossary1.create(apiContext); + await glossaryTerm1.create(apiContext); + await redirectToHomePage(page); + await sidebarClick(page, SidebarItem.GLOSSARY); + await selectActiveGlossary(page, glossary1.data.displayName); + await selectActiveGlossaryTerm(page, glossaryTerm1.data.displayName); + await page.getByTestId('terms').click(); + + await createGlossaryTerm( + page, + glossary2.data.terms[0].data, + 'Approved', + false, + true + ); + } finally { + await glossaryTerm1.delete(apiContext); + await glossary1.delete(apiContext); + await afterAction(); + } + }); + + test.afterAll(async ({ browser }) => { + const { afterAction, apiContext } = await performAdminLogin(browser); + await user1.delete(apiContext); + await user2.delete(apiContext); + await user3.create(apiContext); + await team.delete(apiContext); + await afterAction(); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts index c06c7b2eddf2..fe36cca5a152 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts @@ -392,12 +392,18 @@ export const deleteGlossary = async (page: Page, glossary: GlossaryData) => { export const fillGlossaryTermDetails = async ( page: Page, term: GlossaryTermData, - validateCreateForm = true + validateCreateForm = true, + isGlossaryTerm = false ) => { // Safety check to close potential glossary not found alert // Arrived due to parallel testing await closeFirstPopupAlert(page); - await page.click('[data-testid="add-new-tag-button-header"]'); + + if (isGlossaryTerm) { + await page.click('[data-testid="add-placeholder-button"]'); + } else { + await page.click('[data-testid="add-new-tag-button-header"]'); + } await page.waitForSelector('[role="dialog"].edit-glossary-modal'); @@ -590,28 +596,39 @@ export const updateGlossaryTermDataFromTree = async ( export const validateGlossaryTerm = async ( page: Page, term: GlossaryTermData, - status: 'Draft' | 'Approved' + status: 'Draft' | 'Approved', + isGlossaryTermPage = false ) => { // eslint-disable-next-line no-useless-escape const escapedFqn = term.fullyQualifiedName.replace(/\"/g, '\\"'); const termSelector = `[data-row-key="${escapedFqn}"]`; const statusSelector = `[data-testid="${escapedFqn}-status"]`; - await expect(page.locator(termSelector)).toContainText(term.name); - await expect(page.locator(statusSelector)).toContainText(status); + if (isGlossaryTermPage) { + await expect(page.getByTestId(term.name)).toBeVisible(); + } else { + await expect(page.locator(termSelector)).toContainText(term.name); + await expect(page.locator(statusSelector)).toContainText(status); + } }; export const createGlossaryTerm = async ( page: Page, term: GlossaryTermData, status: 'Draft' | 'Approved', - validateCreateForm = true + validateCreateForm = true, + isGlossaryTermPage = false ) => { - await fillGlossaryTermDetails(page, term, validateCreateForm); + await fillGlossaryTermDetails( + page, + term, + validateCreateForm, + isGlossaryTermPage + ); const glossaryTermResponse = page.waitForResponse('/api/v1/glossaryTerms'); await page.click('[data-testid="save-glossary-term"]'); await glossaryTermResponse; - await validateGlossaryTerm(page, term, status); + await validateGlossaryTerm(page, term, status, isGlossaryTermPage); }; export const createGlossaryTerms = async ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/useGlossary.store.ts b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/useGlossary.store.ts index 9b5c6f2afe46..90bcd4dc45c0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/useGlossary.store.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/useGlossary.store.ts @@ -72,12 +72,28 @@ export const useGlossaryStore = create<{ } }, insertNewGlossaryTermToChildTerms: (glossary: GlossaryTerm) => { - const { glossaryChildTerms } = get(); + const { glossaryChildTerms, activeGlossary } = get(); - // Typically used to updated the glossary term list in the glossary page - set({ - glossaryChildTerms: findAndUpdateNested(glossaryChildTerms, glossary), - }); + const glossaryTerm = 'glossary' in activeGlossary; + + // If activeGlossary is Glossary term & User is adding term to the activeGlossary term + // we don't need to find in hierarchy + if ( + glossaryTerm && + activeGlossary.fullyQualifiedName === glossary.parent?.fullyQualifiedName + ) { + set({ + glossaryChildTerms: [ + ...glossaryChildTerms, + glossary, + ] as ModifiedGlossary[], + }); + } else { + // Typically used to updated the glossary term list in the glossary page + set({ + glossaryChildTerms: findAndUpdateNested(glossaryChildTerms, glossary), + }); + } }, setGlossaryChildTerms: (glossaryChildTerms: ModifiedGlossary[]) => { set({ glossaryChildTerms }); From a0b3d434ba1e8104467490e6f67b91cab3610de3 Mon Sep 17 00:00:00 2001 From: Ashish Gupta Date: Fri, 24 Jan 2025 16:54:50 +0530 Subject: [PATCH 173/294] fix the glossary column width sizes for the resizable columns (#19497) * fix the glossary column width sizes to the previous one * move the colum logic to utils file and added test for it (cherry picked from commit 182b8ab864fc44cf3c2fbf2a4ae739f97beff32d) From efb519ec73cebd895c9445b626dab12ccf9b2cba Mon Sep 17 00:00:00 2001 From: sonika-shah <58761340+sonika-shah@users.noreply.github.com> Date: Mon, 27 Jan 2025 15:43:42 +0530 Subject: [PATCH 174/294] support domain-hierarchy --- .../service/jdbi3/DomainRepository.java | 49 ++++ .../resources/domains/DomainResource.java | 30 +++ .../elasticsearch/ElasticSearchClient.java | 168 +++++++++---- .../search/opensearch/OpenSearchClient.java | 165 +++++++++---- .../service/util/EntityHierarchyList.java | 13 + .../resources/domains/DomainResourceTest.java | 215 +++++++++++++++++ .../ui/playwright/e2e/Pages/Domains.spec.ts | 10 +- .../ui/playwright/e2e/Pages/Entity.spec.ts | 2 +- .../support/entity/DatabaseClass.ts | 2 +- .../playwright/support/entity/EntityClass.ts | 2 +- .../resources/ui/playwright/utils/common.ts | 33 ++- .../ui/src/assets/svg/ic-subdomain.svg | 13 + .../DomainDetailsPage.component.tsx | 57 +++-- .../GlossaryTermTab.component.tsx | 7 +- .../AsyncSelectList/TreeAsyncSelectList.tsx | 4 +- .../DomainSelectableList.component.tsx | 101 +------- .../domain-select-dropdown.less | 6 +- .../DomainSelectableTree.interface.ts | 24 ++ .../DomainSelectableTree.tsx | 224 ++++++++++++++++++ .../domain-selectable.less | 26 ++ .../resources/ui/src/hooks/useDomainStore.ts | 14 +- .../main/resources/ui/src/rest/domainAPI.ts | 30 +++ .../resources/ui/src/utils/DomainUtils.tsx | 93 ++++++-- .../resources/ui/src/utils/GlossaryUtils.tsx | 18 +- 24 files changed, 1041 insertions(+), 265 deletions(-) create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/util/EntityHierarchyList.java create mode 100644 openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-subdomain.svg create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableTree/DomainSelectableTree.interface.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableTree/DomainSelectableTree.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableTree/domain-selectable.less diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DomainRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DomainRepository.java index 40943f8bc185..5a6f6dcca48e 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DomainRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DomainRepository.java @@ -18,10 +18,15 @@ import static org.openmetadata.service.Entity.DOMAIN; import static org.openmetadata.service.Entity.FIELD_ASSETS; +import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.jdbi.v3.sqlobject.transaction.Transaction; import org.openmetadata.schema.EntityInterface; +import org.openmetadata.schema.entity.data.EntityHierarchy; import org.openmetadata.schema.entity.domains.Domain; import org.openmetadata.schema.type.EntityReference; import org.openmetadata.schema.type.Include; @@ -30,8 +35,11 @@ import org.openmetadata.schema.type.api.BulkOperationResult; import org.openmetadata.service.Entity; import org.openmetadata.service.resources.domains.DomainResource; +import org.openmetadata.service.util.EntityUtil; import org.openmetadata.service.util.EntityUtil.Fields; import org.openmetadata.service.util.FullyQualifiedName; +import org.openmetadata.service.util.JsonUtils; +import org.openmetadata.service.util.ResultList; @Slf4j public class DomainRepository extends EntityRepository { @@ -140,6 +148,47 @@ public EntityInterface getParentEntity(Domain entity, String fields) { : null; } + public List buildHierarchy(String fieldsParam, int limit) { + fieldsParam = EntityUtil.addField(fieldsParam, Entity.FIELD_PARENT); + Fields fields = getFields(fieldsParam); + ResultList resultList = listAfter(null, fields, new ListFilter(null), limit, null); + List domains = resultList.getData(); + + /* + Maintaining hierarchy in terms of EntityHierarchy to get all other fields of Domain like style, + which would have been restricted if built using hierarchy of Domain, as Domain.getChildren() returns List + and EntityReference does not support additional properties + */ + List rootDomains = new ArrayList<>(); + + Map entityHierarchyMap = + domains.stream() + .collect( + Collectors.toMap( + Domain::getId, + domain -> { + EntityHierarchy entityHierarchy = + JsonUtils.readValue(JsonUtils.pojoToJson(domain), EntityHierarchy.class); + entityHierarchy.setChildren(new ArrayList<>()); + return entityHierarchy; + })); + + for (Domain domain : domains) { + EntityHierarchy entityHierarchy = entityHierarchyMap.get(domain.getId()); + + if (domain.getParent() != null) { + EntityHierarchy parentHierarchy = entityHierarchyMap.get(domain.getParent().getId()); + if (parentHierarchy != null) { + parentHierarchy.getChildren().add(entityHierarchy); + } + } else { + rootDomains.add(entityHierarchy); + } + } + + return rootDomains; + } + public class DomainUpdater extends EntityUpdater { public DomainUpdater(Domain original, Domain updated, Operation operation) { super(original, updated, operation); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/domains/DomainResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/domains/DomainResource.java index f3b446ce4c98..c335f6833de6 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/domains/DomainResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/domains/DomainResource.java @@ -45,6 +45,7 @@ import javax.ws.rs.core.UriInfo; import lombok.extern.slf4j.Slf4j; import org.openmetadata.schema.api.domains.CreateDomain; +import org.openmetadata.schema.entity.data.EntityHierarchy; import org.openmetadata.schema.entity.domains.Domain; import org.openmetadata.schema.type.ChangeEvent; import org.openmetadata.schema.type.EntityHistory; @@ -57,6 +58,7 @@ import org.openmetadata.service.resources.Collection; import org.openmetadata.service.resources.EntityResource; import org.openmetadata.service.security.Authorizer; +import org.openmetadata.service.util.EntityHierarchyList; import org.openmetadata.service.util.ResultList; @Slf4j @@ -433,4 +435,32 @@ public Response delete( String name) { return deleteByName(uriInfo, securityContext, name, true, true); } + + @GET + @Path("/hierarchy") + @Operation( + operationId = "listDomainsHierarchy", + summary = "List domains in hierarchical order", + description = "Get a list of Domains in hierarchical order.", + responses = { + @ApiResponse( + responseCode = "200", + description = "List of Domains in hierarchical order", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = EntityHierarchyList.class))) + }) + public ResultList listHierarchy( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter( + description = "Fields requested in the returned resource", + schema = @Schema(type = "string", example = FIELDS)) + @QueryParam("fields") + String fieldsParam, + @DefaultValue("10") @Min(0) @Max(1000000) @QueryParam("limit") int limitParam) { + + return new EntityHierarchyList(repository.buildHierarchy(fieldsParam, limitParam)); + } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java index 3813221fe59d..4422ecb27271 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java @@ -127,6 +127,7 @@ import java.util.Optional; import java.util.Set; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; import java.util.stream.Stream; import javax.json.JsonObject; import javax.net.ssl.SSLContext; @@ -468,50 +469,10 @@ public Response search(SearchRequest request, SubjectContext subjectContext) thr .getIndexMapping(GLOSSARY_TERM) .getIndexName(clusterAlias))) { searchSourceBuilder.query(QueryBuilders.boolQuery().must(searchSourceBuilder.query())); - - if (request.isGetHierarchy()) { - QueryBuilder baseQuery = - QueryBuilders.boolQuery() - .should(searchSourceBuilder.query()) - .should(QueryBuilders.matchPhraseQuery("fullyQualifiedName", request.getQuery())) - .should(QueryBuilders.matchPhraseQuery("name", request.getQuery())) - .should(QueryBuilders.matchPhraseQuery("displayName", request.getQuery())) - .should( - QueryBuilders.matchPhraseQuery( - "glossary.fullyQualifiedName", request.getQuery())) - .should(QueryBuilders.matchPhraseQuery("glossary.displayName", request.getQuery())) - .must(QueryBuilders.matchQuery("status", "Approved")) - .minimumShouldMatch(1); - searchSourceBuilder.query(baseQuery); - - SearchResponse searchResponse = - client.search( - new es.org.elasticsearch.action.search.SearchRequest(request.getIndex()) - .source(searchSourceBuilder), - RequestOptions.DEFAULT); - - // Extract parent terms from aggregation - BoolQueryBuilder parentTermQueryBuilder = QueryBuilders.boolQuery(); - Terms parentTerms = searchResponse.getAggregations().get("fqnParts_agg"); - - // Build es query to get parent terms for the user input query , to build correct hierarchy - if (!parentTerms.getBuckets().isEmpty() && !request.getQuery().equals("*")) { - parentTerms.getBuckets().stream() - .map(Terms.Bucket::getKeyAsString) - .forEach( - parentTerm -> - parentTermQueryBuilder.should( - QueryBuilders.matchQuery("fullyQualifiedName", parentTerm))); - - searchSourceBuilder.query( - parentTermQueryBuilder - .minimumShouldMatch(1) - .must(QueryBuilders.matchQuery("status", "Approved"))); - } - searchSourceBuilder.sort(SortBuilders.fieldSort("fullyQualifiedName").order(SortOrder.ASC)); - } } + buildHierarchyQuery(request, searchSourceBuilder, client); + /* for performance reasons ElasticSearch doesn't provide accurate hits if we enable trackTotalHits parameter it will try to match every result, count and return hits however in most cases for search results an approximate value is good enough. @@ -579,15 +540,89 @@ public Response getDocByID(String indexName, String entityId) throws IOException return getResponse(NOT_FOUND, "Document not found."); } + private void buildHierarchyQuery( + SearchRequest request, SearchSourceBuilder searchSourceBuilder, RestHighLevelClient client) + throws IOException { + + if (!request.isGetHierarchy()) { + return; + } + + String indexName = request.getIndex(); + String glossaryTermIndex = + Entity.getSearchRepository().getIndexMapping(GLOSSARY_TERM).getIndexName(clusterAlias); + String domainIndex = + Entity.getSearchRepository().getIndexMapping(DOMAIN).getIndexName(clusterAlias); + + BoolQueryBuilder baseQuery = + QueryBuilders.boolQuery() + .should(searchSourceBuilder.query()) + .should(QueryBuilders.matchPhraseQuery("fullyQualifiedName", request.getQuery())) + .should(QueryBuilders.matchPhraseQuery("name", request.getQuery())) + .should(QueryBuilders.matchPhraseQuery("displayName", request.getQuery())); + + if (indexName.equalsIgnoreCase(glossaryTermIndex)) { + baseQuery + .should(QueryBuilders.matchPhraseQuery("glossary.fullyQualifiedName", request.getQuery())) + .should(QueryBuilders.matchPhraseQuery("glossary.displayName", request.getQuery())) + .must(QueryBuilders.matchQuery("status", "Approved")); + } else if (indexName.equalsIgnoreCase(domainIndex)) { + baseQuery + .should(QueryBuilders.matchPhraseQuery("parent.fullyQualifiedName", request.getQuery())) + .should(QueryBuilders.matchPhraseQuery("parent.displayName", request.getQuery())); + } + + baseQuery.minimumShouldMatch(1); + searchSourceBuilder.query(baseQuery); + + SearchResponse searchResponse = + client.search( + new es.org.elasticsearch.action.search.SearchRequest(request.getIndex()) + .source(searchSourceBuilder), + RequestOptions.DEFAULT); + + Terms parentTerms = searchResponse.getAggregations().get("fqnParts_agg"); + + // Build es query to get parent terms for the user input query , to build correct hierarchy + // In case of default search , no need to get parent terms they are already present in the + // response + if (parentTerms != null + && !parentTerms.getBuckets().isEmpty() + && !request.getQuery().equals("*")) { + BoolQueryBuilder parentTermQueryBuilder = QueryBuilders.boolQuery(); + + parentTerms.getBuckets().stream() + .map(Terms.Bucket::getKeyAsString) + .forEach( + parentTerm -> + parentTermQueryBuilder.should( + QueryBuilders.matchQuery("fullyQualifiedName", parentTerm))); + if (indexName.equalsIgnoreCase(glossaryTermIndex)) { + parentTermQueryBuilder + .minimumShouldMatch(1) + .must(QueryBuilders.matchQuery("status", "Approved")); + } else { + parentTermQueryBuilder.minimumShouldMatch(1); + } + searchSourceBuilder.query(parentTermQueryBuilder); + } + + searchSourceBuilder.sort(SortBuilders.fieldSort("fullyQualifiedName").order(SortOrder.ASC)); + } + public List buildSearchHierarchy(SearchRequest request, SearchResponse searchResponse) { List response = new ArrayList<>(); - if (request - .getIndex() - .equalsIgnoreCase( - Entity.getSearchRepository() - .getIndexMapping(GLOSSARY_TERM) - .getIndexName(clusterAlias))) { + + String indexName = request.getIndex(); + String glossaryTermIndex = + Entity.getSearchRepository().getIndexMapping(GLOSSARY_TERM).getIndexName(clusterAlias); + String domainIndex = + Entity.getSearchRepository().getIndexMapping(DOMAIN).getIndexName(clusterAlias); + + if (indexName.equalsIgnoreCase(glossaryTermIndex)) { response = buildGlossaryTermSearchHierarchy(searchResponse); + } else if (indexName.equalsIgnoreCase(domainIndex)) { + response = buildDomainSearchHierarchy(searchResponse); } return response; } @@ -643,6 +678,38 @@ public List buildGlossaryTermSearchHierarchy(SearchResponse sea return new ArrayList<>(rootTerms.values()); } + public List buildDomainSearchHierarchy(SearchResponse searchResponse) { + Map entityHierarchyMap = + Arrays.stream(searchResponse.getHits().getHits()) + .map(hit -> JsonUtils.readValue(hit.getSourceAsString(), EntityHierarchy.class)) + .collect( + Collectors.toMap( + EntityHierarchy::getFullyQualifiedName, + entity -> { + entity.setChildren(new ArrayList<>()); + return entity; + }, + (existing, replacement) -> existing, + LinkedHashMap::new)); + + List rootDomains = new ArrayList<>(); + + entityHierarchyMap + .values() + .forEach( + entity -> { + String parentFqn = getParentFQN(entity.getFullyQualifiedName()); + EntityHierarchy parentEntity = entityHierarchyMap.get(parentFqn); + if (parentEntity != null) { + parentEntity.getChildren().add(entity); + } else { + rootDomains.add(entity); + } + }); + + return rootDomains; + } + @Override public SearchResultListMapper listWithOffset( String filter, @@ -1817,7 +1884,10 @@ private static SearchSourceBuilder buildDomainsSearch(String query, int from, in buildSearchQueryBuilder(query, DomainIndex.getFields()); FunctionScoreQueryBuilder queryBuilder = boostScore(queryStringBuilder); HighlightBuilder hb = buildHighlights(new ArrayList<>()); - return searchBuilder(queryBuilder, hb, from, size); + SearchSourceBuilder searchSourceBuilder = searchBuilder(queryBuilder, hb, from, size); + searchSourceBuilder.aggregation( + AggregationBuilders.terms("fqnParts_agg").field("fqnParts").size(1000)); + return addAggregation(searchSourceBuilder); } private static SearchSourceBuilder buildCostAnalysisReportDataSearch( diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java index a6baeed0facf..51da4f271dea 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java @@ -47,6 +47,7 @@ import java.util.Optional; import java.util.Set; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; import java.util.stream.Stream; import javax.json.JsonObject; import javax.net.ssl.SSLContext; @@ -462,53 +463,10 @@ public Response search(SearchRequest request, SubjectContext subjectContext) thr .getIndexMapping(GLOSSARY_TERM) .getIndexName(clusterAlias))) { searchSourceBuilder.query(QueryBuilders.boolQuery().must(searchSourceBuilder.query())); - - if (request.isGetHierarchy()) { - /* - Search for user input terms in name, fullyQualifiedName, displayName and glossary.fullyQualifiedName, glossary.displayName - */ - QueryBuilder baseQuery = - QueryBuilders.boolQuery() - .should(searchSourceBuilder.query()) - .should(QueryBuilders.matchPhraseQuery("fullyQualifiedName", request.getQuery())) - .should(QueryBuilders.matchPhraseQuery("name", request.getQuery())) - .should(QueryBuilders.matchPhraseQuery("displayName", request.getQuery())) - .should( - QueryBuilders.matchPhraseQuery( - "glossary.fullyQualifiedName", request.getQuery())) - .should(QueryBuilders.matchPhraseQuery("glossary.displayName", request.getQuery())) - .must(QueryBuilders.matchQuery("status", "Approved")) - .minimumShouldMatch(1); - searchSourceBuilder.query(baseQuery); - - SearchResponse searchResponse = - client.search( - new os.org.opensearch.action.search.SearchRequest(request.getIndex()) - .source(searchSourceBuilder), - RequestOptions.DEFAULT); - - // Extract parent terms from aggregation - BoolQueryBuilder parentTermQueryBuilder = QueryBuilders.boolQuery(); - Terms parentTerms = searchResponse.getAggregations().get("fqnParts_agg"); - - // Build es query to get parent terms for the user input query , to build correct hierarchy - if (!parentTerms.getBuckets().isEmpty() && !request.getQuery().equals("*")) { - parentTerms.getBuckets().stream() - .map(Terms.Bucket::getKeyAsString) - .forEach( - parentTerm -> - parentTermQueryBuilder.should( - QueryBuilders.matchQuery("fullyQualifiedName", parentTerm))); - - searchSourceBuilder.query( - parentTermQueryBuilder - .minimumShouldMatch(1) - .must(QueryBuilders.matchQuery("status", "Approved"))); - } - searchSourceBuilder.sort(SortBuilders.fieldSort("fullyQualifiedName").order(SortOrder.ASC)); - } } + buildHierarchyQuery(request, searchSourceBuilder, client); + /* for performance reasons OpenSearch doesn't provide accurate hits if we enable trackTotalHits parameter it will try to match every result, count and return hits however in most cases for search results an approximate value is good enough. @@ -571,15 +529,88 @@ public Response getDocByID(String indexName, String entityId) throws IOException return getResponse(NOT_FOUND, "Document not found."); } + private void buildHierarchyQuery( + SearchRequest request, SearchSourceBuilder searchSourceBuilder, RestHighLevelClient client) + throws IOException { + + if (!request.isGetHierarchy()) { + return; + } + + String indexName = request.getIndex(); + String glossaryTermIndex = + Entity.getSearchRepository().getIndexMapping(GLOSSARY_TERM).getIndexName(clusterAlias); + String domainIndex = + Entity.getSearchRepository().getIndexMapping(DOMAIN).getIndexName(clusterAlias); + + BoolQueryBuilder baseQuery = + QueryBuilders.boolQuery() + .should(searchSourceBuilder.query()) + .should(QueryBuilders.matchPhraseQuery("fullyQualifiedName", request.getQuery())) + .should(QueryBuilders.matchPhraseQuery("name", request.getQuery())) + .should(QueryBuilders.matchPhraseQuery("displayName", request.getQuery())); + + if (indexName.equalsIgnoreCase(glossaryTermIndex)) { + baseQuery + .should(QueryBuilders.matchPhraseQuery("glossary.fullyQualifiedName", request.getQuery())) + .should(QueryBuilders.matchPhraseQuery("glossary.displayName", request.getQuery())) + .must(QueryBuilders.matchQuery("status", "Approved")); + } else if (indexName.equalsIgnoreCase(domainIndex)) { + baseQuery + .should(QueryBuilders.matchPhraseQuery("parent.fullyQualifiedName", request.getQuery())) + .should(QueryBuilders.matchPhraseQuery("parent.displayName", request.getQuery())); + } + + baseQuery.minimumShouldMatch(1); + searchSourceBuilder.query(baseQuery); + + SearchResponse searchResponse = + client.search( + new os.org.opensearch.action.search.SearchRequest(request.getIndex()) + .source(searchSourceBuilder), + RequestOptions.DEFAULT); + + Terms parentTerms = searchResponse.getAggregations().get("fqnParts_agg"); + + // Build es query to get parent terms for the user input query , to build correct hierarchy + // In case of default search , no need to get parent terms they are already present in the + // response + if (parentTerms != null + && !parentTerms.getBuckets().isEmpty() + && !request.getQuery().equals("*")) { + BoolQueryBuilder parentTermQueryBuilder = QueryBuilders.boolQuery(); + + parentTerms.getBuckets().stream() + .map(Terms.Bucket::getKeyAsString) + .forEach( + parentTerm -> + parentTermQueryBuilder.should( + QueryBuilders.matchQuery("fullyQualifiedName", parentTerm))); + if (indexName.equalsIgnoreCase(glossaryTermIndex)) { + parentTermQueryBuilder + .minimumShouldMatch(1) + .must(QueryBuilders.matchQuery("status", "Approved")); + } else { + parentTermQueryBuilder.minimumShouldMatch(1); + } + searchSourceBuilder.query(parentTermQueryBuilder); + } + + searchSourceBuilder.sort(SortBuilders.fieldSort("fullyQualifiedName").order(SortOrder.ASC)); + } + public List buildSearchHierarchy(SearchRequest request, SearchResponse searchResponse) { List response = new ArrayList<>(); - if (request - .getIndex() - .equalsIgnoreCase( - Entity.getSearchRepository() - .getIndexMapping(GLOSSARY_TERM) - .getIndexName(clusterAlias))) { + String indexName = request.getIndex(); + String glossaryTermIndex = + Entity.getSearchRepository().getIndexMapping(GLOSSARY_TERM).getIndexName(clusterAlias); + String domainIndex = + Entity.getSearchRepository().getIndexMapping(DOMAIN).getIndexName(clusterAlias); + + if (indexName.equalsIgnoreCase(glossaryTermIndex)) { response = buildGlossaryTermSearchHierarchy(searchResponse); + } else if (indexName.equalsIgnoreCase(domainIndex)) { + response = buildDomainSearchHierarchy(searchResponse); } return response; } @@ -635,6 +666,38 @@ public List buildGlossaryTermSearchHierarchy(SearchResponse sea return new ArrayList<>(rootTerms.values()); } + public List buildDomainSearchHierarchy(SearchResponse searchResponse) { + Map entityHierarchyMap = + Arrays.stream(searchResponse.getHits().getHits()) + .map(hit -> JsonUtils.readValue(hit.getSourceAsString(), EntityHierarchy.class)) + .collect( + Collectors.toMap( + EntityHierarchy::getFullyQualifiedName, + entity -> { + entity.setChildren(new ArrayList<>()); + return entity; + }, + (existing, replacement) -> existing, + LinkedHashMap::new)); + + List rootDomains = new ArrayList<>(); + + entityHierarchyMap + .values() + .forEach( + entity -> { + String parentFqn = getParentFQN(entity.getFullyQualifiedName()); + EntityHierarchy parentEntity = entityHierarchyMap.get(parentFqn); + if (parentEntity != null) { + parentEntity.getChildren().add(entity); + } else { + rootDomains.add(entity); + } + }); + + return rootDomains; + } + @Override public SearchResultListMapper listWithOffset( String filter, diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/util/EntityHierarchyList.java b/openmetadata-service/src/main/java/org/openmetadata/service/util/EntityHierarchyList.java new file mode 100644 index 000000000000..fe58dc390de3 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/util/EntityHierarchyList.java @@ -0,0 +1,13 @@ +package org.openmetadata.service.util; + +import java.util.List; +import org.openmetadata.schema.entity.data.EntityHierarchy; + +public class EntityHierarchyList extends ResultList { + @SuppressWarnings("unused") + public EntityHierarchyList() {} + + public EntityHierarchyList(List data) { + super(data, null, null, data.size()); + } +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/domains/DomainResourceTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/domains/DomainResourceTest.java index f2d1e8540309..178ca066eb98 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/resources/domains/DomainResourceTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/domains/DomainResourceTest.java @@ -3,6 +3,8 @@ import static javax.ws.rs.core.Response.Status.NOT_FOUND; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.openmetadata.common.utils.CommonUtil.listOf; import static org.openmetadata.service.Entity.TABLE; import static org.openmetadata.service.security.SecurityUtil.authHeaders; @@ -17,15 +19,18 @@ import static org.openmetadata.service.util.TestUtils.assertResponse; import java.io.IOException; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.UUID; +import javax.ws.rs.client.WebTarget; import javax.ws.rs.core.Response.Status; import org.apache.http.client.HttpResponseException; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInfo; import org.openmetadata.schema.api.domains.CreateDomain; import org.openmetadata.schema.api.domains.CreateDomain.DomainType; +import org.openmetadata.schema.entity.data.EntityHierarchy; import org.openmetadata.schema.entity.domains.Domain; import org.openmetadata.schema.entity.type.Style; import org.openmetadata.schema.type.ChangeDescription; @@ -35,7 +40,9 @@ import org.openmetadata.service.jdbi3.TableRepository; import org.openmetadata.service.resources.EntityResourceTest; import org.openmetadata.service.resources.domains.DomainResource.DomainList; +import org.openmetadata.service.util.EntityHierarchyList; import org.openmetadata.service.util.JsonUtils; +import org.openmetadata.service.util.TestUtils; public class DomainResourceTest extends EntityResourceTest { public DomainResourceTest() { @@ -143,6 +150,214 @@ void patchWrongExperts(TestInfo test) throws IOException { String.format("user instance for %s not found", expertReference.getId())); } + @Test + void test_buildDomainNestedHierarchyFromSearch() throws HttpResponseException { + CreateDomain createRootDomain = + createRequest("rootDomain") + .withDisplayName("Global Headquarters") + .withDescription("Root Domain") + .withDomainType(DomainType.AGGREGATE) + .withStyle(null) + .withExperts(null); + Domain rootDomain = createEntity(createRootDomain, ADMIN_AUTH_HEADERS); + + CreateDomain createSecondLevelDomain = + createRequest("secondLevelDomain") + .withDisplayName("Operations Hub") + .withDescription("Second Level Domain") + .withDomainType(DomainType.AGGREGATE) + .withStyle(null) + .withExperts(null) + .withParent(rootDomain.getFullyQualifiedName()); + Domain secondLevelDomain = createEntity(createSecondLevelDomain, ADMIN_AUTH_HEADERS); + + CreateDomain createThirdLevelDomain = + createRequest("thirdLevelDomain") + .withDisplayName("Innovation Center") + .withDescription("Third Level Domain") + .withDomainType(DomainType.AGGREGATE) + .withStyle(null) + .withExperts(null) + .withParent(secondLevelDomain.getFullyQualifiedName()); + Domain thirdLevelDomain = createEntity(createThirdLevelDomain, ADMIN_AUTH_HEADERS); + + // Search for the displayName of third-level child domain and verify the hierarchy + String response = getResponseFormSearchWithHierarchy("domain_search_index", "*innovation*"); + List domains = JsonUtils.readObjects(response, EntityHierarchy.class); + + boolean isChild = + domains.stream() + .filter(domain -> "rootDomain".equals(domain.getName())) + .findFirst() + .map( + root -> + root.getChildren().stream() + .filter(domain -> "secondLevelDomain".equals(domain.getName())) + .flatMap(secondLevel -> secondLevel.getChildren().stream()) + .anyMatch(thirdLevel -> "thirdLevelDomain".equals(thirdLevel.getName()))) + .orElse(false); + + assertTrue( + isChild, + "thirdLevelDomain should be a child of secondLevelDomain, which should be a child of rootDomain"); + + // Search for the fqn of third-level child domain and verify the hierarchy + response = getResponseFormSearchWithHierarchy("domain_search_index", "*third*"); + domains = JsonUtils.readObjects(response, EntityHierarchy.class); + + isChild = + domains.stream() + .filter(domain -> "rootDomain".equals(domain.getName())) + .findFirst() + .map( + root -> + root.getChildren().stream() + .filter(domain -> "secondLevelDomain".equals(domain.getName())) + .flatMap(secondLevel -> secondLevel.getChildren().stream()) + .anyMatch(thirdLevel -> "thirdLevelDomain".equals(thirdLevel.getName()))) + .orElse(false); + + assertTrue( + isChild, + "thirdLevelDomain should be a child of secondLevelDomain, which should be a child of rootDomain"); + } + + @Test + void get_hierarchicalListOfDomain(TestInfo test) throws HttpResponseException { + Domain rootDomain = createEntity(createRequest("A_ROOT_DOMAIN"), ADMIN_AUTH_HEADERS); + Domain subDomain1 = + createEntity( + createRequest("A_subDomain1").withParent(rootDomain.getFullyQualifiedName()), + ADMIN_AUTH_HEADERS); + Domain subDomain2 = + createEntity( + createRequest("A_subDomain2").withParent(rootDomain.getFullyQualifiedName()), + ADMIN_AUTH_HEADERS); + Domain subDomain3 = + createEntity( + createRequest("A_subDomain3").withParent(rootDomain.getFullyQualifiedName()), + ADMIN_AUTH_HEADERS); + + // Ensure parent has all the newly created children + rootDomain = getEntity(rootDomain.getId(), "children,parent", ADMIN_AUTH_HEADERS); + assertEntityReferences( + new ArrayList<>( + List.of( + subDomain1.getEntityReference(), + subDomain2.getEntityReference(), + subDomain3.getEntityReference())), + rootDomain.getChildren()); + + Domain subSubDomain1 = + createEntity( + createRequest("A_subSubDomain11").withParent(subDomain1.getFullyQualifiedName()), + ADMIN_AUTH_HEADERS); + Domain subSubDomain2 = + createEntity( + createRequest("A_subSubDomain12").withParent(subDomain1.getFullyQualifiedName()), + ADMIN_AUTH_HEADERS); + Domain subSubDomain3 = + createEntity( + createRequest("A_subSubDomain13").withParent(subDomain1.getFullyQualifiedName()), + ADMIN_AUTH_HEADERS); + + // Ensure parent has all the newly created children + subDomain1 = getEntity(subDomain1.getId(), "children,parent", ADMIN_AUTH_HEADERS); + assertEntityReferences( + new ArrayList<>( + List.of( + subSubDomain1.getEntityReference(), + subSubDomain2.getEntityReference(), + subSubDomain3.getEntityReference())), + subDomain1.getChildren()); + assertParent(subSubDomain1, subDomain1.getEntityReference()); + assertParent(subSubDomain2, subDomain1.getEntityReference()); + assertParent(subSubDomain3, subDomain1.getEntityReference()); + + // Create another root domain without hierarchy + Domain secondRootDomain = createEntity(createRequest("B_ROOT_DOMAIN"), ADMIN_AUTH_HEADERS); + + List hierarchyList = getDomainsHierarchy(ADMIN_AUTH_HEADERS).getData(); + + UUID rootDomainId = rootDomain.getId(); + UUID subDomain1Id = subDomain1.getId(); + UUID subDomain2Id = subDomain2.getId(); + UUID subDomain3Id = subDomain3.getId(); + UUID subSubDomain1Id = subSubDomain1.getId(); + UUID subSubDomain2Id = subSubDomain2.getId(); + UUID subSubDomain3Id = subSubDomain3.getId(); + UUID secondRootDomainId = secondRootDomain.getId(); + + EntityHierarchy rootHierarchy = + hierarchyList.stream().filter(h -> h.getId().equals(rootDomainId)).findAny().orElse(null); + assertNotNull(rootHierarchy); + assertEquals(3, rootHierarchy.getChildren().size()); + + List rootChildren = rootHierarchy.getChildren(); + assertEquals(3, rootChildren.size()); + assertTrue(rootChildren.stream().anyMatch(h -> h.getId().equals(subDomain1Id))); + assertTrue(rootChildren.stream().anyMatch(h -> h.getId().equals(subDomain2Id))); + assertTrue(rootChildren.stream().anyMatch(h -> h.getId().equals(subDomain3Id))); + + EntityHierarchy subDomain1Hierarchy = + rootChildren.stream().filter(h -> h.getId().equals(subDomain1Id)).findAny().orElse(null); + assertNotNull(subDomain1Hierarchy); + assertEquals(3, subDomain1Hierarchy.getChildren().size()); + + List subDomain1Children = subDomain1Hierarchy.getChildren(); + assertTrue(subDomain1Children.stream().anyMatch(h -> h.getId().equals(subSubDomain1Id))); + assertTrue(subDomain1Children.stream().anyMatch(h -> h.getId().equals(subSubDomain2Id))); + assertTrue(subDomain1Children.stream().anyMatch(h -> h.getId().equals(subSubDomain3Id))); + + EntityHierarchy subSubDomain1Hierarchy = + subDomain1Children.stream() + .filter(h -> h.getId().equals(subSubDomain1Id)) + .findAny() + .orElse(null); + assertNotNull(subSubDomain1Hierarchy); + assertEquals(0, subSubDomain1Hierarchy.getChildren().size()); + + EntityHierarchy subSubDomain2Hierarchy = + subDomain1Children.stream() + .filter(h -> h.getId().equals(subSubDomain2Id)) + .findAny() + .orElse(null); + assertNotNull(subSubDomain2Hierarchy); + assertEquals(0, subSubDomain2Hierarchy.getChildren().size()); + + EntityHierarchy subSubDomain3Hierarchy = + subDomain1Children.stream() + .filter(h -> h.getId().equals(subSubDomain3Id)) + .findAny() + .orElse(null); + assertNotNull(subSubDomain3Hierarchy); + assertEquals(0, subSubDomain3Hierarchy.getChildren().size()); + + // Verify the new root domain without hierarchy + EntityHierarchy secondRootDomainHierarchy = + hierarchyList.stream() + .filter(h -> h.getId().equals(secondRootDomainId)) + .findAny() + .orElse(null); + assertNotNull(secondRootDomainHierarchy); + assertEquals(0, secondRootDomainHierarchy.getChildren().size()); + } + + private void assertParent(Domain domain, EntityReference expectedParent) + throws HttpResponseException { + assertEquals(expectedParent, domain.getParent()); + // Ensure the parent has the given domain as a child + Domain parent = getEntity(expectedParent.getId(), "children", ADMIN_AUTH_HEADERS); + assertEntityReferencesContain(parent.getChildren(), domain.getEntityReference()); + } + + private EntityHierarchyList getDomainsHierarchy(Map authHeaders) + throws HttpResponseException { + WebTarget target = getResource("domains/hierarchy"); + target = target.queryParam("limit", 25); + return TestUtils.get(target, EntityHierarchyList.class, authHeaders); + } + @Override public CreateDomain createRequest(String name) { return new CreateDomain() 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 e2f13affea7e..6d644b2f5ab5 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 @@ -141,9 +141,13 @@ test.describe('Domains', () => { const dataProduct1 = new DataProduct(domain); const dataProduct2 = new DataProduct(domain); await domain.create(apiContext); - await sidebarClick(page, SidebarItem.DOMAIN); await page.reload(); - await addAssetsToDomain(page, domain, assets); + + await test.step('Add assets to domain', async () => { + await redirectToHomePage(page); + await sidebarClick(page, SidebarItem.DOMAIN); + await addAssetsToDomain(page, domain, assets); + }); await test.step('Create DataProducts', async () => { await selectDomain(page, domain.data); @@ -582,7 +586,7 @@ test.describe('Domains Rbac', () => { const urlParams = new URLSearchParams(queryString); const qParam = urlParams.get('q'); - expect(qParam).toContain(`domain.fullyQualifiedName:`); + expect(qParam).toEqual(''); }); for (const asset of domainAssset2) { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Entity.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Entity.spec.ts index 5e5294e3d321..01d983cf9aef 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Entity.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Entity.spec.ts @@ -115,7 +115,7 @@ entities.forEach((EntityClass) => { }, false ); - await removeDomain(page); + await removeDomain(page, EntityDataClass.domain1.responseData); } }); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DatabaseClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DatabaseClass.ts index df1bdfc0cbdf..d800d4fe3da5 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DatabaseClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DatabaseClass.ts @@ -348,6 +348,6 @@ export class DatabaseClass extends EntityClass { await assignDomain(page, domain1); await this.verifyDomainPropagation(page, domain1); await updateDomain(page, domain2); - await removeDomain(page); + await removeDomain(page, domain2); } } diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/EntityClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/EntityClass.ts index d3b91e20cf7b..e79eb7edf669 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/EntityClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/EntityClass.ts @@ -124,7 +124,7 @@ export class EntityClass { ) { await assignDomain(page, domain1); await updateDomain(page, domain2); - await removeDomain(page); + await removeDomain(page, domain2); } async owner( 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 bff8a10b1792..01a7a17f01c7 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts @@ -152,7 +152,7 @@ export const visitOwnProfilePage = async (page: Page) => { export const assignDomain = async ( page: Page, - domain: { name: string; displayName: string } + domain: { name: string; displayName: string; fullyQualifiedName?: string } ) => { await page.getByTestId('add-domain').click(); await page.waitForSelector('[data-testid="loader"]', { state: 'detached' }); @@ -160,11 +160,13 @@ export const assignDomain = async ( `/api/v1/search/query?q=*${encodeURIComponent(domain.name)}*` ); await page - .getByTestId('selectable-list') + .getByTestId('domain-selectable-tree') .getByTestId('searchbar') .fill(domain.name); await searchDomain; - await page.getByRole('listitem', { name: domain.displayName }).click(); + + await page.getByTestId(`tag-${domain.fullyQualifiedName}`).click(); + await page.getByTestId('saveAssociatedTag').click(); await expect(page.getByTestId('domain-link')).toContainText( domain.displayName @@ -173,33 +175,42 @@ export const assignDomain = async ( export const updateDomain = async ( page: Page, - domain: { name: string; displayName: string } + domain: { name: string; displayName: string; fullyQualifiedName?: string } ) => { await page.getByTestId('add-domain').click(); await page.waitForSelector('[data-testid="loader"]', { state: 'detached' }); - await page.getByTestId('selectable-list').getByTestId('searchbar').clear(); + + await page + .getByTestId('domain-selectable-tree') + .getByTestId('searchbar') + .clear(); + const searchDomain = page.waitForResponse( `/api/v1/search/query?q=*${encodeURIComponent(domain.name)}*` ); await page - .getByTestId('selectable-list') + .getByTestId('domain-selectable-tree') .getByTestId('searchbar') .fill(domain.name); await searchDomain; - await page.getByRole('listitem', { name: domain.displayName }).click(); + + await page.getByTestId(`tag-${domain.fullyQualifiedName}`).click(); + await page.getByTestId('saveAssociatedTag').click(); await expect(page.getByTestId('domain-link')).toContainText( domain.displayName ); }; -export const removeDomain = async (page: Page) => { +export const removeDomain = async ( + page: Page, + domain: { name: string; displayName: string; fullyQualifiedName?: string } +) => { await page.getByTestId('add-domain').click(); await page.waitForSelector('[data-testid="loader"]', { state: 'detached' }); - await expect(page.getByTestId('remove-owner').locator('path')).toBeVisible(); - - await page.getByTestId('remove-owner').locator('svg').click(); + await page.getByTestId(`tag-${domain.fullyQualifiedName}`).click(); + await page.getByTestId('saveAssociatedTag').click(); await expect(page.getByTestId('no-domain-text')).toContainText('No Domain'); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-subdomain.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-subdomain.svg new file mode 100644 index 000000000000..544403812a74 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-subdomain.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetailsPage/DomainDetailsPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetailsPage/DomainDetailsPage.component.tsx index 58953c2f9e50..379f6afaed9d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetailsPage/DomainDetailsPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetailsPage/DomainDetailsPage.component.tsx @@ -40,6 +40,7 @@ import { useHistory, useParams } from 'react-router-dom'; import { ReactComponent as EditIcon } from '../../../assets/svg/edit-new.svg'; import { ReactComponent as DeleteIcon } from '../../../assets/svg/ic-delete.svg'; import { ReactComponent as DomainIcon } from '../../../assets/svg/ic-domain.svg'; +import { ReactComponent as SubDomainIcon } from '../../../assets/svg/ic-subdomain.svg'; import { ReactComponent as VersionIcon } from '../../../assets/svg/ic-version.svg'; import { ReactComponent as IconDropdown } from '../../../assets/svg/menu.svg'; import { ReactComponent as StyleIcon } from '../../../assets/svg/style.svg'; @@ -639,6 +640,41 @@ const DomainDetailsPage = ({ fetchSubDomains(); }, [domainFqn]); + const iconData = useMemo(() => { + if (domain.style?.iconURL) { + return ( + domain-icon + ); + } else if (isSubDomain) { + return ( + + ); + } + + return ( + + ); + }, [domain, isSubDomain]); + return ( <> - ) : ( - - ) - } + icon={iconData} serviceName="" titleColor={domain.style?.color} /> diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx index 7f90651e9d42..2db0161d7f2e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTermTab/GlossaryTermTab.component.tsx @@ -82,7 +82,7 @@ import Fqn from '../../../utils/Fqn'; import { buildTree, findExpandableKeysForArray, - findGlossaryTermByFqn, + findItemByFqn, glossaryTermTableColumnsWidth, StatusClass, } from '../../../utils/GlossaryUtils'; @@ -672,10 +672,7 @@ const GlossaryTermTab = ({ ); const terms = cloneDeep(glossaryTerms) ?? []; - const item = findGlossaryTermByFqn( - terms, - record.fullyQualifiedName ?? '' - ); + const item = findItemByFqn(terms, record.fullyQualifiedName ?? ''); (item as ModifiedGlossary).children = data; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/TreeAsyncSelectList.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/TreeAsyncSelectList.tsx index d47ea8070963..ccd96f06df98 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/TreeAsyncSelectList.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/TreeAsyncSelectList.tsx @@ -43,7 +43,7 @@ import { getEntityName } from '../../../utils/EntityUtils'; import { convertGlossaryTermsToTreeOptions, filterTreeNodeOptions, - findGlossaryTermByFqn, + findItemByFqn, } from '../../../utils/GlossaryUtils'; import { escapeESReservedCharacters, @@ -200,7 +200,7 @@ const TreeAsyncSelectList: FC> = ({ if (lastSelectedMap.has(value)) { return lastSelectedMap.get(value) as SelectOption; } - const initialData = findGlossaryTermByFqn( + const initialData = findItemByFqn( [ ...glossaries, ...(isNull(searchOptions) ? [] : searchOptions), diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableList/DomainSelectableList.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableList/DomainSelectableList.component.tsx index aecba1071cf3..fef7bebee64b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableList/DomainSelectableList.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableList/DomainSelectableList.component.tsx @@ -15,26 +15,12 @@ import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ReactComponent as EditIcon } from '../../../assets/svg/edit-new.svg'; import { ReactComponent as DomainIcon } from '../../../assets/svg/ic-domain.svg'; -import { - DE_ACTIVE_COLOR, - PAGE_SIZE_MEDIUM, -} from '../../../constants/constants'; +import { DE_ACTIVE_COLOR } from '../../../constants/constants'; import { NO_PERMISSION_FOR_ACTION } from '../../../constants/HelperTextUtil'; -import { EntityType } from '../../../enums/entity.enum'; -import { SearchIndex } from '../../../enums/search.enum'; import { EntityReference } from '../../../generated/entity/type'; -import { useApplicationStore } from '../../../hooks/useApplicationStore'; -import { getDomainList } from '../../../rest/domainAPI'; -import { searchData } from '../../../rest/miscAPI'; -import { formatDomainsResponse } from '../../../utils/APIUtils'; -import { Transi18next } from '../../../utils/CommonUtils'; -import { - getEntityName, - getEntityReferenceListFromEntities, -} from '../../../utils/EntityUtils'; +import { getEntityName } from '../../../utils/EntityUtils'; import Fqn from '../../../utils/Fqn'; -import { getDomainPath } from '../../../utils/RouterUtils'; -import { SelectableList } from '../SelectableList/SelectableList.component'; +import DomainSelectablTree from '../DomainSelectableTree/DomainSelectableTree'; import './domain-select-dropdown.less'; import { DomainSelectableListProps } from './DomainSelectableList.interface'; @@ -74,57 +60,18 @@ const DomainSelectableList = ({ multiple = false, }: DomainSelectableListProps) => { const { t } = useTranslation(); - const { theme } = useApplicationStore(); const [popupVisible, setPopupVisible] = useState(false); const selectedDomainsList = useMemo(() => { if (selectedDomain) { - return Array.isArray(selectedDomain) ? selectedDomain : [selectedDomain]; + return Array.isArray(selectedDomain) + ? selectedDomain.map((item) => item.fullyQualifiedName) + : [selectedDomain.fullyQualifiedName]; } return []; }, [selectedDomain]); - const fetchOptions = async (searchText: string, after?: string) => { - if (searchText) { - try { - const res = await searchData( - searchText, - 1, - PAGE_SIZE_MEDIUM, - '', - '', - '', - SearchIndex.DOMAIN - ); - - const data = getEntityReferenceListFromEntities( - formatDomainsResponse(res.data.hits.hits), - EntityType.DOMAIN - ); - - return { data, paging: { total: res.data.hits.total.value } }; - } catch (error) { - return { data: [], paging: { total: 0 } }; - } - } else { - try { - const { data, paging } = await getDomainList({ - limit: PAGE_SIZE_MEDIUM, - after: after ?? undefined, - }); - const filterData = getEntityReferenceListFromEntities( - data, - EntityType.DOMAIN - ); - - return { data: filterData, paging }; - } catch (error) { - return { data: [], paging: { total: 0 } }; - } - } - }; - const handleUpdate = useCallback( async (domains: EntityReference[]) => { if (multiple) { @@ -132,6 +79,7 @@ const DomainSelectableList = ({ } else { await onUpdate(domains[0]); } + setPopupVisible(false); }, [onUpdate, multiple] @@ -146,39 +94,16 @@ const DomainSelectableList = ({ - } - values={{ - link: t('label.domain-plural'), - }} - /> - } - fetchOptions={fetchOptions} - multiSelect={multiple} - removeIconTooltipLabel={t('label.remove-entity', { - entity: t('label.domain-lowercase'), - })} - searchPlaceholder={t('label.search-for-type', { - type: t('label.domain'), - })} - selectedItems={selectedDomainsList} + setPopupVisible(false)} - onUpdate={handleUpdate} + onSubmit={handleUpdate} /> } open={popupVisible} - overlayClassName="domain-select-popover" + overlayClassName="domain-select-popover w-400" placement="bottomRight" showArrow={false} trigger="click" diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableList/domain-select-dropdown.less b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableList/domain-select-dropdown.less index 3b8694212385..13aae85efc6a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableList/domain-select-dropdown.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableList/domain-select-dropdown.less @@ -15,7 +15,11 @@ .domain-select-popover { min-width: 275px; - padding-top: 0; + + &.ant-popover { + padding-top: 0; + } + .ant-popover-inner-content { padding: 0; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableTree/DomainSelectableTree.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableTree/DomainSelectableTree.interface.ts new file mode 100644 index 000000000000..b3c140eda20f --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableTree/DomainSelectableTree.interface.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2025 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 { DefaultOptionType } from 'antd/lib/select'; +import { EntityReference } from '../../../generated/entity/type'; + +export interface DomainSelectableTreeProps { + value?: string[]; // array of fqn + onSubmit: (option: EntityReference[]) => Promise; + visible: boolean; + onCancel: () => void; + isMultiple?: boolean; +} + +export type TreeListItem = Omit; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableTree/DomainSelectableTree.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableTree/DomainSelectableTree.tsx new file mode 100644 index 000000000000..090a5df0b757 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableTree/DomainSelectableTree.tsx @@ -0,0 +1,224 @@ +/* + * Copyright 2025 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 { Button, Empty, Space, Tree } from 'antd'; +import Search from 'antd/lib/input/Search'; +import { AxiosError } from 'axios'; +import { debounce } from 'lodash'; +import React, { + FC, + Key, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as IconDown } from '../../../assets/svg/ic-arrow-down.svg'; +import { ReactComponent as IconRight } from '../../../assets/svg/ic-arrow-right.svg'; +import { EntityType } from '../../../enums/entity.enum'; +import { Domain } from '../../../generated/entity/domains/domain'; +import { EntityReference } from '../../../generated/tests/testCase'; +import { listDomainHierarchy, searchDomains } from '../../../rest/domainAPI'; +import { convertDomainsToTreeOptions } from '../../../utils/DomainUtils'; +import { getEntityReferenceFromEntity } from '../../../utils/EntityUtils'; +import { findItemByFqn } from '../../../utils/GlossaryUtils'; +import { + escapeESReservedCharacters, + getEncodedFqn, +} from '../../../utils/StringsUtils'; +import { showErrorToast } from '../../../utils/ToastUtils'; +import Loader from '../Loader/Loader'; +import './domain-selectable.less'; +import { + DomainSelectableTreeProps, + TreeListItem, +} from './DomainSelectableTree.interface'; + +const DomainSelectablTree: FC = ({ + onSubmit, + value, + visible, + onCancel, + isMultiple = false, +}) => { + const { t } = useTranslation(); + const [treeData, setTreeData] = useState([]); + const [domains, setDomains] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isSubmitLoading, setIsSubmitLoading] = useState(false); + const [selectedDomains, setSelectedDomains] = useState([]); + const [searchTerm, setSearchTerm] = useState(''); + + const handleSave = async () => { + setIsSubmitLoading(true); + if (isMultiple) { + const selectedData = []; + for (const item of selectedDomains) { + selectedData.push(findItemByFqn(domains, item as string, false)); + } + const domains1 = selectedData.map((item) => + getEntityReferenceFromEntity(item as EntityReference, EntityType.DOMAIN) + ); + await onSubmit(domains1); + } else { + let retn: EntityReference[] = []; + if (selectedDomains.length > 0) { + const initialData = findItemByFqn( + domains, + selectedDomains[0] as string, + false + ); + const domain = getEntityReferenceFromEntity( + initialData as EntityReference, + EntityType.DOMAIN + ); + retn = [domain]; + } + await onSubmit(retn); + } + + setIsSubmitLoading(false); + }; + + const fetchAPI = useCallback(async () => { + try { + setIsLoading(true); + const data = await listDomainHierarchy({ limit: 100 }); + setTreeData(convertDomainsToTreeOptions(data.data, 0, isMultiple)); + setDomains(data.data); + } catch (error) { + showErrorToast(error as AxiosError); + } finally { + setIsLoading(false); + } + }, []); + + const onSelect = (selectedKeys: React.Key[]) => { + if (!isMultiple) { + setSelectedDomains(selectedKeys); + } + }; + + const onCheck = ( + checked: Key[] | { checked: Key[]; halfChecked: Key[] } + ): void => { + if (Array.isArray(checked)) { + setSelectedDomains(checked); + } else { + setSelectedDomains(checked.checked); + } + }; + + const onSearch = debounce(async (value: string) => { + setSearchTerm(value); + if (value) { + try { + setIsLoading(true); + const encodedValue = getEncodedFqn(escapeESReservedCharacters(value)); + const results: Domain[] = await searchDomains(encodedValue); + const updatedTreeData = convertDomainsToTreeOptions( + results, + 0, + isMultiple + ); + setTreeData(updatedTreeData); + } finally { + setIsLoading(false); + } + } else { + fetchAPI(); + } + }, 300); + + const switcherIcon = useCallback(({ expanded }) => { + return expanded ? : ; + }, []); + + const treeContent = useMemo(() => { + if (isLoading) { + return ; + } else if (treeData.length === 0) { + return ( + + ); + } else { + return ( + + ); + } + }, [isLoading, treeData, value, onSelect, isMultiple, searchTerm]); + + useEffect(() => { + if (visible) { + setSearchTerm(''); + fetchAPI(); + } + }, [visible]); + + return ( +
+ onSearch(e.target.value)} + /> + + {treeContent} + + + + + +
+ ); +}; + +export default DomainSelectablTree; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableTree/domain-selectable.less b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableTree/domain-selectable.less new file mode 100644 index 000000000000..bcf651e2e812 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableTree/domain-selectable.less @@ -0,0 +1,26 @@ +/* + * Copyright 2025 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'); + +.domain-selectable-tree { + overflow: auto; + height: 270px; + + .ant-tree-treenode { + .ant-tree-node-content-wrapper { + &.ant-tree-node-selected { + background-color: @radio-button-checked-bg; + } + } + } +} diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useDomainStore.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useDomainStore.ts index c25f6fb992d8..ffc93273ea81 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useDomainStore.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useDomainStore.ts @@ -34,7 +34,7 @@ export const useDomainStore = create()( activeDomain: DEFAULT_DOMAIN_VALUE, // Set default value here activeDomainEntityRef: undefined, domainOptions: [], - updateDomains: (data: Domain[], selectDefault = true) => { + updateDomains: (data: Domain[]) => { const currentUser = useApplicationStore.getState().currentUser; const { isAdmin = false, domains = [] } = currentUser ?? {}; const userDomainsObj = isAdmin ? [] : domains; @@ -51,19 +51,9 @@ export const useDomainStore = create()( set({ domains: filteredDomains, domainOptions: getDomainOptions( - isAdmin ? filteredDomains : userDomainsObj, - isAdmin + isAdmin ? filteredDomains : userDomainsObj ), }); - - if ( - selectDefault && - !isAdmin && - userDomainsObj.length > 0 && - get().activeDomain === DEFAULT_DOMAIN_VALUE - ) { - get().updateActiveDomain(userDomainsObj[0].fullyQualifiedName ?? ''); - } }, updateActiveDomain: (activeDomainKey: string) => { const currentUser = useApplicationStore.getState().currentUser; diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/domainAPI.ts b/openmetadata-ui/src/main/resources/ui/src/rest/domainAPI.ts index ab5517588499..7716bbbb86e4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/domainAPI.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/domainAPI.ts @@ -14,6 +14,8 @@ import { AxiosResponse } from 'axios'; import { Operation } from 'fast-json-patch'; import { PagingResponse } from 'Models'; +import { PAGE_SIZE_MEDIUM } from '../constants/constants'; +import { SearchIndex } from '../enums/search.enum'; import { CreateDomain } from '../generated/api/domains/createDomain'; import { Domain, EntityReference } from '../generated/entity/domains/domain'; import { EntityHistory } from '../generated/type/entityHistory'; @@ -105,3 +107,31 @@ export const removeAssetsFromDomain = async ( return response.data; }; + +export const listDomainHierarchy = async (params?: ListParams) => { + const response = await APIClient.get>( + `${BASE_URL}/hierarchy`, + { + params, + } + ); + + return response.data; +}; + +export const searchDomains = async (search: string, page = 1) => { + const apiUrl = `/search/query?q=*${search ?? ''}*`; + + const { data } = await APIClient.get(apiUrl, { + params: { + index: SearchIndex.DOMAIN, + from: (page - 1) * PAGE_SIZE_MEDIUM, + size: PAGE_SIZE_MEDIUM, + deleted: false, + track_total_hits: true, + getHierarchy: true, + }, + }); + + return data; +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/DomainUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/DomainUtils.tsx index f6643c077ada..6bc8b5f030c4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/DomainUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/DomainUtils.tsx @@ -14,12 +14,16 @@ import { Divider, Space, Typography } from 'antd'; import { ItemType } from 'antd/lib/menu/hooks/useItems'; import classNames from 'classnames'; import { t } from 'i18next'; -import { isEmpty, isUndefined } from 'lodash'; +import { get, isEmpty, isUndefined } from 'lodash'; import React, { Fragment, ReactNode } from 'react'; import { Link } from 'react-router-dom'; +import { ReactComponent as DomainIcon } from '../assets/svg/ic-domain.svg'; +import { ReactComponent as SubDomainIcon } from '../assets/svg/ic-subdomain.svg'; +import { TreeListItem } from '../components/common/DomainSelectableTree/DomainSelectableTree.interface'; import { OwnerLabel } from '../components/common/OwnerLabel/OwnerLabel.component'; import { DEFAULT_DOMAIN_VALUE, + DE_ACTIVE_COLOR, NO_DATA_PLACEHOLDER, } from '../constants/constants'; import { DOMAIN_TYPE_DATA } from '../constants/Domain.constants'; @@ -202,23 +206,33 @@ export const domainTypeTooltipDataRender = () => ( ); -export const getDomainOptions = ( - domains: Domain[] | EntityReference[], - isAdmin = true -) => { - const domainOptions: ItemType[] = - isAdmin || domains.length === 0 - ? [ - { - label: t('label.all-domain-plural'), - key: DEFAULT_DOMAIN_VALUE, - }, - ] - : []; +export const getDomainOptions = (domains: Domain[] | EntityReference[]) => { + const domainOptions: ItemType[] = [ + { + label: t('label.all-domain-plural'), + key: DEFAULT_DOMAIN_VALUE, + }, + ]; + domains.forEach((domain) => { domainOptions.push({ label: getEntityName(domain), key: domain.fullyQualifiedName ?? '', + icon: get(domain, 'parent') ? ( + + ) : ( + + ), }); }); @@ -266,3 +280,54 @@ export const getDomainFieldFromEntityType = ( return 'domain'; } }; + +export const convertDomainsToTreeOptions = ( + options: EntityReference[] | Domain[] = [], + level = 0, + multiple = false +): TreeListItem[] => { + const treeData = options.map((option) => { + const hasChildren = 'children' in option && !isEmpty(option?.children); + + return { + id: option.id, + value: option.fullyQualifiedName, + name: option.name, + label: option.name, + key: option.fullyQualifiedName, + title: ( +
+ {level === 0 ? ( + + ) : ( + + )} + + {getEntityName(option)} +
+ ), + 'data-testid': `tag-${option.fullyQualifiedName}`, + isLeaf: !hasChildren, + selectable: !multiple, + children: hasChildren + ? convertDomainsToTreeOptions( + (option as unknown as Domain)?.children as EntityReference[], + level + 1, + multiple + ) + : undefined, + }; + }); + + return treeData; +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.tsx index 90d89006ae26..e218fb78dd6c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.tsx @@ -22,6 +22,7 @@ import { FQN_SEPARATOR_CHAR } from '../constants/char.constants'; import { EntityType } from '../enums/entity.enum'; import { Glossary } from '../generated/entity/data/glossary'; import { GlossaryTerm, Status } from '../generated/entity/data/glossaryTerm'; +import { Domain } from '../generated/entity/domains/domain'; import { calculatePercentageFromValue } from './CommonUtils'; import { getEntityName } from './EntityUtils'; import Fqn from './Fqn'; @@ -177,23 +178,28 @@ export const updateGlossaryTermByFqn = ( // This function finds and gives you the glossary term you're looking for. // You can then use this term or update its information in the Glossary or Term with it's reference created // Reference will only be created if withReference is true -export const findGlossaryTermByFqn = ( - list: ModifiedGlossaryTerm[], +export const findItemByFqn = ( + list: ModifiedGlossaryTerm[] | Domain[], fullyQualifiedName: string, withReference = true ): GlossaryTerm | Glossary | ModifiedGlossary | null => { for (const item of list) { - if ((item.fullyQualifiedName ?? item.value) === fullyQualifiedName) { + if ( + (item.fullyQualifiedName ?? (item as ModifiedGlossaryTerm).value) === + fullyQualifiedName + ) { return withReference ? item : { ...item, - fullyQualifiedName: item.fullyQualifiedName ?? item.data?.tagFQN, - ...(item.data ?? {}), + fullyQualifiedName: + item.fullyQualifiedName ?? + (item as ModifiedGlossaryTerm).data?.tagFQN, + ...((item as ModifiedGlossaryTerm).data ?? {}), }; } if (item.children) { - const found = findGlossaryTermByFqn( + const found = findItemByFqn( item.children as ModifiedGlossaryTerm[], fullyQualifiedName ); From 7745fc6700f34260cd4898ffc5e43d7e13a2c3c5 Mon Sep 17 00:00:00 2001 From: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> Date: Tue, 28 Jan 2025 09:33:26 +0530 Subject: [PATCH 175/294] chore(ui): logout user on unsuccessful refresh attempt (#19542) (cherry picked from commit 33c107dea06ff53ebdae89eb2c7a8e3d26868960) --- .../ui/src/components/Auth/AuthProviders/AuthProvider.tsx | 4 ++++ openmetadata-ui/src/main/resources/ui/src/rest/LoginAPI.ts | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) 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 7ed56792f836..67f2c38587f6 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 @@ -548,6 +548,10 @@ export const AuthProvider = ({ resetUserDetails(true); } }); + }).catch((err) => { + resetUserDetails(true); + + return Promise.reject(err); }); } else { // If refresh is in progress, queue the request diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/LoginAPI.ts b/openmetadata-ui/src/main/resources/ui/src/rest/LoginAPI.ts index 206a12351aab..862ec7c88c2d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/LoginAPI.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/LoginAPI.ts @@ -23,7 +23,12 @@ interface RenewTokenResponse { } export const renewToken = async () => { - const data = await axiosClient.get(`${BASE_URL}/refresh`); + const data = await axiosClient.get( + `${BASE_URL}/refresh`, + // Need to invalidate other status codes + // which help is user to force logout + { validateStatus: (status) => status === 200 } + ); return data.data; }; From 147b8fc79a00db67d7727e8f019e3a0cf9677fb7 Mon Sep 17 00:00:00 2001 From: Ashish Gupta Date: Tue, 28 Jan 2025 09:34:11 +0530 Subject: [PATCH 176/294] remove unwanted spacing around the list in block editor (#19521) * remove unwanted spacing around the list in block editor * fix spacing issue --------- Co-authored-by: karanh37 (cherry picked from commit 13f3d19b5daab0b5875a7eb3560e0d19208c3ad0) --- .../ui/src/utils/BlockEditorUtils.test.ts | 47 ++++++++++++++++++- .../ui/src/utils/BlockEditorUtils.ts | 8 +++- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorUtils.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorUtils.test.ts index f04c638dea51..491d8a93a368 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorUtils.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorUtils.test.ts @@ -10,7 +10,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { getTextFromHtmlString } from './BlockEditorUtils'; +import { + getHtmlStringFromMarkdownString, + getTextFromHtmlString, +} from './BlockEditorUtils'; describe('getTextFromHtmlString', () => { it('should return empty string when input is undefined', () => { @@ -72,3 +75,45 @@ describe('getTextFromHtmlString', () => { expect(getTextFromHtmlString(input)).toBe(output); }); }); + +describe('getHtmlStringFromMarkdownString', () => { + it('should return the same string if input is already HTML', () => { + const input = '

Hello World

'; + + expect(getHtmlStringFromMarkdownString(input)).toBe(input); + }); + + it('should convert markdown to HTML', () => { + const input = 'Hello **World**'; + const expectedOutput = '

Hello World

'; + + expect(getHtmlStringFromMarkdownString(input)).toBe(expectedOutput); + }); + + it('should handle empty string', () => { + expect(getHtmlStringFromMarkdownString('')).toBe(''); + }); + + it('should preserve special characters in markdown', () => { + const input = 'Hello & World! @ #$%^'; + const expectedOutput = '

Hello & World! @ #$%^

'; + + expect(getHtmlStringFromMarkdownString(input)).toBe(expectedOutput); + }); + + it('should handle complex markdown structure', () => { + const input = ` + ## Demo Title + Small Subtitle. + - Item 1 + - Item 2 + `; + const expectedOutput = ` +
##DemoTitleSmallSubtitle.-Item1-Item2
+ `; + + expect(getHtmlStringFromMarkdownString(input).replace(/\s+/g, '')).toBe( + expectedOutput.replace(/\s+/g, '') + ); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorUtils.ts index 1019c519b1f2..091647a06e9b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/BlockEditorUtils.ts @@ -139,6 +139,12 @@ const _convertMarkdownStringToHtmlString = new Showdown.Converter({ ellipsis: false, }); +export const getHtmlStringFromMarkdownString = (content: string) => { + return isHTMLString(content) + ? content + : _convertMarkdownStringToHtmlString.makeHtml(content); +}; + /** * Set the content of the editor * @param editor The editor instance @@ -146,7 +152,7 @@ const _convertMarkdownStringToHtmlString = new Showdown.Converter({ */ export const setEditorContent = (editor: Editor, newContent: string) => { // Convert the markdown string to an HTML string - const htmlString = _convertMarkdownStringToHtmlString.makeHtml(newContent); + const htmlString = getHtmlStringFromMarkdownString(newContent); editor.commands.setContent(htmlString); From 206c7c29b624364800867a31029c0f1f7dc56e4f Mon Sep 17 00:00:00 2001 From: Mayur Singal <39544459+ulixius9@users.noreply.github.com> Date: Tue, 28 Jan 2025 10:29:34 +0530 Subject: [PATCH 177/294] MINOR: Optimise Snowflake SP Query (#19536) (cherry picked from commit 5118bd604859baa6a613a18b6f14fcb5d362601a) --- .../metadata/ingestion/source/database/snowflake/queries.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ingestion/src/metadata/ingestion/source/database/snowflake/queries.py b/ingestion/src/metadata/ingestion/source/database/snowflake/queries.py index 671e2ebfa8a4..2a97dc7d127b 100644 --- a/ingestion/src/metadata/ingestion/source/database/snowflake/queries.py +++ b/ingestion/src/metadata/ingestion/source/database/snowflake/queries.py @@ -337,6 +337,8 @@ FROM SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY SP WHERE QUERY_TYPE = 'CALL' AND START_TIME >= '{start_date}' + AND QUERY_TEXT <> '' + AND QUERY_TEXT IS NOT NULL ), Q_HISTORY AS ( SELECT @@ -354,6 +356,10 @@ AND QUERY_TEXT NOT LIKE '/* {{"app": "OpenMetadata", %%}} */%%' AND QUERY_TEXT NOT LIKE '/* {{"app": "dbt", %%}} */%%' AND START_TIME >= '{start_date}' + AND ( + QUERY_TYPE IN ('MERGE', 'UPDATE','CREATE_TABLE_AS_SELECT') + OR (QUERY_TYPE = 'INSERT' and query_text ILIKE '%%insert%%into%%select%%') + ) ) SELECT Q.QUERY_TYPE AS QUERY_TYPE, From dd8d5a5c3164694c6d6030d136286781e4ed1e01 Mon Sep 17 00:00:00 2001 From: Suman Maharana Date: Thu, 23 Jan 2025 22:18:25 +0530 Subject: [PATCH 178/294] Fix :postgres parse json schemas (#19487) --- .../source/database/postgres/metadata.py | 2 - .../source/database/postgres/queries.py | 85 ------------------- .../source/database/postgres/utils.py | 26 ------ .../source/database/sql_column_handler.py | 25 ------ 4 files changed, 138 deletions(-) diff --git a/ingestion/src/metadata/ingestion/source/database/postgres/metadata.py b/ingestion/src/metadata/ingestion/source/database/postgres/metadata.py index 845277a088c9..42a37bb49ebc 100644 --- a/ingestion/src/metadata/ingestion/source/database/postgres/metadata.py +++ b/ingestion/src/metadata/ingestion/source/database/postgres/metadata.py @@ -67,7 +67,6 @@ get_columns, get_etable_owner, get_foreign_keys, - get_json_fields_and_type, get_table_comment, get_table_owner, get_view_definition, @@ -147,7 +146,6 @@ Inspector.get_all_table_ddls = get_all_table_ddls Inspector.get_table_ddl = get_table_ddl Inspector.get_table_owner = get_etable_owner -Inspector.get_json_fields_and_type = get_json_fields_and_type PGDialect.get_foreign_keys = get_foreign_keys diff --git a/ingestion/src/metadata/ingestion/source/database/postgres/queries.py b/ingestion/src/metadata/ingestion/source/database/postgres/queries.py index ec50272846bc..03a66ede0a31 100644 --- a/ingestion/src/metadata/ingestion/source/database/postgres/queries.py +++ b/ingestion/src/metadata/ingestion/source/database/postgres/queries.py @@ -212,91 +212,6 @@ ORDER BY 1 """ -POSTGRES_GET_JSON_FIELDS = """ - WITH RECURSIVE json_hierarchy AS ( - SELECT - key AS path, - json_typeof(value) AS type, - value, - json_build_object() AS properties, - key AS title - FROM - {table_name} tbd, - LATERAL json_each({column_name}::json) - ), - build_hierarchy AS ( - SELECT - path, - type, - title, - CASE - WHEN type = 'object' THEN - json_build_object( - 'title', title, - 'type', 'object', - 'properties', ( - SELECT json_object_agg( - key, - json_build_object( - 'title', key, - 'type', json_typeof(value), - 'properties', ( - CASE - WHEN json_typeof(value) = 'object' THEN - ( - SELECT json_object_agg( - key, - json_build_object( - 'title', key, - 'type', json_typeof(value), - 'properties', - json_build_object() - ) - ) - FROM json_each(value::json) AS sub_key_value - ) - ELSE json_build_object() - END - ) - ) - ) - FROM json_each(value::json) AS key_value - ) - ) - WHEN type = 'array' THEN - json_build_object( - 'title', title, - 'type', 'array', - 'properties', json_build_object() - ) - ELSE - json_build_object( - 'title', title, - 'type', type - ) - END AS hierarchy - FROM - json_hierarchy - ), - aggregate_hierarchy AS ( - select - json_build_object( - 'title','{column_name}', - 'type','object', - 'properties', - json_object_agg( - path, - hierarchy - )) AS result - FROM - build_hierarchy - ) - SELECT - result - FROM - aggregate_hierarchy; -""" - POSTGRES_GET_STORED_PROCEDURES = """ SELECT proname AS procedure_name, nspname AS schema_name, diff --git a/ingestion/src/metadata/ingestion/source/database/postgres/utils.py b/ingestion/src/metadata/ingestion/source/database/postgres/utils.py index aed0322d3156..6222dfa493ae 100644 --- a/ingestion/src/metadata/ingestion/source/database/postgres/utils.py +++ b/ingestion/src/metadata/ingestion/source/database/postgres/utils.py @@ -13,7 +13,6 @@ """ Postgres SQLAlchemy util methods """ -import json import re import traceback from typing import Dict, Optional, Tuple @@ -24,18 +23,15 @@ from sqlalchemy.engine import reflection from sqlalchemy.sql import sqltypes -from metadata.generated.schema.entity.data.table import Column from metadata.ingestion.source.database.postgres.queries import ( POSTGRES_COL_IDENTITY, POSTGRES_FETCH_FK, - POSTGRES_GET_JSON_FIELDS, POSTGRES_GET_SERVER_VERSION, POSTGRES_SQL_COLUMNS, POSTGRES_TABLE_COMMENTS, POSTGRES_TABLE_OWNERS, POSTGRES_VIEW_DEFINITIONS, ) -from metadata.parsers.json_schema_parser import parse_json_schema from metadata.utils.logger import utils_logger from metadata.utils.sqlalchemy_utils import ( get_table_comment_wrapper, @@ -190,28 +186,6 @@ def get_table_comment( ) -@reflection.cache -def get_json_fields_and_type( - self, table_name, column_name, schema=None, **kw -): # pylint: disable=unused-argument - try: - query = POSTGRES_GET_JSON_FIELDS.format( - table_name=table_name, column_name=column_name - ) - cursor = self.engine.execute(query) - result = cursor.fetchone() - if result: - parsed_column = parse_json_schema(json.dumps(result[0]), Column) - if parsed_column: - return parsed_column[0].children - except Exception as err: - logger.warning( - f"Unable to parse the json fields for {table_name}.{column_name} - {err}" - ) - logger.debug(traceback.format_exc()) - return None - - @reflection.cache def get_columns( # pylint: disable=too-many-locals self, connection, table_name, schema=None, **kw diff --git a/ingestion/src/metadata/ingestion/source/database/sql_column_handler.py b/ingestion/src/metadata/ingestion/source/database/sql_column_handler.py index f309ef0d6973..9b65620d3402 100644 --- a/ingestion/src/metadata/ingestion/source/database/sql_column_handler.py +++ b/ingestion/src/metadata/ingestion/source/database/sql_column_handler.py @@ -202,26 +202,6 @@ def _process_complex_col_type(self, parsed_string: dict, column: dict) -> Column ] return Column(**parsed_string) - @calculate_execution_time() - def process_json_type_column_fields( # pylint: disable=too-many-locals - self, schema_name: str, table_name: str, column_name: str, inspector: Inspector - ) -> Optional[List[Column]]: - """ - Parse fields column with json data types - """ - try: - if hasattr(inspector, "get_json_fields_and_type"): - result = inspector.get_json_fields_and_type( - table_name, column_name, schema_name - ) - return result - - except NotImplementedError: - logger.debug( - "Cannot parse json fields for table column [{schema_name}.{table_name}.{col_name}]: NotImplementedError" - ) - return None - @calculate_execution_time() def get_columns_and_constraints( # pylint: disable=too-many-locals self, schema_name: str, table_name: str, db_name: str, inspector: Inspector @@ -302,11 +282,6 @@ def get_columns_and_constraints( # pylint: disable=too-many-locals ) col_data_length = 1 if col_data_length is None else col_data_length - if col_type == "JSON": - children = self.process_json_type_column_fields( - schema_name, table_name, column.get("name"), inspector - ) - om_column = Column( name=ColumnName( root=column["name"] From f71defe971a8861a40f8ec5a514051fd5a6c8a84 Mon Sep 17 00:00:00 2001 From: Ayush Shah Date: Fri, 17 Jan 2025 14:29:36 +0530 Subject: [PATCH 179/294] MINOR: Typo for datetime attribute (#19420) --- ingestion/src/metadata/clients/aws_client.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/ingestion/src/metadata/clients/aws_client.py b/ingestion/src/metadata/clients/aws_client.py index 751f64da61dc..c616f614b008 100644 --- a/ingestion/src/metadata/clients/aws_client.py +++ b/ingestion/src/metadata/clients/aws_client.py @@ -58,10 +58,12 @@ class AWSAssumeRoleCredentialResponse(BaseModel): class AWSAssumeRoleCredentialWrapper(BaseModel): - accessKeyId: str = Field(alias="access_key") - secretAccessKey: CustomSecretStr = Field(alias="secret_key") - sessionToken: Optional[str] = Field(default=None, alias="token") - expiryTime: Optional[str] = Field(alias="expiry_time") + accessKeyId: str = Field() + secretAccessKey: CustomSecretStr = Field() + sessionToken: Optional[str] = Field( + default=None, + ) + expiryTime: Optional[str] = Field() class Config: populate_by_name = True From f5ec56d7eba3acc03844558249871e4681609c3f Mon Sep 17 00:00:00 2001 From: ulixius9 Date: Tue, 28 Jan 2025 12:07:02 +0530 Subject: [PATCH 180/294] Revert "MINOR: Typo for datetime attribute (#19420)" This reverts commit f71defe971a8861a40f8ec5a514051fd5a6c8a84. --- ingestion/src/metadata/clients/aws_client.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/ingestion/src/metadata/clients/aws_client.py b/ingestion/src/metadata/clients/aws_client.py index c616f614b008..751f64da61dc 100644 --- a/ingestion/src/metadata/clients/aws_client.py +++ b/ingestion/src/metadata/clients/aws_client.py @@ -58,12 +58,10 @@ class AWSAssumeRoleCredentialResponse(BaseModel): class AWSAssumeRoleCredentialWrapper(BaseModel): - accessKeyId: str = Field() - secretAccessKey: CustomSecretStr = Field() - sessionToken: Optional[str] = Field( - default=None, - ) - expiryTime: Optional[str] = Field() + accessKeyId: str = Field(alias="access_key") + secretAccessKey: CustomSecretStr = Field(alias="secret_key") + sessionToken: Optional[str] = Field(default=None, alias="token") + expiryTime: Optional[str] = Field(alias="expiry_time") class Config: populate_by_name = True From 373e8a2d5986f4a57a969cffad5f57502ac8c698 Mon Sep 17 00:00:00 2001 From: Aniket Katkar Date: Tue, 28 Jan 2025 15:29:45 +0530 Subject: [PATCH 181/294] Increase the test timeout for advanced search playwright tests to avoid AUT flakiness (#19555) (cherry picked from commit cb9b5d4a620d7ca6c070ef2a80a0b643c0245f5d) --- .../ui/playwright/e2e/Features/AdvancedSearch.spec.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/AdvancedSearch.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/AdvancedSearch.spec.ts index 3dda28a884d6..9f94b424d7c5 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/AdvancedSearch.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/AdvancedSearch.spec.ts @@ -26,6 +26,11 @@ import { createNewPage, redirectToHomePage } from '../../utils/common'; import { addMultiOwner, assignTag, assignTier } from '../../utils/entity'; import { sidebarClick } from '../../utils/sidebar'; +test.describe.configure({ + // 4 minutes to avoid test timeout happening some times in AUTs + timeout: 4 * 60 * 1000, +}); + test.describe('Advanced Search', { tag: '@advanced-search' }, () => { // use the admin user to login test.use({ storageState: 'playwright/.auth/admin.json' }); @@ -42,8 +47,6 @@ test.describe('Advanced Search', { tag: '@advanced-search' }, () => { let searchCriteria: Record = {}; test.beforeAll('Setup pre-requests', async ({ browser }) => { - test.setTimeout(150000); - const { page, apiContext, afterAction } = await createNewPage(browser); await Promise.all([ user1.create(apiContext), From d68711d36f9e9b0103dc9e8e1f812d8612c3bad1 Mon Sep 17 00:00:00 2001 From: Mayur Singal <39544459+ulixius9@users.noreply.github.com> Date: Mon, 27 Jan 2025 18:15:58 +0530 Subject: [PATCH 182/294] Fix #19489: Optimise multithreading for lineage (#19524) --- .../src/metadata/ingestion/lineage/masker.py | 43 +++-- .../src/metadata/ingestion/lineage/parser.py | 45 ++--- .../metadata/ingestion/lineage/sql_lineage.py | 9 +- .../ingestion/ometa/mixins/es_mixin.py | 17 +- .../ingestion/ometa/mixins/lineage_mixin.py | 2 +- .../source/database/lineage_source.py | 167 +++++++++++------- .../database/stored_procedures_mixin.py | 59 ++++--- ingestion/src/metadata/utils/db_utils.py | 4 + ingestion/src/metadata/utils/logger.py | 11 +- ingestion/tests/unit/test_sql_lineage.py | 44 ++++- 10 files changed, 229 insertions(+), 172 deletions(-) diff --git a/ingestion/src/metadata/ingestion/lineage/masker.py b/ingestion/src/metadata/ingestion/lineage/masker.py index 49a5999e72c6..69aab2d7ba01 100644 --- a/ingestion/src/metadata/ingestion/lineage/masker.py +++ b/ingestion/src/metadata/ingestion/lineage/masker.py @@ -14,8 +14,7 @@ import traceback -import sqlparse -from sqlfluff.core import Linter +from collate_sqllineage.runner import SQLPARSE_DIALECT, LineageRunner from sqlparse.sql import Comparison from sqlparse.tokens import Literal, Number, String @@ -24,6 +23,7 @@ MASK_TOKEN = "?" +# pylint: disable=protected-access def get_logger(): # pylint: disable=import-outside-toplevel from metadata.utils.logger import utils_logger @@ -31,18 +31,14 @@ def get_logger(): return utils_logger() -def mask_literals_with_sqlparse(query: str): +def mask_literals_with_sqlparse(query: str, parser: LineageRunner): """ Mask literals in a query using sqlparse. """ logger = get_logger() try: - parsed = sqlparse.parse(query) # Parse the query - - if not parsed: - return query - parsed = parsed[0] + parsed = parser._parsed_result def mask_token(token): # Mask all literals: strings, numbers, or other literal values @@ -79,17 +75,16 @@ def mask_token(token): return query -def mask_literals_with_sqlfluff(query: str, dialect: str = Dialect.ANSI.value) -> str: +def mask_literals_with_sqlfluff(query: str, parser: LineageRunner) -> str: """ Mask literals in a query using SQLFluff. """ logger = get_logger() try: - # Initialize SQLFluff linter - linter = Linter(dialect=dialect) + if not parser._evaluated: + parser._eval() - # Parse the query - parsed = linter.parse_string(query) + parsed = parser._parsed_result def replace_literals(segment): """Recursively replace literals with placeholders.""" @@ -114,17 +109,21 @@ def replace_literals(segment): return query -def mask_query(query: str, dialect: str = Dialect.ANSI.value) -> str: +def mask_query( + query: str, dialect: str = Dialect.ANSI.value, parser: LineageRunner = None +) -> str: logger = get_logger() try: - sqlfluff_masked_query = mask_literals_with_sqlfluff(query, dialect) - sqlparse_masked_query = mask_literals_with_sqlparse(query) - # compare both masked queries and return the one with more masked tokens - if sqlfluff_masked_query.count(MASK_TOKEN) >= sqlparse_masked_query.count( - MASK_TOKEN - ): - return sqlfluff_masked_query - return sqlparse_masked_query + if not parser: + try: + parser = LineageRunner(query, dialect=dialect) + len(parser.source_tables) + except Exception: + parser = LineageRunner(query) + len(parser.source_tables) + if parser._dialect == SQLPARSE_DIALECT: + return mask_literals_with_sqlparse(query, parser) + return mask_literals_with_sqlfluff(query, parser) except Exception as exc: logger.debug(f"Failed to mask query with sqlfluff: {exc}") logger.debug(traceback.format_exc()) diff --git a/ingestion/src/metadata/ingestion/lineage/parser.py b/ingestion/src/metadata/ingestion/lineage/parser.py index f7fad5fe812a..93bae226d74f 100644 --- a/ingestion/src/metadata/ingestion/lineage/parser.py +++ b/ingestion/src/metadata/ingestion/lineage/parser.py @@ -71,12 +71,13 @@ def __init__( self.query_parsing_success = True self.query_parsing_failure_reason = None self.dialect = dialect - self._masked_query = mask_query(self.query, dialect.value) + self.masked_query = None self._clean_query = self.clean_raw_query(query) - self._masked_clean_query = mask_query(self._clean_query, dialect.value) self.parser = self._evaluate_best_parser( self._clean_query, dialect=dialect, timeout_seconds=timeout_seconds ) + if self.masked_query is None: + self.masked_query = mask_query(self._clean_query, parser=self.parser) @cached_property def involved_tables(self) -> Optional[List[Table]]: @@ -95,7 +96,7 @@ def involved_tables(self) -> Optional[List[Table]]: except SQLLineageException as exc: logger.debug(traceback.format_exc()) logger.warning( - f"Cannot extract source table information from query [{self._masked_query}]: {exc}" + f"Cannot extract source table information from query [{self.masked_query or self.query}]: {exc}" ) return None @@ -334,12 +335,10 @@ def stateful_add_joins_from_statement( ) if not table_left or not table_right: - logger.warning( - f"Can't extract table names when parsing JOIN information from {comparison}" - ) logger.debug( - f"Query: {mask_query(sql_statement, self.dialect.value)}" + f"Can't extract table names when parsing JOIN information from {comparison}" ) + logger.debug(f"Query: {self.masked_query}") continue left_table_column = TableColumn(table=table_left, column=column_left) @@ -422,10 +421,9 @@ def get_sqlfluff_lineage_runner(qry: str, dlct: str) -> LineageRunner: lr_dialect.get_column_lineage() return lr_dialect - sqlfluff_count = 0 try: lr_sqlfluff = get_sqlfluff_lineage_runner(query, dialect.value) - sqlfluff_count = len(lr_sqlfluff.get_column_lineage()) + len( + _ = len(lr_sqlfluff.get_column_lineage()) + len( set(lr_sqlfluff.source_tables).union( set(lr_sqlfluff.target_tables).union( set(lr_sqlfluff.intermediate_tables) @@ -438,23 +436,20 @@ def get_sqlfluff_lineage_runner(qry: str, dlct: str) -> LineageRunner: f"Lineage with SqlFluff failed for the [{dialect.value}]. " f"Parser has been running for more than {timeout_seconds} seconds." ) - logger.debug( - f"{self.query_parsing_failure_reason}] query: [{self._masked_clean_query}]" - ) lr_sqlfluff = None except Exception: self.query_parsing_success = False self.query_parsing_failure_reason = ( f"Lineage with SqlFluff failed for the [{dialect.value}]" ) - logger.debug( - f"{self.query_parsing_failure_reason} query: [{self._masked_clean_query}]" - ) lr_sqlfluff = None + if lr_sqlfluff: + return lr_sqlfluff + lr_sqlparser = LineageRunner(query) try: - sqlparser_count = len(lr_sqlparser.get_column_lineage()) + len( + _ = len(lr_sqlparser.get_column_lineage()) + len( set(lr_sqlparser.source_tables).union( set(lr_sqlparser.target_tables).union( set(lr_sqlparser.intermediate_tables) @@ -463,21 +458,13 @@ def get_sqlfluff_lineage_runner(qry: str, dlct: str) -> LineageRunner: ) except Exception: # if both runner have failed we return the usual one + logger.debug(f"Failed to parse query with sqlparse & sqlfluff: {query}") return lr_sqlfluff if lr_sqlfluff else lr_sqlparser - if lr_sqlfluff: - # if sqlparser retrieve more lineage info that sqlfluff - if sqlparser_count > sqlfluff_count: - self.query_parsing_success = False - self.query_parsing_failure_reason = ( - "Lineage computed with SqlFluff did not perform as expected " - f"for the [{dialect.value}]" - ) - logger.debug( - f"{self.query_parsing_failure_reason} query: [{self._masked_clean_query}]" - ) - return lr_sqlparser - return lr_sqlfluff + self.masked_query = mask_query(self._clean_query, parser=lr_sqlparser) + logger.debug( + f"Using sqlparse for lineage parsing for query: {self.masked_query}" + ) return lr_sqlparser @staticmethod diff --git a/ingestion/src/metadata/ingestion/lineage/sql_lineage.py b/ingestion/src/metadata/ingestion/lineage/sql_lineage.py index c8954fad6f0f..f2876197ac96 100644 --- a/ingestion/src/metadata/ingestion/lineage/sql_lineage.py +++ b/ingestion/src/metadata/ingestion/lineage/sql_lineage.py @@ -37,7 +37,6 @@ from metadata.generated.schema.type.entityLineage import Source as LineageSource from metadata.generated.schema.type.entityReference import EntityReference from metadata.ingestion.api.models import Either -from metadata.ingestion.lineage.masker import mask_query from metadata.ingestion.lineage.models import ( Dialect, QueryParsingError, @@ -614,11 +613,11 @@ def get_lineage_by_query( """ column_lineage = {} query_parsing_failures = QueryParsingFailures() - masked_query = mask_query(query, dialect.value) try: - logger.debug(f"Running lineage with query: {masked_query}") lineage_parser = LineageParser(query, dialect, timeout_seconds=timeout_seconds) + masked_query = lineage_parser.masked_query or query + logger.debug(f"Running lineage with query: {masked_query}") raw_column_lineage = lineage_parser.column_lineage column_lineage.update(populate_column_lineage_map(raw_column_lineage)) @@ -715,11 +714,11 @@ def get_lineage_via_table_entity( """Get lineage from table entity""" column_lineage = {} query_parsing_failures = QueryParsingFailures() - masked_query = mask_query(query, dialect.value) try: - logger.debug(f"Getting lineage via table entity using query: {masked_query}") lineage_parser = LineageParser(query, dialect, timeout_seconds=timeout_seconds) + masked_query = lineage_parser.masked_query or query + logger.debug(f"Getting lineage via table entity using query: {masked_query}") to_table_name = table_entity.name.root for from_table_name in lineage_parser.source_tables: diff --git a/ingestion/src/metadata/ingestion/ometa/mixins/es_mixin.py b/ingestion/src/metadata/ingestion/ometa/mixins/es_mixin.py index 27d4e6cef9e5..fd363d9bcda7 100644 --- a/ingestion/src/metadata/ingestion/ometa/mixins/es_mixin.py +++ b/ingestion/src/metadata/ingestion/ometa/mixins/es_mixin.py @@ -344,7 +344,7 @@ def _paginate_es_internal( # Get next page last_hit = response.hits.hits[-1] if response.hits.hits else None if not last_hit or not last_hit.sort: - logger.info("No more pages to fetch") + logger.debug("No more pages to fetch") break after = ",".join(last_hit.sort) @@ -420,10 +420,11 @@ def yield_es_view_def( _, database_name, schema_name, table_name = fqn.split( hit.source["fullyQualifiedName"] ) - yield TableView( - view_definition=hit.source["schemaDefinition"], - service_name=service_name, - db_name=database_name, - schema_name=schema_name, - table_name=table_name, - ) + if hit.source.get("schemaDefinition"): + yield TableView( + view_definition=hit.source["schemaDefinition"], + service_name=service_name, + db_name=database_name, + schema_name=schema_name, + table_name=table_name, + ) diff --git a/ingestion/src/metadata/ingestion/ometa/mixins/lineage_mixin.py b/ingestion/src/metadata/ingestion/ometa/mixins/lineage_mixin.py index 45769f5935c4..5adc2c18296f 100644 --- a/ingestion/src/metadata/ingestion/ometa/mixins/lineage_mixin.py +++ b/ingestion/src/metadata/ingestion/ometa/mixins/lineage_mixin.py @@ -63,7 +63,7 @@ def _merge_column_lineage( ) for column in updated or []: if not isinstance(column, dict): - data = column.dict() + data = column.model_dump() else: data = column if data.get("toColumn") and data.get("fromColumns"): diff --git a/ingestion/src/metadata/ingestion/source/database/lineage_source.py b/ingestion/src/metadata/ingestion/source/database/lineage_source.py index 581467faf779..cdf68491dd65 100644 --- a/ingestion/src/metadata/ingestion/source/database/lineage_source.py +++ b/ingestion/src/metadata/ingestion/source/database/lineage_source.py @@ -13,11 +13,12 @@ """ import csv import os +import time import traceback from abc import ABC -from concurrent.futures import ThreadPoolExecutor, as_completed +from concurrent.futures import ThreadPoolExecutor from functools import partial -from typing import Callable, Iterable, Iterator, Union +from typing import Any, Callable, Iterable, Iterator, List, Union from metadata.generated.schema.api.data.createQuery import CreateQueryRequest from metadata.generated.schema.api.lineage.addLineage import AddLineageRequest @@ -27,6 +28,7 @@ from metadata.ingestion.lineage.models import ConnectionTypeDialectMapper, Dialect from metadata.ingestion.lineage.sql_lineage import get_lineage_by_query from metadata.ingestion.models.ometa_lineage import OMetaLineageRequest +from metadata.ingestion.models.topology import Queue from metadata.ingestion.source.database.query_parser_source import QueryParserSource from metadata.ingestion.source.models import TableView from metadata.utils import fqn @@ -36,6 +38,9 @@ logger = ingestion_logger() +CHUNK_SIZE = 200 + + class LineageSource(QueryParserSource, ABC): """ This is the base source to handle Lineage-only ingestion. @@ -96,27 +101,57 @@ def get_table_query(self) -> Iterator[TableQuery]: ) yield from self.yield_table_query() - def generate_lineage_in_thread(self, producer_fn: Callable, processor_fn: Callable): - with ThreadPoolExecutor(max_workers=self.source_config.threads) as executor: - futures = [] + def generate_lineage_in_thread( + self, + producer_fn: Callable[[], Iterable[Any]], + processor_fn: Callable[[Any], Iterable[Any]], + chunk_size: int = CHUNK_SIZE, + ): + """ + Optimized multithreaded lineage generation with improved error handling and performance. - for produced_input in producer_fn(): - futures.append(executor.submit(processor_fn, produced_input)) + Args: + producer_fn: Function that yields input items + processor_fn: Function to process each input item + chunk_size: Optional batching to reduce thread creation overhead + """ - # Handle remaining futures after the loop - for future in as_completed( - futures, timeout=self.source_config.parsingTimeoutLimit - ): - try: - results = future.result( - timeout=self.source_config.parsingTimeoutLimit - ) - yield from results - except Exception as exc: - logger.debug(traceback.format_exc()) - logger.warning( - f"Error processing result for {produced_input}: {exc}" - ) + def chunk_generator(): + temp_chunk = [] + for chunk in producer_fn(): + temp_chunk.append(chunk) + if len(temp_chunk) >= chunk_size: + yield temp_chunk + temp_chunk = [] + + if temp_chunk: + yield temp_chunk + + thread_pool = ThreadPoolExecutor(max_workers=self.source_config.threads) + queue = Queue() + + futures = [ + thread_pool.submit( + processor_fn, + chunk, + queue, + ) + for chunk in chunk_generator() + ] + while True: + if queue.has_tasks(): + yield from queue.process() + + else: + if not futures: + break + + for i, future in enumerate(futures): + if future.done(): + future.result() + futures.pop(i) + + time.sleep(0.01) def yield_table_query(self) -> Iterator[TableQuery]: """ @@ -158,33 +193,38 @@ def _query_already_processed(self, table_query: TableQuery) -> bool: return fqn.get_query_checksum(table_query.query) in checksums or {} def query_lineage_generator( - self, table_query: TableQuery + self, table_queries: List[TableQuery], queue: Queue ) -> Iterable[Either[Union[AddLineageRequest, CreateQueryRequest]]]: - if not self._query_already_processed(table_query): - lineages: Iterable[Either[AddLineageRequest]] = get_lineage_by_query( - self.metadata, - query=table_query.query, - service_name=table_query.serviceName, - database_name=table_query.databaseName, - schema_name=table_query.databaseSchema, - dialect=self.dialect, - timeout_seconds=self.source_config.parsingTimeoutLimit, - ) + for table_query in table_queries or []: + if not self._query_already_processed(table_query): + lineages: Iterable[Either[AddLineageRequest]] = get_lineage_by_query( + self.metadata, + query=table_query.query, + service_name=table_query.serviceName, + database_name=table_query.databaseName, + schema_name=table_query.databaseSchema, + dialect=self.dialect, + timeout_seconds=self.source_config.parsingTimeoutLimit, + ) - for lineage_request in lineages or []: - yield lineage_request + for lineage_request in lineages or []: + queue.put(lineage_request) - # If we identified lineage properly, ingest the original query - if lineage_request.right: - yield Either( - right=CreateQueryRequest( - query=SqlQuery(table_query.query), - query_type=table_query.query_type, - duration=table_query.duration, - processedLineage=True, - service=FullyQualifiedEntityName(self.config.serviceName), + # If we identified lineage properly, ingest the original query + if lineage_request.right: + queue.put( + Either( + right=CreateQueryRequest( + query=SqlQuery(table_query.query), + query_type=table_query.query_type, + duration=table_query.duration, + processedLineage=True, + service=FullyQualifiedEntityName( + self.config.serviceName + ), + ) + ) ) - ) def yield_query_lineage( self, @@ -197,28 +237,33 @@ def yield_query_lineage( self.dialect = ConnectionTypeDialectMapper.dialect_of(connection_type) producer_fn = self.get_table_query processor_fn = self.query_lineage_generator - yield from self.generate_lineage_in_thread(producer_fn, processor_fn) + yield from self.generate_lineage_in_thread( + producer_fn, processor_fn, CHUNK_SIZE + ) def view_lineage_generator( - self, view: TableView + self, views: List[TableView], queue: Queue ) -> Iterable[Either[AddLineageRequest]]: try: - for lineage in get_view_lineage( - view=view, - metadata=self.metadata, - service_name=self.config.serviceName, - connection_type=self.service_connection.type.value, - timeout_seconds=self.source_config.parsingTimeoutLimit, - ): - if lineage.right is not None: - yield Either( - right=OMetaLineageRequest( - lineage_request=lineage.right, - override_lineage=self.source_config.overrideViewLineage, + for view in views: + for lineage in get_view_lineage( + view=view, + metadata=self.metadata, + service_name=self.config.serviceName, + connection_type=self.service_connection.type.value, + timeout_seconds=self.source_config.parsingTimeoutLimit, + ): + if lineage.right is not None: + queue.put( + Either( + right=OMetaLineageRequest( + lineage_request=lineage.right, + override_lineage=self.source_config.overrideViewLineage, + ) + ) ) - ) - else: - yield lineage + else: + queue.put(lineage) except Exception as exc: logger.debug(traceback.format_exc()) logger.warning(f"Error processing view {view}: {exc}") diff --git a/ingestion/src/metadata/ingestion/source/database/stored_procedures_mixin.py b/ingestion/src/metadata/ingestion/source/database/stored_procedures_mixin.py index 5473f8b17009..f7190c696811 100644 --- a/ingestion/src/metadata/ingestion/source/database/stored_procedures_mixin.py +++ b/ingestion/src/metadata/ingestion/source/database/stored_procedures_mixin.py @@ -38,10 +38,11 @@ from metadata.ingestion.api.status import Status from metadata.ingestion.lineage.models import ConnectionTypeDialectMapper from metadata.ingestion.lineage.sql_lineage import get_lineage_by_query +from metadata.ingestion.models.topology import Queue from metadata.ingestion.ometa.ometa_api import OpenMetadata from metadata.utils.logger import ingestion_logger from metadata.utils.stored_procedures import get_procedure_name_from_call -from metadata.utils.time_utils import convert_timestamp_to_milliseconds +from metadata.utils.time_utils import datetime_to_timestamp logger = ingestion_logger() @@ -176,8 +177,6 @@ def _yield_procedure_lineage( timeout_seconds=self.source_config.parsingTimeoutLimit, lineage_source=LineageSource.QueryLineage, ): - print("&& " * 100) - print(either_lineage) if ( either_lineage.left is None and either_lineage.right.edge.lineageDetails @@ -200,8 +199,8 @@ def yield_procedure_query( query_type=query_by_procedure.query_type, duration=query_by_procedure.query_duration, queryDate=Timestamp( - root=convert_timestamp_to_milliseconds( - int(query_by_procedure.query_start_time.timestamp()) + root=datetime_to_timestamp( + query_by_procedure.query_start_time, True ) ), triggeredBy=EntityReference( @@ -214,29 +213,31 @@ def yield_procedure_query( ) def procedure_lineage_processor( - self, procedure_and_query: ProcedureAndQuery + self, procedure_and_queries: List[ProcedureAndQuery], queue: Queue ) -> Iterable[Either[Union[AddLineageRequest, CreateQueryRequest]]]: - - try: - yield from self._yield_procedure_lineage( - query_by_procedure=procedure_and_query.query_by_procedure, - procedure=procedure_and_query.procedure, - ) - except Exception as exc: - logger.debug(traceback.format_exc()) - logger.warning( - f"Could not get lineage for store procedure '{procedure_and_query.procedure.fullyQualifiedName}' due to [{exc}]." - ) - try: - yield from self.yield_procedure_query( - query_by_procedure=procedure_and_query.query_by_procedure, - procedure=procedure_and_query.procedure, - ) - except Exception as exc: - logger.debug(traceback.format_exc()) - logger.warning( - f"Could not get query for store procedure '{procedure_and_query.procedure.fullyQualifiedName}' due to [{exc}]." - ) + for procedure_and_query in procedure_and_queries: + try: + for lineage in self._yield_procedure_lineage( + query_by_procedure=procedure_and_query.query_by_procedure, + procedure=procedure_and_query.procedure, + ): + queue.put(lineage) + except Exception as exc: + logger.debug(traceback.format_exc()) + logger.warning( + f"Could not get lineage for store procedure '{procedure_and_query.procedure.fullyQualifiedName}' due to [{exc}]." + ) + try: + for lineage in self.yield_procedure_query( + query_by_procedure=procedure_and_query.query_by_procedure, + procedure=procedure_and_query.procedure, + ): + queue.put(lineage) + except Exception as exc: + logger.debug(traceback.format_exc()) + logger.warning( + f"Could not get query for store procedure '{procedure_and_query.procedure.fullyQualifiedName}' due to [{exc}]." + ) def procedure_lineage_generator(self) -> Iterable[ProcedureAndQuery]: query = { @@ -256,7 +257,9 @@ def procedure_lineage_generator(self) -> Iterable[ProcedureAndQuery]: queries_dict = self.get_stored_procedure_queries_dict() # Then for each procedure, iterate over all its queries for procedure in ( - self.metadata.paginate_es(entity=StoredProcedure, query_filter=query_filter) + self.metadata.paginate_es( + entity=StoredProcedure, query_filter=query_filter, size=10 + ) or [] ): if procedure: diff --git a/ingestion/src/metadata/utils/db_utils.py b/ingestion/src/metadata/utils/db_utils.py index ef3a7c8d6088..afd1e4832940 100644 --- a/ingestion/src/metadata/utils/db_utils.py +++ b/ingestion/src/metadata/utils/db_utils.py @@ -69,6 +69,10 @@ def get_view_lineage( fqn=table_fqn, ) + if not view_definition: + logger.warning(f"View definition for view {table_fqn} not available") + return + try: connection_type = str(connection_type) dialect = ConnectionTypeDialectMapper.dialect_of(connection_type) diff --git a/ingestion/src/metadata/utils/logger.py b/ingestion/src/metadata/utils/logger.py index f437bb4d3fcd..08d648d8c2fd 100644 --- a/ingestion/src/metadata/utils/logger.py +++ b/ingestion/src/metadata/utils/logger.py @@ -28,7 +28,6 @@ from metadata.generated.schema.type.queryParserData import QueryParserData from metadata.generated.schema.type.tableQuery import TableQueries from metadata.ingestion.api.models import Entity -from metadata.ingestion.lineage.masker import mask_query from metadata.ingestion.models.delete_entity import DeleteEntity from metadata.ingestion.models.life_cycle import OMetaLifeCycleData from metadata.ingestion.models.ometa_classification import OMetaTagAndClassification @@ -284,19 +283,13 @@ def _(record: PatchRequest) -> str: @get_log_name.register def _(record: TableQueries) -> str: """Get the log of the TableQuery""" - queries = "\n------\n".join( - mask_query(query.query, query.dialect) for query in record.queries - ) - return f"Table Queries [{queries}]" + return f"Table Queries [{len(record.queries)}]" @get_log_name.register def _(record: QueryParserData) -> str: """Get the log of the ParsedData""" - queries = "\n------\n".join( - mask_query(query.sql, query.dialect) for query in record.parsedData - ) - return f"Usage ParsedData [{queries}]" + return f"Usage ParsedData [{len(record.parsedData)}]" def redacted_config(config: Dict[str, Union[str, dict]]) -> Dict[str, Union[str, dict]]: diff --git a/ingestion/tests/unit/test_sql_lineage.py b/ingestion/tests/unit/test_sql_lineage.py index 4199cf84a5f8..ffe07a0557a1 100644 --- a/ingestion/tests/unit/test_sql_lineage.py +++ b/ingestion/tests/unit/test_sql_lineage.py @@ -229,14 +229,39 @@ def test_table_name_from_query(self): def test_query_masker(self): query_list = [ - """SELECT * FROM user WHERE id=1234 AND name='Alice' AND birthdate=DATE '2023-01-01';""", - """insert into user values ('mayur',123,'my random address 1'), ('mayur',123,'my random address 1');""", - """SELECT * FROM user WHERE address = '5th street' and name = 'john';""", - """INSERT INTO user VALUE ('John', '19', '5TH Street');""", - """SELECT CASE address WHEN '5th Street' THEN 'CEO' ELSE 'Unknown' END AS person FROM user;""", - """with test as (SELECT CASE address WHEN '5th Street' THEN 'CEO' ELSE 'Unknown' END AS person FROM user) select * from test;""", - """select * from (select * from (SELECT CASE address WHEN '5th Street' THEN 'CEO' ELSE 'Unknown' END AS person FROM user));""", - """select * from users where id > 2 and name <> 'pere';""", + ( + """SELECT * FROM user WHERE id=1234 AND name='Alice' AND birthdate=DATE '2023-01-01';""", + Dialect.MYSQL.value, + ), + ( + """insert into user values ('mayur',123,'my random address 1'), ('mayur',123,'my random address 1');""", + Dialect.ANSI.value, + ), + ( + """SELECT * FROM user WHERE address = '5th street' and name = 'john';""", + Dialect.ANSI.value, + ), + ( + """INSERT INTO user VALUE ('John', '19', '5TH Street');""", + Dialect.ANSI.value, + ), + ( + """SELECT CASE address WHEN '5th Street' THEN 'CEO' ELSE 'Unknown' END AS person FROM user;""", + Dialect.ANSI.value, + ), + ( + """with test as (SELECT CASE address WHEN '5th Street' THEN 'CEO' ELSE 'Unknown' END AS person FROM user) select * from test;""", + Dialect.ANSI.value, + ), + ( + """select * from (select * from (SELECT CASE address WHEN '5th Street' THEN 'CEO' ELSE 'Unknown' END AS person FROM user));""", + Dialect.ANSI.value, + ), + ( + """select * from users where id > 2 and name <> 'pere';""", + Dialect.ANSI.value, + ), + ("""select * from users where id > 2 and name <> 'pere';""", "random"), ] expected_query_list = [ @@ -248,7 +273,8 @@ def test_query_masker(self): """with test as (SELECT CASE address WHEN ? THEN ? ELSE ? END AS person FROM user) select * from test;""", """select * from (select * from (SELECT CASE address WHEN ? THEN ? ELSE ? END AS person FROM user));""", """select * from users where id > ? and name <> ?;""", + """select * from users where id > ? and name <> ?;""", ] for i, query in enumerate(query_list): - self.assertEqual(mask_query(query), expected_query_list[i]) + self.assertEqual(mask_query(query[0], query[1]), expected_query_list[i]) From 89fbf8fcdcfd72208592cd8f2c65b2b823a5dc54 Mon Sep 17 00:00:00 2001 From: Mayur Singal <39544459+ulixius9@users.noreply.github.com> Date: Thu, 16 Jan 2025 11:33:11 +0530 Subject: [PATCH 183/294] Fix #19317: fetch Stored Procedures from account usage (#19375) --- .../source/database/snowflake/queries.py | 4 ++-- .../connectors/database/snowflake/index.md | 23 +++++++----------- .../connectors/database/snowflake/index.md | 24 +++++++------------ 3 files changed, 18 insertions(+), 33 deletions(-) diff --git a/ingestion/src/metadata/ingestion/source/database/snowflake/queries.py b/ingestion/src/metadata/ingestion/source/database/snowflake/queries.py index 2a97dc7d127b..d94f2d209839 100644 --- a/ingestion/src/metadata/ingestion/source/database/snowflake/queries.py +++ b/ingestion/src/metadata/ingestion/source/database/snowflake/queries.py @@ -296,7 +296,7 @@ ARGUMENT_SIGNATURE AS signature, COMMENT as comment, 'StoredProcedure' as procedure_type -FROM INFORMATION_SCHEMA.PROCEDURES +FROM SNOWFLAKE.ACCOUNT_USAGE.PROCEDURES WHERE PROCEDURE_CATALOG = '{database_name}' AND PROCEDURE_SCHEMA = '{schema_name}' """ @@ -312,7 +312,7 @@ ARGUMENT_SIGNATURE AS signature, COMMENT as comment, 'UDF' as procedure_type -FROM INFORMATION_SCHEMA.FUNCTIONS +FROM SNOWFLAKE.ACCOUNT_USAGE.FUNCTIONS WHERE FUNCTION_CATALOG = '{database_name}' AND FUNCTION_SCHEMA = '{schema_name}' """ diff --git a/openmetadata-docs/content/v1.4.x/connectors/database/snowflake/index.md b/openmetadata-docs/content/v1.4.x/connectors/database/snowflake/index.md index 92f5e3369403..f026a4363adc 100644 --- a/openmetadata-docs/content/v1.4.x/connectors/database/snowflake/index.md +++ b/openmetadata-docs/content/v1.4.x/connectors/database/snowflake/index.md @@ -59,13 +59,18 @@ GRANT SELECT ON ALL TABLES IN SCHEMA TEST_SCHEMA TO ROLE NEW_ROLE; GRANT SELECT ON ALL EXTERNAL TABLES IN SCHEMA TEST_SCHEMA TO ROLE NEW_ROLE; GRANT SELECT ON ALL VIEWS IN SCHEMA TEST_SCHEMA TO ROLE NEW_ROLE; GRANT SELECT ON ALL DYNAMIC TABLES IN SCHEMA TEST_SCHEMA TO ROLE NEW_ROLE; + +-- Grant IMPORTED PRIVILEGES on all Schemas of SNOWFLAKE DB to New Role, +-- optional but required for usage, lineage and stored procedure ingestion +GRANT IMPORTED PRIVILEGES ON ALL SCHEMAS IN DATABASE SNOWFLAKE TO ROLE NEW_ROLE; ``` {% note %} If running any of: - Incremental Extraction - Ingesting Tags - - Usage Workflow + - Ingesting Stored Procedures + - Lineage & Usage Workflow The following Grant is needed {% /note %} @@ -74,24 +79,12 @@ The following Grant is needed - **Ingesting Tags**: Openmetadata fetches the information by querying `snowflake.account_usage.tag_references`. -- **Usage Workflow**: Openmetadata fetches the query logs by querying `snowflake.account_usage.query_history` table. For this the snowflake user should be granted the `ACCOUNTADMIN` role or a role granted IMPORTED PRIVILEGES on the database `SNOWFLAKE`. - -In order to be able to query those tables, the user should be either granted the `ACCOUNTADMIN` role or a role with the `IMPORTED PRIVILEGES` grant on the `SNOWFLAKE` database: - -```sql --- Grant IMPORTED PRIVILEGES on all Schemas of SNOWFLAKE DB to New Role -GRANT IMPORTED PRIVILEGES ON ALL SCHEMAS IN DATABASE SNOWFLAKE TO ROLE NEW_ROLE; -``` +- **Lineage & Usage Workflow**: Openmetadata fetches the query logs by querying `snowflake.account_usage.query_history` table. For this the snowflake user should be granted the `ACCOUNTADMIN` role or a role granted IMPORTED PRIVILEGES on the database `SNOWFLAKE`. You can find more information about the `account_usage` schema [here](https://docs.snowflake.com/en/sql-reference/account-usage). -Regarding Stored Procedures: -1. Snowflake only allows the grant of `USAGE` or `OWNERSHIP` -2. A user can only see the definition of the procedure in 2 situations: - 1. If it has the `OWNERSHIP` grant, - 2. If it has the `USAGE` grant and the procedure is created with `EXECUTE AS CALLER`. +- **Ingesting Stored Procedures**: Openmetadata fetches the information by querying `snowflake.account_usage.procedures` & `snowflake.account_usage.functions`. -Make sure to add the `GRANT ON PROCEDURE () to NEW_ROLE`, e.g., `GRANT USAGE ON PROCEDURE CLEAN_DATA(varchar, varchar) to NEW_ROLE`. ## Metadata Ingestion diff --git a/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/database/snowflake/index.md b/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/database/snowflake/index.md index a4c5d452b58d..c6f7ab738035 100644 --- a/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/database/snowflake/index.md +++ b/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/database/snowflake/index.md @@ -59,13 +59,18 @@ GRANT SELECT ON ALL TABLES IN SCHEMA TEST_SCHEMA TO ROLE NEW_ROLE; GRANT SELECT ON ALL EXTERNAL TABLES IN SCHEMA TEST_SCHEMA TO ROLE NEW_ROLE; GRANT SELECT ON ALL VIEWS IN SCHEMA TEST_SCHEMA TO ROLE NEW_ROLE; GRANT SELECT ON ALL DYNAMIC TABLES IN SCHEMA TEST_SCHEMA TO ROLE NEW_ROLE; + +-- Grant IMPORTED PRIVILEGES on all Schemas of SNOWFLAKE DB to New Role, +-- optional but required for usage, lineage and stored procedure ingestion +GRANT IMPORTED PRIVILEGES ON ALL SCHEMAS IN DATABASE SNOWFLAKE TO ROLE NEW_ROLE; ``` {% note %} If running any of: - Incremental Extraction - Ingesting Tags - - Usage Workflow + - Ingesting Stored Procedures + - Lineage & Usage Workflow The following Grant is needed {% /note %} @@ -74,24 +79,11 @@ The following Grant is needed - **Ingesting Tags**: Openmetadata fetches the information by querying `snowflake.account_usage.tag_references`. -- **Usage Workflow**: Openmetadata fetches the query logs by querying `snowflake.account_usage.query_history` table. For this the snowflake user should be granted the `ACCOUNTADMIN` role or a role granted IMPORTED PRIVILEGES on the database `SNOWFLAKE`. - -In order to be able to query those tables, the user should be either granted the `ACCOUNTADMIN` role or a role with the `IMPORTED PRIVILEGES` grant on the `SNOWFLAKE` database: - -```sql --- Grant IMPORTED PRIVILEGES on all Schemas of SNOWFLAKE DB to New Role -GRANT IMPORTED PRIVILEGES ON ALL SCHEMAS IN DATABASE SNOWFLAKE TO ROLE NEW_ROLE; -``` +- **Lineage & Usage Workflow**: Openmetadata fetches the query logs by querying `snowflake.account_usage.query_history` table. For this the snowflake user should be granted the `ACCOUNTADMIN` role or a role granted IMPORTED PRIVILEGES on the database `SNOWFLAKE`. You can find more information about the `account_usage` schema [here](https://docs.snowflake.com/en/sql-reference/account-usage). -Regarding Stored Procedures: -1. Snowflake only allows the grant of `USAGE` or `OWNERSHIP` -2. A user can only see the definition of the procedure in 2 situations: - 1. If it has the `OWNERSHIP` grant, - 2. If it has the `USAGE` grant and the procedure is created with `EXECUTE AS CALLER`. - -Make sure to add the `GRANT ON PROCEDURE () to NEW_ROLE`, e.g., `GRANT USAGE ON PROCEDURE CLEAN_DATA(varchar, varchar) to NEW_ROLE`. +- **Ingesting Stored Procedures**: Openmetadata fetches the information by querying `snowflake.account_usage.procedures` & `snowflake.account_usage.functions`. ## Metadata Ingestion From 44c2cdb199b7c65a296b3d6e797d95e07bed700f Mon Sep 17 00:00:00 2001 From: Mayur Singal <39544459+ulixius9@users.noreply.github.com> Date: Thu, 16 Jan 2025 11:12:02 +0530 Subject: [PATCH 184/294] Fix #19317: Add MaterializedView & DynamicTable for lineage computation (#19373) --- .../src/metadata/ingestion/ometa/mixins/es_mixin.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/ingestion/src/metadata/ingestion/ometa/mixins/es_mixin.py b/ingestion/src/metadata/ingestion/ometa/mixins/es_mixin.py index fd363d9bcda7..fbc315ffdafa 100644 --- a/ingestion/src/metadata/ingestion/ometa/mixins/es_mixin.py +++ b/ingestion/src/metadata/ingestion/ometa/mixins/es_mixin.py @@ -403,7 +403,16 @@ def yield_es_view_def( "bool": { "must": [ {"term": {"service.name.keyword": service_name}}, - {"term": {"tableType": TableType.View.value}}, + { + "term": { + "tableType": [ + TableType.View.value, + TableType.MaterializedView.value, + TableType.SecureView.value, + TableType.Dynamic.value, + ] + } + }, {"term": {"deleted": False}}, {"exists": {"field": "schemaDefinition"}}, ] From d75506d82c14699a45c89dd9baae08dd7f5cfca7 Mon Sep 17 00:00:00 2001 From: Mayur Singal <39544459+ulixius9@users.noreply.github.com> Date: Thu, 16 Jan 2025 11:11:39 +0530 Subject: [PATCH 185/294] Fix #19370: Fix Mariadb Dialect (#19371) --- ingestion/src/metadata/ingestion/lineage/models.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ingestion/src/metadata/ingestion/lineage/models.py b/ingestion/src/metadata/ingestion/lineage/models.py index 6e0e4385854c..a3f50208c5f7 100644 --- a/ingestion/src/metadata/ingestion/lineage/models.py +++ b/ingestion/src/metadata/ingestion/lineage/models.py @@ -43,6 +43,9 @@ from metadata.generated.schema.entity.services.connections.database.impalaConnection import ( ImpalaType, ) +from metadata.generated.schema.entity.services.connections.database.mariaDBConnection import ( + MariaDBType, +) from metadata.generated.schema.entity.services.connections.database.mssqlConnection import ( MssqlType, ) @@ -58,6 +61,9 @@ from metadata.generated.schema.entity.services.connections.database.redshiftConnection import ( RedshiftType, ) +from metadata.generated.schema.entity.services.connections.database.singleStoreConnection import ( + SingleStoreType, +) from metadata.generated.schema.entity.services.connections.database.snowflakeConnection import ( SnowflakeType, ) @@ -120,6 +126,8 @@ class Dialect(Enum): str(MssqlType.Mssql.value): Dialect.TSQL, str(AzureSQLType.AzureSQL.value): Dialect.TSQL, str(TeradataType.Teradata.value): Dialect.TERADATA, + str(MariaDBType.MariaDB.value): Dialect.MYSQL, + str(SingleStoreType.SingleStore.value): Dialect.MYSQL, } From 408c39376767f169a4ba2686fc781f12fa180ef4 Mon Sep 17 00:00:00 2001 From: Ayush Shah Date: Mon, 27 Jan 2025 10:15:48 +0530 Subject: [PATCH 186/294] Feat: Enhance Kafka SSL configuration support with consumerConfigSSL (#19433) --- .../source/messaging/kafka/metadata.py | 18 +++--- ingestion/src/metadata/utils/ssl_manager.py | 59 +++++++++++++++++-- .../messaging/kafkaConnection.json | 5 ++ .../ui/src/constants/Services.constant.ts | 1 + .../connections/messaging/kafkaConnection.ts | 42 +++++++------ 5 files changed, 93 insertions(+), 32 deletions(-) diff --git a/ingestion/src/metadata/ingestion/source/messaging/kafka/metadata.py b/ingestion/src/metadata/ingestion/source/messaging/kafka/metadata.py index f6f11d070fd9..72873ace2dcf 100644 --- a/ingestion/src/metadata/ingestion/source/messaging/kafka/metadata.py +++ b/ingestion/src/metadata/ingestion/source/messaging/kafka/metadata.py @@ -22,21 +22,19 @@ from metadata.ingestion.api.steps import InvalidSourceException from metadata.ingestion.ometa.ometa_api import OpenMetadata from metadata.ingestion.source.messaging.common_broker_source import CommonBrokerSource -from metadata.utils.ssl_manager import SSLManager +from metadata.utils.ssl_manager import SSLManager, check_ssl_and_init class KafkaSource(CommonBrokerSource): def __init__(self, config: WorkflowSource, metadata: OpenMetadata): self.ssl_manager = None - service_connection = cast(KafkaConnection, config.serviceConnection.root.config) - if service_connection.schemaRegistrySSL: - self.ssl_manager = SSLManager( - ca=service_connection.schemaRegistrySSL.root.caCertificate, - key=service_connection.schemaRegistrySSL.root.sslKey, - cert=service_connection.schemaRegistrySSL.root.sslCertificate, - ) - service_connection = self.ssl_manager.setup_ssl( - config.serviceConnection.root.config + self.service_connection = cast( + KafkaConnection, config.serviceConnection.root.config + ) + self.ssl_manager: SSLManager = check_ssl_and_init(self.service_connection) + if self.ssl_manager: + self.service_connection = self.ssl_manager.setup_ssl( + self.service_connection ) super().__init__(config, metadata) diff --git a/ingestion/src/metadata/utils/ssl_manager.py b/ingestion/src/metadata/utils/ssl_manager.py index 1e6d7a0af5ec..01e7d591a61a 100644 --- a/ingestion/src/metadata/utils/ssl_manager.py +++ b/ingestion/src/metadata/utils/ssl_manager.py @@ -17,7 +17,7 @@ import tempfile import traceback from functools import singledispatch, singledispatchmethod -from typing import Optional, Union, cast +from typing import List, Optional, Union, cast from pydantic import SecretStr @@ -66,7 +66,9 @@ class SSLManager: "SSL Manager to manage SSL certificates for service connections" - def __init__(self, ca=None, key=None, cert=None): + def __init__( + self, ca=None, key=None, cert=None, *args, **kwargs + ): # pylint: disable=keyword-arg-before-vararg self.temp_files = [] self.ca_file_path = None self.cert_file_path = None @@ -77,6 +79,14 @@ def __init__(self, ca=None, key=None, cert=None): self.cert_file_path = self.create_temp_file(cert) if key: self.key_file_path = self.create_temp_file(key) + if args: + for arg in args: + if arg: + setattr(self, f"{arg}", self.create_temp_file(arg)) + if kwargs: + for dict_key, value in kwargs.items(): + if value: + setattr(self, f"{dict_key}", self.create_temp_file(value)) def create_temp_file(self, content: SecretStr): with tempfile.NamedTemporaryFile(delete=False) as temp_file: @@ -197,8 +207,15 @@ def _(self, connection: MongoDBConnection): return connection @setup_ssl.register(KafkaConnection) - def _(self, connection): + def _(self, connection) -> KafkaConnection: connection = cast(KafkaConnection, connection) + if connection.consumerConfigSSL: + connection.consumerConfig = { + **connection.consumerConfig, + "ssl.ca.location": getattr(self, "ca_consumer_config", None), + "ssl.key.location": getattr(self, "key_consumer_config", None), + "ssl.certificate.location": getattr(self, "cert_consumer_config", None), + } connection.schemaRegistryConfig["ssl.ca.location"] = self.ca_file_path connection.schemaRegistryConfig["ssl.key.location"] = self.key_file_path connection.schemaRegistryConfig[ @@ -208,7 +225,9 @@ def _(self, connection): @singledispatch -def check_ssl_and_init(_) -> Optional[SSLManager]: +def check_ssl_and_init( + _, *args, **kwargs # pylint: disable=unused-argument +) -> Optional[Union[SSLManager, List[SSLManager]]]: return None @@ -274,6 +293,38 @@ def _(connection): return None +@check_ssl_and_init.register(KafkaConnection) +def _(connection, *args, **kwargs): + + service_connection: KafkaConnection = cast(KafkaConnection, connection) + ssl_consumer_config: Optional[ + verifySSLConfig.SslConfig + ] = service_connection.consumerConfigSSL + ssl_schema_registry: Optional[ + verifySSLConfig.SslConfig + ] = service_connection.schemaRegistrySSL + + ssl_consumer_config_dict = {} + + if ssl_consumer_config: + ssl_consumer_config_dict = { + "ca_consumer_config": ssl_consumer_config.root.caCertificate, + "cert_consumer_config": ssl_consumer_config.root.sslCertificate, + "key_consumer_config": ssl_consumer_config.root.sslKey, + } + ssl_schema_registry_dict = {} + + if ssl_schema_registry: + ssl_schema_registry_dict = { + "ca_schema_registry": ssl_schema_registry.root.caCertificate, + "cert_schema_registry": ssl_schema_registry.root.sslCertificate, + "key_schema_registry": ssl_schema_registry.root.sslKey, + } + if ssl_consumer_config_dict or ssl_schema_registry_dict: + return SSLManager(**ssl_consumer_config_dict, **ssl_schema_registry_dict) + return None + + @check_ssl_and_init.register(PostgresConnection) @check_ssl_and_init.register(RedshiftConnection) @check_ssl_and_init.register(GreenplumConnection) diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/services/connections/messaging/kafkaConnection.json b/openmetadata-spec/src/main/resources/json/schema/entity/services/connections/messaging/kafkaConnection.json index e5590befd006..649b5404c31a 100644 --- a/openmetadata-spec/src/main/resources/json/schema/entity/services/connections/messaging/kafkaConnection.json +++ b/openmetadata-spec/src/main/resources/json/schema/entity/services/connections/messaging/kafkaConnection.json @@ -88,6 +88,11 @@ "type": "string", "default": "-value" }, + "consumerConfigSSL": { + "title": "Consumer Config SSL", + "description": "Consumer Config SSL Config. Configuration for enabling SSL for the Consumer Config connection.", + "$ref": "../../../../security/ssl/verifySSLConfig.json#/definitions/sslConfig" + }, "schemaRegistrySSL": { "title": "Schema Registry SSL", "description": "Schema Registry SSL Config. Configuration for enabling SSL for the Schema Registry connection.", diff --git a/openmetadata-ui/src/main/resources/ui/src/constants/Services.constant.ts b/openmetadata-ui/src/main/resources/ui/src/constants/Services.constant.ts index 815e84f39290..9fa594360c37 100644 --- a/openmetadata-ui/src/main/resources/ui/src/constants/Services.constant.ts +++ b/openmetadata-ui/src/main/resources/ui/src/constants/Services.constant.ts @@ -474,6 +474,7 @@ export const ADVANCED_PROPERTIES = [ 'sslConfig', 'sslMode', 'schemaRegistrySSL', + 'consumerConfigSSL', 'verify', ]; diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/connections/messaging/kafkaConnection.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/connections/messaging/kafkaConnection.ts index e5290501e775..761788929749 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/connections/messaging/kafkaConnection.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/connections/messaging/kafkaConnection.ts @@ -1,5 +1,5 @@ /* - * Copyright 2024 Collate. + * Copyright 2025 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 @@ -10,9 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - - - /** +/** * Kafka Connection Config */ export interface KafkaConnection { @@ -30,6 +28,11 @@ export interface KafkaConnection { * https://github.com/edenhill/librdkafka/blob/master/CONFIGURATION.md */ consumerConfig?: { [key: string]: any }; + /** + * Consumer Config SSL Config. Configuration for enabling SSL for the Consumer Config + * connection. + */ + consumerConfigSSL?: Config; /** * sasl.mechanism Consumer Config property */ @@ -73,24 +76,14 @@ export interface KafkaConnection { } /** - * sasl.mechanism Consumer Config property - * - * SASL Mechanism consumer config property - */ -export enum SaslMechanismType { - Gssapi = "GSSAPI", - Oauthbearer = "OAUTHBEARER", - Plain = "PLAIN", - ScramSHA256 = "SCRAM-SHA-256", - ScramSHA512 = "SCRAM-SHA-512", -} - -/** - * Schema Registry SSL Config. Configuration for enabling SSL for the Schema Registry + * Consumer Config SSL Config. Configuration for enabling SSL for the Consumer Config * connection. * * Client SSL configuration * + * Schema Registry SSL Config. Configuration for enabling SSL for the Schema Registry + * connection. + * * OpenMetadata Client configured to validate SSL certificates. */ export interface Config { @@ -108,6 +101,19 @@ export interface Config { sslKey?: string; } +/** + * sasl.mechanism Consumer Config property + * + * SASL Mechanism consumer config property + */ +export enum SaslMechanismType { + Gssapi = "GSSAPI", + Oauthbearer = "OAUTHBEARER", + Plain = "PLAIN", + ScramSHA256 = "SCRAM-SHA-256", + ScramSHA512 = "SCRAM-SHA-512", +} + /** * security.protocol consumer config property */ From 282232902ab4977c6b3fb67b1147b1aecb114ab1 Mon Sep 17 00:00:00 2001 From: Mohit Yadav <105265192+mohityadav766@users.noreply.github.com> Date: Tue, 28 Jan 2025 11:45:58 +0530 Subject: [PATCH 187/294] Add onboarding SQL --- .../sql/migrations/native/1.6.3/mysql/schemaChanges.sql | 5 +++++ .../sql/migrations/native/1.6.3/postgres/schemaChanges.sql | 5 +++++ .../java/org/openmetadata/service/jdbi3/CollectionDAO.java | 4 ++-- 3 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 bootstrap/sql/migrations/native/1.6.3/mysql/schemaChanges.sql create mode 100644 bootstrap/sql/migrations/native/1.6.3/postgres/schemaChanges.sql diff --git a/bootstrap/sql/migrations/native/1.6.3/mysql/schemaChanges.sql b/bootstrap/sql/migrations/native/1.6.3/mysql/schemaChanges.sql new file mode 100644 index 000000000000..1bd78fc4dc8f --- /dev/null +++ b/bootstrap/sql/migrations/native/1.6.3/mysql/schemaChanges.sql @@ -0,0 +1,5 @@ +-- Add constraint to apps_data_store +ALTER TABLE apps_data_store ADD CONSTRAINT entity_relationship_pky PRIMARY KEY (identifier, type); +UPDATE user_entity SET json = JSON_SET(json, '$.isBot', false) WHERE JSON_EXTRACT(json, '$.isBot') IS NULL; +ALTER TABLE user_entity ADD COLUMN isBot BOOLEAN GENERATED ALWAYS AS (json -> '$.isBot') NOT NULL; +CREATE INDEX idx_isBot ON user_entity (isBot); \ No newline at end of file diff --git a/bootstrap/sql/migrations/native/1.6.3/postgres/schemaChanges.sql b/bootstrap/sql/migrations/native/1.6.3/postgres/schemaChanges.sql new file mode 100644 index 000000000000..b8a8f413fc01 --- /dev/null +++ b/bootstrap/sql/migrations/native/1.6.3/postgres/schemaChanges.sql @@ -0,0 +1,5 @@ +-- Add constraint to apps_data_store +ALTER TABLE apps_data_store ADD CONSTRAINT entity_relationship_pky PRIMARY KEY (identifier, type); +UPDATE user_entity SET json = jsonb_set(json::jsonb, '{isBot}', 'false'::jsonb, true) WHERE NOT (json ? 'isBot'); +ALTER TABLE user_entity ADD COLUMN isBot BOOLEAN GENERATED ALWAYS AS ((json ->> 'deleted')::boolean) STORED NOT NULL; +CREATE INDEX idx_isBot ON user_entity (isBot); \ No newline at end of file diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java index 86437cc72752..ca6fd40a9196 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java @@ -4830,11 +4830,11 @@ default String getTimeSeriesTableName() { interface AppsDataStore { @ConnectionAwareSqlUpdate( value = - "INSERT INTO apps_data_store(identifier, type, json) VALUES (:identifier, :type, :json)", + "INSERT INTO apps_data_store(identifier, type, json) VALUES (:identifier, :type, :json) ON DUPLICATE KEY UPDATE json = VALUES(json)", connectionType = MYSQL) @ConnectionAwareSqlUpdate( value = - "INSERT INTO apps_data_store(identifier, type, json) VALUES (:identifier, :type, :json :: jsonb)", + "INSERT INTO apps_data_store(identifier, type, json) VALUES (:identifier, :type, :json :: jsonb) ON CONFLICT (identifier, type) DO UPDATE SET json = EXCLUDED.json", connectionType = POSTGRES) void insert( @Bind("identifier") String identifier, From 52030922284df9c651fd10d32e7876a26d2f893a Mon Sep 17 00:00:00 2001 From: Imri Paran Date: Tue, 28 Jan 2025 13:37:23 +0100 Subject: [PATCH 188/294] fix(migrations): use double question-mark (#19557) Use double question mark to escape parametrization by jdbi. https://jdbc.postgresql.org/documentation/query/#using-the-statement-or-preparedstatement-interface (cherry picked from commit 76c9b5a0aefc7ce1dd2ce4e2862b77cca4bdec80) --- .../sql/migrations/native/1.6.3/postgres/schemaChanges.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bootstrap/sql/migrations/native/1.6.3/postgres/schemaChanges.sql b/bootstrap/sql/migrations/native/1.6.3/postgres/schemaChanges.sql index b8a8f413fc01..af2c924354dc 100644 --- a/bootstrap/sql/migrations/native/1.6.3/postgres/schemaChanges.sql +++ b/bootstrap/sql/migrations/native/1.6.3/postgres/schemaChanges.sql @@ -1,5 +1,5 @@ -- Add constraint to apps_data_store ALTER TABLE apps_data_store ADD CONSTRAINT entity_relationship_pky PRIMARY KEY (identifier, type); -UPDATE user_entity SET json = jsonb_set(json::jsonb, '{isBot}', 'false'::jsonb, true) WHERE NOT (json ? 'isBot'); +UPDATE user_entity SET json = jsonb_set(json::jsonb, '{isBot}', 'false'::jsonb, true) WHERE NOT (json ?? 'isBot'); ALTER TABLE user_entity ADD COLUMN isBot BOOLEAN GENERATED ALWAYS AS ((json ->> 'deleted')::boolean) STORED NOT NULL; CREATE INDEX idx_isBot ON user_entity (isBot); \ No newline at end of file From 49e30af1359150a00d40a68417db7f2eb4c9f13e Mon Sep 17 00:00:00 2001 From: Mohit Yadav <105265192+mohityadav766@users.noreply.github.com> Date: Tue, 28 Jan 2025 19:22:33 +0530 Subject: [PATCH 189/294] Cleanup App data (#19571) (cherry picked from commit cb7942f4b697fdc50904d4928b0e421f6410c54b) --- bootstrap/sql/migrations/native/1.6.3/mysql/schemaChanges.sql | 3 ++- .../sql/migrations/native/1.6.3/postgres/schemaChanges.sql | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/bootstrap/sql/migrations/native/1.6.3/mysql/schemaChanges.sql b/bootstrap/sql/migrations/native/1.6.3/mysql/schemaChanges.sql index 1bd78fc4dc8f..0c3231f17dad 100644 --- a/bootstrap/sql/migrations/native/1.6.3/mysql/schemaChanges.sql +++ b/bootstrap/sql/migrations/native/1.6.3/mysql/schemaChanges.sql @@ -2,4 +2,5 @@ ALTER TABLE apps_data_store ADD CONSTRAINT entity_relationship_pky PRIMARY KEY (identifier, type); UPDATE user_entity SET json = JSON_SET(json, '$.isBot', false) WHERE JSON_EXTRACT(json, '$.isBot') IS NULL; ALTER TABLE user_entity ADD COLUMN isBot BOOLEAN GENERATED ALWAYS AS (json -> '$.isBot') NOT NULL; -CREATE INDEX idx_isBot ON user_entity (isBot); \ No newline at end of file +CREATE INDEX idx_isBot ON user_entity (isBot); +DELETE from apps_data_store; \ No newline at end of file diff --git a/bootstrap/sql/migrations/native/1.6.3/postgres/schemaChanges.sql b/bootstrap/sql/migrations/native/1.6.3/postgres/schemaChanges.sql index af2c924354dc..cb734e7b0c6e 100644 --- a/bootstrap/sql/migrations/native/1.6.3/postgres/schemaChanges.sql +++ b/bootstrap/sql/migrations/native/1.6.3/postgres/schemaChanges.sql @@ -2,4 +2,5 @@ ALTER TABLE apps_data_store ADD CONSTRAINT entity_relationship_pky PRIMARY KEY (identifier, type); UPDATE user_entity SET json = jsonb_set(json::jsonb, '{isBot}', 'false'::jsonb, true) WHERE NOT (json ?? 'isBot'); ALTER TABLE user_entity ADD COLUMN isBot BOOLEAN GENERATED ALWAYS AS ((json ->> 'deleted')::boolean) STORED NOT NULL; -CREATE INDEX idx_isBot ON user_entity (isBot); \ No newline at end of file +CREATE INDEX idx_isBot ON user_entity (isBot); +DELETE from apps_data_store; \ No newline at end of file From b44942cf2f9e8622ab2bad40c4aedfb40955a49a Mon Sep 17 00:00:00 2001 From: Mayur Singal <39544459+ulixius9@users.noreply.github.com> Date: Wed, 29 Jan 2025 10:59:16 +0530 Subject: [PATCH 190/294] MINOR: Mariadb Lineage Fix (#19575) (cherry picked from commit 39eae2dfa515d73f7b38b51778e79f2faebdbcd6) --- .../metadata/ingestion/source/database/mariadb/lineage.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ingestion/src/metadata/ingestion/source/database/mariadb/lineage.py b/ingestion/src/metadata/ingestion/source/database/mariadb/lineage.py index afbfcc398f3b..074ce5b8c6a0 100644 --- a/ingestion/src/metadata/ingestion/source/database/mariadb/lineage.py +++ b/ingestion/src/metadata/ingestion/source/database/mariadb/lineage.py @@ -14,7 +14,7 @@ from typing import Optional from metadata.generated.schema.entity.services.connections.database.mariaDBConnection import ( - MariadbConnection, + MariaDBConnection, ) from metadata.generated.schema.metadataIngestion.workflow import ( Source as WorkflowSource, @@ -38,8 +38,8 @@ def create( ): """Create class instance""" config: WorkflowSource = WorkflowSource.model_validate(config_dict) - connection: MariadbConnection = config.serviceConnection.root.config - if not isinstance(connection, MariadbConnection): + connection: MariaDBConnection = config.serviceConnection.root.config + if not isinstance(connection, MariaDBConnection): raise InvalidSourceException( f"Expected MariadbConnection, but got {connection}" ) From e04a9a284b256800b68e6fe376da4354342f54ac Mon Sep 17 00:00:00 2001 From: Mayur Singal <39544459+ulixius9@users.noreply.github.com> Date: Wed, 29 Jan 2025 11:00:23 +0530 Subject: [PATCH 191/294] MINOR: Make DBT Tests failure log to debug (#19577) (cherry picked from commit 3a8f1a6d1b103a69a231f27a71a52bbea463966b) --- .../src/metadata/ingestion/source/database/dbt/metadata.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ingestion/src/metadata/ingestion/source/database/dbt/metadata.py b/ingestion/src/metadata/ingestion/source/database/dbt/metadata.py index d0d1542b2b27..adb8c689278a 100644 --- a/ingestion/src/metadata/ingestion/source/database/dbt/metadata.py +++ b/ingestion/src/metadata/ingestion/source/database/dbt/metadata.py @@ -1133,7 +1133,7 @@ def add_dbt_test_result(self, dbt_test: dict): except Exception as err: # pylint: disable=broad-except logger.debug(traceback.format_exc()) - logger.error( + logger.debug( f"Failed to capture tests results for node: {manifest_node.name} {err}" ) From 569b902c7e1bff0df91eea2f2e795e5a260e05a2 Mon Sep 17 00:00:00 2001 From: Ashish Gupta Date: Wed, 29 Jan 2025 11:38:01 +0530 Subject: [PATCH 192/294] MINOR: fix the lineage and DataAssetsWidget flaky playwright test failure (#19560) * fix the lineage flaky test failure * fix the DataAssetsWidget falky test failure * fix falky test around the pipeline modal and teams (cherry picked from commit 3924e58073688714eeec5ac88768e54fb585a659) --- .../main/resources/ui/playwright/e2e/Pages/Lineage.spec.ts | 3 ++- .../src/main/resources/ui/playwright/e2e/Pages/Teams.spec.ts | 2 -- .../src/main/resources/ui/playwright/utils/entity.ts | 1 + .../src/main/resources/ui/playwright/utils/lineage.ts | 4 ++++ .../src/main/resources/ui/playwright/utils/team.ts | 5 +++++ 5 files changed, 12 insertions(+), 3 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Lineage.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Lineage.spec.ts index ce818ae4e9fb..2bc5cc55809f 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Lineage.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Lineage.spec.ts @@ -86,7 +86,8 @@ for (const EntityClass of entities) { test(`Lineage creation from ${defaultEntity.getType()} entity`, async ({ browser, }) => { - test.slow(true); + // 5 minutes to avoid test timeout happening some times in AUTs + test.setTimeout(300_000); const { page } = await createNewPage(browser); const { currentEntity, entities, cleanup } = await setupEntitiesForLineage( diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Teams.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Teams.spec.ts index f5c2650b8fa4..868d86e15f5f 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Teams.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Teams.spec.ts @@ -114,8 +114,6 @@ test.describe('Teams Page', () => { test('Teams Page Flow', async ({ page }) => { await test.step('Create a new team', async () => { - await settingClick(page, GlobalSettingOptions.TEAMS); - await checkTeamTabCount(page); await page.waitForLoadState('networkidle'); 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 5e54306fe450..d503fc1752b1 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts @@ -1300,6 +1300,7 @@ export const hardDeleteEntity = async ( }; export const checkDataAssetWidget = async (page: Page, serviceType: string) => { + await clickOutside(page); const quickFilterResponse = page.waitForResponse( `/api/v1/search/query?q=&index=dataAsset*${serviceType}*` ); 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 9dcdbb85e250..a7ee6506a4ea 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/lineage.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/lineage.ts @@ -369,6 +369,10 @@ export const applyPipelineFromModal = async ( const saveRes = page.waitForResponse('/api/v1/lineage'); await page.click('[data-testid="save-button"]'); await saveRes; + + await page.waitForSelector('[data-testid="add-edge-modal"]', { + state: 'detached', + }); }; export const deleteNode = async (page: Page, node: EntityClass) => { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/team.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/team.ts index 7229cd3c101a..471f607a0ea6 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/team.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/team.ts @@ -11,12 +11,14 @@ * limitations under the License. */ import { APIRequestContext, expect, Page } from '@playwright/test'; +import { GlobalSettingOptions } from '../constant/settings'; import { TableClass } from '../support/entity/TableClass'; import { TeamClass } from '../support/team/TeamClass'; import { UserClass } from '../support/user/UserClass'; import { descriptionBox, toastNotification, uuid } from './common'; import { addOwner } from './entity'; import { validateFormNameFieldInput } from './form'; +import { settingClick } from './sidebar'; const TEAM_TYPES = ['Department', 'Division', 'Group']; @@ -354,6 +356,9 @@ export const checkTeamTabCount = async (page: Page) => { const fetchResponse = page.waitForResponse( '/api/v1/teams/name/*?fields=*childrenCount*include=all' ); + + await settingClick(page, GlobalSettingOptions.TEAMS); + const response = await fetchResponse; const jsonRes = await response.json(); From 4a3721b8cd6c6a0c6c46f7f24cb6abc8a40b15fd Mon Sep 17 00:00:00 2001 From: Mohit Yadav <105265192+mohityadav766@users.noreply.github.com> Date: Wed, 29 Jan 2025 00:08:45 +0530 Subject: [PATCH 193/294] Add prometheus counter for search and database (#19561) --- conf/openmetadata.yaml | 1 + .../service/OpenMetadataApplication.java | 19 ++++-- .../DatabseAndSearchServiceStatusJob.java | 55 ++++++++++++++++ .../scheduled/PipelineServiceStatusJob.java | 6 +- ...ler.java => ServicesStatusJobHandler.java} | 62 +++++++++++++------ .../monitoring/EventMonitorConfiguration.java | 2 + .../service/search/SearchClient.java | 2 + .../service/search/SearchHealthStatus.java | 12 ++++ .../elasticsearch/ElasticSearchClient.java | 19 ++++++ .../search/opensearch/OpenSearchClient.java | 19 ++++++ 10 files changed, 169 insertions(+), 28 deletions(-) create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/events/scheduled/DatabseAndSearchServiceStatusJob.java rename openmetadata-service/src/main/java/org/openmetadata/service/events/scheduled/{PipelineServiceStatusJobHandler.java => ServicesStatusJobHandler.java} (55%) create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/search/SearchHealthStatus.java diff --git a/conf/openmetadata.yaml b/conf/openmetadata.yaml index cf7f07b7f286..cc6909053c8a 100644 --- a/conf/openmetadata.yaml +++ b/conf/openmetadata.yaml @@ -296,6 +296,7 @@ eventMonitoringConfiguration: batchSize: ${EVENT_MONITOR_BATCH_SIZE:-10} pathPattern: ${EVENT_MONITOR_PATH_PATTERN:-["/api/v1/tables/*", "/api/v1/health-check"]} latency: ${EVENT_MONITOR_LATENCY:-[0.99, 0.90]} # For value p99=0.99, p90=0.90, p50=0.50 etc. + servicesHealthCheckInterval: ${EVENT_MONITOR_SERVICES_HEALTH_CHECK_INTERVAL:-300} # it will use the default auth provider for AWS services if parameters are not set # parameters: # region: ${OM_MONITOR_REGION:-""} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/OpenMetadataApplication.java b/openmetadata-service/src/main/java/org/openmetadata/service/OpenMetadataApplication.java index ee00e54cf121..900b34fcf0dd 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/OpenMetadataApplication.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/OpenMetadataApplication.java @@ -73,7 +73,7 @@ import org.openmetadata.service.events.EventFilter; import org.openmetadata.service.events.EventPubSub; import org.openmetadata.service.events.scheduled.EventSubscriptionScheduler; -import org.openmetadata.service.events.scheduled.PipelineServiceStatusJobHandler; +import org.openmetadata.service.events.scheduled.ServicesStatusJobHandler; import org.openmetadata.service.exception.CatalogGenericExceptionMapper; import org.openmetadata.service.exception.ConstraintViolationExceptionMapper; import org.openmetadata.service.exception.JsonMappingExceptionMapper; @@ -264,16 +264,23 @@ public void run(OpenMetadataApplicationConfig catalogConfig, Environment environ // Asset Servlet Registration registerAssetServlet(catalogConfig.getWebConfiguration(), environment); - // Handle Pipeline Service Client Status job - PipelineServiceStatusJobHandler pipelineServiceStatusJobHandler = - PipelineServiceStatusJobHandler.create( - catalogConfig.getPipelineServiceClientConfiguration(), catalogConfig.getClusterName()); - pipelineServiceStatusJobHandler.addPipelineServiceStatusJob(); + // Handle Services Jobs + registerHealthCheckJobs(catalogConfig); // Register Auth Handlers registerAuthServlets(catalogConfig, environment); } + private void registerHealthCheckJobs(OpenMetadataApplicationConfig catalogConfig) { + ServicesStatusJobHandler healthCheckStatusHandler = + ServicesStatusJobHandler.create( + catalogConfig.getEventMonitorConfiguration(), + catalogConfig.getPipelineServiceClientConfiguration(), + catalogConfig.getClusterName()); + healthCheckStatusHandler.addPipelineServiceStatusJob(); + healthCheckStatusHandler.addDatabaseAndSearchStatusJobs(); + } + private void registerAuthServlets(OpenMetadataApplicationConfig config, Environment environment) { if (config.getAuthenticationConfiguration() != null && config diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/events/scheduled/DatabseAndSearchServiceStatusJob.java b/openmetadata-service/src/main/java/org/openmetadata/service/events/scheduled/DatabseAndSearchServiceStatusJob.java new file mode 100644 index 000000000000..c27d91df2a1b --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/events/scheduled/DatabseAndSearchServiceStatusJob.java @@ -0,0 +1,55 @@ +package org.openmetadata.service.events.scheduled; + +import static org.openmetadata.service.events.scheduled.ServicesStatusJobHandler.JOB_CONTEXT_METER_REGISTRY; +import static org.openmetadata.service.events.scheduled.ServicesStatusJobHandler.UNHEALTHY_STATUS; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.prometheus.PrometheusMeterRegistry; +import lombok.extern.slf4j.Slf4j; +import org.openmetadata.service.Entity; +import org.openmetadata.service.search.SearchHealthStatus; +import org.quartz.Job; +import org.quartz.JobExecutionContext; + +@Slf4j +public class DatabseAndSearchServiceStatusJob implements Job { + private static final String SERVICE_COUNTER = "omd_service_unreachable"; + private static final String SERVICE_NAME = "service_name"; + private static final String SEARCH_SERVICE_NAME = "search"; + private static final String DATABASE_SERVICE_NAME = "database"; + + @Override + public void execute(JobExecutionContext jobExecutionContext) { + PrometheusMeterRegistry meterRegistry = + (PrometheusMeterRegistry) + jobExecutionContext.getJobDetail().getJobDataMap().get(JOB_CONTEXT_METER_REGISTRY); + checkDatabaseStatus(meterRegistry); + checkElasticSearchStatus(meterRegistry); + } + + private void checkElasticSearchStatus(PrometheusMeterRegistry meterRegistry) { + try { + SearchHealthStatus status = + Entity.getSearchRepository().getSearchClient().getSearchHealthStatus(); + if (status.getStatus().equals(UNHEALTHY_STATUS)) { + publishUnhealthyCounter(meterRegistry, SERVICE_NAME, SEARCH_SERVICE_NAME); + } + } catch (Exception ex) { + LOG.error("Elastic Search encountering exception.", ex); + publishUnhealthyCounter(meterRegistry, SERVICE_NAME, SEARCH_SERVICE_NAME); + } + } + + private void checkDatabaseStatus(PrometheusMeterRegistry meterRegistry) { + try { + Entity.getCollectionDAO().systemDAO().testConnection(); + } catch (Exception ex) { + LOG.error("Database encountering exception.", ex); + publishUnhealthyCounter(meterRegistry, SERVICE_NAME, DATABASE_SERVICE_NAME); + } + } + + private void publishUnhealthyCounter(PrometheusMeterRegistry meterRegistry, String... tags) { + Counter.builder(SERVICE_COUNTER).tags(tags).register(meterRegistry).increment(); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/events/scheduled/PipelineServiceStatusJob.java b/openmetadata-service/src/main/java/org/openmetadata/service/events/scheduled/PipelineServiceStatusJob.java index e2c368c965f0..bc45a4bc0570 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/events/scheduled/PipelineServiceStatusJob.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/events/scheduled/PipelineServiceStatusJob.java @@ -2,9 +2,9 @@ import static org.openmetadata.sdk.PipelineServiceClientInterface.HEALTHY_STATUS; import static org.openmetadata.sdk.PipelineServiceClientInterface.STATUS_KEY; -import static org.openmetadata.service.events.scheduled.PipelineServiceStatusJobHandler.JOB_CONTEXT_CLUSTER_NAME; -import static org.openmetadata.service.events.scheduled.PipelineServiceStatusJobHandler.JOB_CONTEXT_METER_REGISTRY; -import static org.openmetadata.service.events.scheduled.PipelineServiceStatusJobHandler.JOB_CONTEXT_PIPELINE_SERVICE_CLIENT; +import static org.openmetadata.service.events.scheduled.ServicesStatusJobHandler.JOB_CONTEXT_CLUSTER_NAME; +import static org.openmetadata.service.events.scheduled.ServicesStatusJobHandler.JOB_CONTEXT_METER_REGISTRY; +import static org.openmetadata.service.events.scheduled.ServicesStatusJobHandler.JOB_CONTEXT_PIPELINE_SERVICE_CLIENT; import io.micrometer.core.instrument.Counter; import io.micrometer.prometheus.PrometheusMeterRegistry; diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/events/scheduled/PipelineServiceStatusJobHandler.java b/openmetadata-service/src/main/java/org/openmetadata/service/events/scheduled/ServicesStatusJobHandler.java similarity index 55% rename from openmetadata-service/src/main/java/org/openmetadata/service/events/scheduled/PipelineServiceStatusJobHandler.java rename to openmetadata-service/src/main/java/org/openmetadata/service/events/scheduled/ServicesStatusJobHandler.java index 2e12d51764ff..c48b8df90eeb 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/events/scheduled/PipelineServiceStatusJobHandler.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/events/scheduled/ServicesStatusJobHandler.java @@ -5,7 +5,9 @@ import org.openmetadata.schema.api.configuration.pipelineServiceClient.PipelineServiceClientConfiguration; import org.openmetadata.sdk.PipelineServiceClientInterface; import org.openmetadata.service.clients.pipeline.PipelineServiceClientFactory; +import org.openmetadata.service.monitoring.EventMonitorConfiguration; import org.openmetadata.service.util.MicrometerBundleSingleton; +import org.quartz.Job; import org.quartz.JobBuilder; import org.quartz.JobDataMap; import org.quartz.JobDetail; @@ -17,11 +19,14 @@ import org.quartz.impl.StdSchedulerFactory; @Slf4j -public class PipelineServiceStatusJobHandler { - +public class ServicesStatusJobHandler { + public static final String HEALTHY_STATUS = "healthy"; + public static final String UNHEALTHY_STATUS = "unhealthy"; + public static final String DATABASE_SEARCH_STATUS_JOB = "databaseAndSearchServiceStatusJob"; public static final String PIPELINE_SERVICE_STATUS_JOB = "pipelineServiceStatusJob"; public static final String STATUS_GROUP = "status"; - public static final String STATUS_CRON_TRIGGER = "statusTrigger"; + public static final String PIPELINE_STATUS_CRON_TRIGGER = "pipelineStatusTrigger"; + public static final String DATABSE_SEARCH_STATUS_CRON_TRIGGER = "databaseAndSearchStatusTrigger"; public static final String JOB_CONTEXT_PIPELINE_SERVICE_CLIENT = "pipelineServiceClient"; public static final String JOB_CONTEXT_METER_REGISTRY = "meterRegistry"; public static final String JOB_CONTEXT_CLUSTER_NAME = "clusterName"; @@ -32,51 +37,55 @@ public class PipelineServiceStatusJobHandler { private final String clusterName; private final Integer healthCheckInterval; private final Scheduler scheduler = new StdSchedulerFactory().getScheduler(); + private final int servicesHealthCheckInterval; + private static ServicesStatusJobHandler instance; - private static PipelineServiceStatusJobHandler instance; - - private PipelineServiceStatusJobHandler( - PipelineServiceClientConfiguration config, String clusterName) throws SchedulerException { + private ServicesStatusJobHandler( + EventMonitorConfiguration monitorConfiguration, + PipelineServiceClientConfiguration config, + String clusterName) + throws SchedulerException { this.config = config; this.pipelineServiceClient = PipelineServiceClientFactory.createPipelineServiceClient(config); this.meterRegistry = MicrometerBundleSingleton.prometheusMeterRegistry; this.clusterName = clusterName; this.healthCheckInterval = config.getHealthCheckInterval(); + this.servicesHealthCheckInterval = monitorConfiguration.getServicesHealthCheckInterval(); this.scheduler.start(); } - public static PipelineServiceStatusJobHandler create( - PipelineServiceClientConfiguration config, String clusterName) { + public static ServicesStatusJobHandler create( + EventMonitorConfiguration eventMonitorConfiguration, + PipelineServiceClientConfiguration config, + String clusterName) { if (instance != null) return instance; try { - instance = new PipelineServiceStatusJobHandler(config, clusterName); + instance = new ServicesStatusJobHandler(eventMonitorConfiguration, config, clusterName); } catch (Exception ex) { LOG.error("Failed to initialize the Pipeline Service Status Handler"); } return instance; } - private JobDetail jobBuilder() { + private JobDetail jobBuilder(Class clazz, String jobName, String group) { JobDataMap dataMap = new JobDataMap(); dataMap.put(JOB_CONTEXT_PIPELINE_SERVICE_CLIENT, pipelineServiceClient); dataMap.put(JOB_CONTEXT_METER_REGISTRY, meterRegistry); dataMap.put(JOB_CONTEXT_CLUSTER_NAME, clusterName); JobBuilder jobBuilder = - JobBuilder.newJob(PipelineServiceStatusJob.class) - .withIdentity(PIPELINE_SERVICE_STATUS_JOB, STATUS_GROUP) - .usingJobData(dataMap); + JobBuilder.newJob(clazz).withIdentity(jobName, group).usingJobData(dataMap); return jobBuilder.build(); } - private Trigger getTrigger() { + private Trigger getTrigger(int checkInterval, String identity, String group) { return TriggerBuilder.newTrigger() - .withIdentity(STATUS_CRON_TRIGGER, STATUS_GROUP) + .withIdentity(identity, group) .withSchedule( SimpleScheduleBuilder.simpleSchedule() - .withIntervalInSeconds(healthCheckInterval) + .withIntervalInSeconds(checkInterval) .repeatForever()) .build(); } @@ -86,12 +95,27 @@ public void addPipelineServiceStatusJob() { // enabled if (config.getEnabled().equals(Boolean.TRUE)) { try { - JobDetail jobDetail = jobBuilder(); - Trigger trigger = getTrigger(); + JobDetail jobDetail = + jobBuilder(PipelineServiceStatusJob.class, PIPELINE_SERVICE_STATUS_JOB, STATUS_GROUP); + Trigger trigger = + getTrigger(healthCheckInterval, PIPELINE_STATUS_CRON_TRIGGER, STATUS_GROUP); scheduler.scheduleJob(jobDetail, trigger); } catch (Exception ex) { LOG.error("Failed in setting up job Scheduler for Pipeline Service Status", ex); } } } + + public void addDatabaseAndSearchStatusJobs() { + try { + JobDetail jobDetail = + jobBuilder( + DatabseAndSearchServiceStatusJob.class, DATABASE_SEARCH_STATUS_JOB, STATUS_GROUP); + Trigger trigger = + getTrigger(servicesHealthCheckInterval, DATABSE_SEARCH_STATUS_CRON_TRIGGER, STATUS_GROUP); + scheduler.scheduleJob(jobDetail, trigger); + } catch (Exception ex) { + LOG.error("Failed in setting up job Scheduler for Pipeline Service Status", ex); + } + } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/monitoring/EventMonitorConfiguration.java b/openmetadata-service/src/main/java/org/openmetadata/service/monitoring/EventMonitorConfiguration.java index 4e3f17b84c7b..552d34db3daf 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/monitoring/EventMonitorConfiguration.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/monitoring/EventMonitorConfiguration.java @@ -31,4 +31,6 @@ public class EventMonitorConfiguration { private String[] pathPattern; private double[] latency; + + private int servicesHealthCheckInterval = 300; } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchClient.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchClient.java index d764b11a8b00..88fb80571a38 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchClient.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchClient.java @@ -390,4 +390,6 @@ static boolean shouldApplyRbacConditions( && !subjectContext.isBot() && rbacConditionEvaluator != null; } + + SearchHealthStatus getSearchHealthStatus() throws IOException; } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchHealthStatus.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchHealthStatus.java new file mode 100644 index 000000000000..f3d2b5cf0430 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchHealthStatus.java @@ -0,0 +1,12 @@ +package org.openmetadata.service.search; + +import lombok.Getter; + +@Getter +public class SearchHealthStatus { + public SearchHealthStatus(String status) { + this.status = status; + } + + String status; +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java index 4422ecb27271..8a22119ceef5 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java @@ -13,6 +13,8 @@ import static org.openmetadata.service.Entity.QUERY; import static org.openmetadata.service.Entity.RAW_COST_ANALYSIS_REPORT_DATA; import static org.openmetadata.service.Entity.TABLE; +import static org.openmetadata.service.events.scheduled.ServicesStatusJobHandler.HEALTHY_STATUS; +import static org.openmetadata.service.events.scheduled.ServicesStatusJobHandler.UNHEALTHY_STATUS; import static org.openmetadata.service.exception.CatalogGenericExceptionMapper.getResponse; import static org.openmetadata.service.search.EntityBuilderConstant.API_RESPONSE_SCHEMA_FIELD; import static org.openmetadata.service.search.EntityBuilderConstant.API_RESPONSE_SCHEMA_FIELD_KEYWORD; @@ -38,6 +40,9 @@ import com.fasterxml.jackson.databind.JsonNode; import es.org.elasticsearch.ElasticsearchStatusException; +import es.org.elasticsearch.action.ActionListener; +import es.org.elasticsearch.action.admin.cluster.health.ClusterHealthRequest; +import es.org.elasticsearch.action.admin.cluster.health.ClusterHealthResponse; import es.org.elasticsearch.action.admin.indices.alias.IndicesAliasesRequest; import es.org.elasticsearch.action.admin.indices.delete.DeleteIndexRequest; import es.org.elasticsearch.action.bulk.BulkRequest; @@ -60,6 +65,7 @@ import es.org.elasticsearch.client.indices.GetMappingsRequest; import es.org.elasticsearch.client.indices.GetMappingsResponse; import es.org.elasticsearch.client.indices.PutMappingRequest; +import es.org.elasticsearch.cluster.health.ClusterHealthStatus; import es.org.elasticsearch.cluster.metadata.MappingMetadata; import es.org.elasticsearch.common.lucene.search.function.CombineFunction; import es.org.elasticsearch.common.lucene.search.function.FieldValueFactorFunction; @@ -166,6 +172,7 @@ import org.openmetadata.service.jdbi3.TestCaseResultRepository; import org.openmetadata.service.search.SearchAggregation; import org.openmetadata.service.search.SearchClient; +import org.openmetadata.service.search.SearchHealthStatus; import org.openmetadata.service.search.SearchIndexUtils; import org.openmetadata.service.search.SearchRequest; import org.openmetadata.service.search.SearchSortFilter; @@ -2840,6 +2847,18 @@ public Object getLowLevelClient() { return client.getLowLevelClient(); } + @Override + public SearchHealthStatus getSearchHealthStatus() throws IOException { + ClusterHealthRequest request = new ClusterHealthRequest(); + ClusterHealthResponse response = client.cluster().health(request, RequestOptions.DEFAULT); + if (response.getStatus().equals(ClusterHealthStatus.GREEN) + || response.getStatus().equals(ClusterHealthStatus.YELLOW)) { + return new SearchHealthStatus(HEALTHY_STATUS); + } else { + return new SearchHealthStatus(UNHEALTHY_STATUS); + } + } + private void buildSearchRBACQuery( SubjectContext subjectContext, SearchSourceBuilder searchSourceBuilder) { if (SearchClient.shouldApplyRbacConditions(subjectContext, rbacConditionEvaluator)) { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java index 51da4f271dea..069f3ee75e31 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java @@ -12,6 +12,8 @@ import static org.openmetadata.service.Entity.QUERY; import static org.openmetadata.service.Entity.RAW_COST_ANALYSIS_REPORT_DATA; import static org.openmetadata.service.Entity.TABLE; +import static org.openmetadata.service.events.scheduled.ServicesStatusJobHandler.HEALTHY_STATUS; +import static org.openmetadata.service.events.scheduled.ServicesStatusJobHandler.UNHEALTHY_STATUS; import static org.openmetadata.service.exception.CatalogGenericExceptionMapper.getResponse; import static org.openmetadata.service.search.EntityBuilderConstant.API_RESPONSE_SCHEMA_FIELD; import static org.openmetadata.service.search.EntityBuilderConstant.API_RESPONSE_SCHEMA_FIELD_KEYWORD; @@ -86,6 +88,7 @@ import org.openmetadata.service.jdbi3.TestCaseResultRepository; import org.openmetadata.service.search.SearchAggregation; import org.openmetadata.service.search.SearchClient; +import org.openmetadata.service.search.SearchHealthStatus; import org.openmetadata.service.search.SearchIndexUtils; import org.openmetadata.service.search.SearchRequest; import org.openmetadata.service.search.SearchSortFilter; @@ -135,6 +138,9 @@ import org.openmetadata.service.workflows.searchIndex.ReindexingUtil; import os.org.opensearch.OpenSearchException; import os.org.opensearch.OpenSearchStatusException; +import os.org.opensearch.action.ActionListener; +import os.org.opensearch.action.admin.cluster.health.ClusterHealthRequest; +import os.org.opensearch.action.admin.cluster.health.ClusterHealthResponse; import os.org.opensearch.action.admin.indices.alias.IndicesAliasesRequest; import os.org.opensearch.action.admin.indices.delete.DeleteIndexRequest; import os.org.opensearch.action.bulk.BulkRequest; @@ -156,6 +162,7 @@ import os.org.opensearch.client.indices.GetMappingsRequest; import os.org.opensearch.client.indices.GetMappingsResponse; import os.org.opensearch.client.indices.PutMappingRequest; +import os.org.opensearch.cluster.health.ClusterHealthStatus; import os.org.opensearch.cluster.metadata.MappingMetadata; import os.org.opensearch.common.lucene.search.function.CombineFunction; import os.org.opensearch.common.lucene.search.function.FieldValueFactorFunction; @@ -2835,4 +2842,16 @@ private static void buildSearchSourceFilter( } } } + + @Override + public SearchHealthStatus getSearchHealthStatus() throws IOException { + ClusterHealthRequest request = new ClusterHealthRequest(); + ClusterHealthResponse response = client.cluster().health(request, RequestOptions.DEFAULT); + if (response.getStatus().equals(ClusterHealthStatus.GREEN) + || response.getStatus().equals(ClusterHealthStatus.YELLOW)) { + return new SearchHealthStatus(HEALTHY_STATUS); + } else { + return new SearchHealthStatus(UNHEALTHY_STATUS); + } + } } From b42a8dcd8289b30a75f8f6b43709149505527356 Mon Sep 17 00:00:00 2001 From: Ashish Gupta Date: Wed, 29 Jan 2025 12:00:10 +0530 Subject: [PATCH 194/294] Update the what's new for 1.6.3 (#19570) * Update the what's new for 1.6.3 * minor changes * minor addition (cherry picked from commit 2dc8f1d330764d5db66f3b692e1841a9a3791380) --- .../Modals/WhatsNewModal/whatsNewData.ts | 77 ++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) 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 aa4ffc40acbd..541dda569b81 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_6_2'; // To be changed with each release. +export const COOKIE_VERSION = 'VERSION_1_6_3'; // 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 @@ -1495,6 +1495,81 @@ Since we are introducing the Auto Classification workflow, we are going to remov - **Fix**: Avoid pluralizing for custom charts. ${CollateIconWithLinkMD} - **Improvement**: Add the missing filters for different assets in the Automator(Ex. Database filter for Database Schema asset). ${CollateIconWithLinkMD} - **Improvement**: Add Glossary Term and Metric as assets for Automation. ${CollateIconWithLinkMD} +`, + }, + }, + { + id: 59, + version: 'v1.6.3', + description: 'Released on 29th January 2025.', + features: [], + changeLogs: { + Improvements: `- **Fix**: Adds percona server for postgresql support. +- **Fix**: Inherited Ownership for Data Products. +- **Fix**: Favicon not being updated in the browser tab. +- **Fix**: Fix Search Index for ER Model. +- **Fix**: dbt ingestion picks up wrong service to patch metadata. +- **Fix**: Wrong team count displayed on team tab. +- **Fix**: Tracing highlighter in lineage after edge clicked. +- **Fix**: Api should not called after time out in Test connection. +- **Fix**: Get only non-deleted entities in export. +- **Fix**: The permissions call made for search service. +- **Fix**: Kafkaconnect validation errors. +- **Fix**: DI Filter not getting applied. +- **Fix**: Redash Get Dashboards flow. +- **Fix**: Description not rendered in Glossary Modal while edit. +- **Fix**: The persona JSON schema is named Team. +- **Fix**: Redirection issue on IDP initiated calls. +- **Fix**: Async export csv not happening in lineage. +- **Fix**: Description renderer having tags in glossary,team and user import. +- **Fix**: RichTextEditor output in case on no data save. +- **Fix**: s3 storage parquet structureFormat ingestion. +- **Fix**: Data Insights index mapping. +- **Fix**: Edit description permission for domain owner. +- **Fix**: Model dump dict key names. +- **Fix**: Broken looker lineage. +- **Fix**: Refresh call concurrency for multiple browser tabs. +- **Fix**: Infinite loading for refresh attempted on app visit. +- **Fix**: Duplicate table constraints. +- **Fix**: Updated MSSQL queries causing arithmetic overflow error. +- **Fix**: PowerBI tables, datamodel metadata missing. +- **Fix**: Wrong dataset and project id in filter of system metric query. +- **Fix**: Data Insight fix custom property filter. +- **Fix**: Entity Hierarchy Schema. +- **Fix**: Salesforce column description with toggle api. +- **Fix**: Update glossary term table upon new term added. +- **Fix**: Remove unwanted spacing around the list in block editor. +- **Fix**: Postgres parse json schema. +- **Fix**: Optimize multithreading for lineage. +- **Fix**: Fetch Stored Procedures from account usage . +- **Fix**: Add MaterializedView & DynamicTable for lineage computation. +- **Fix**: MariaDB Lineage Dialect Issue. +- **Fix**: DQ Dashboard: update order of the pie chart. ${CollateIconWithLinkMD} +- **Fix**: Lineage Propagation when Entity doesn't have a given field. ${CollateIconWithLinkMD} +- **Minor**: Optimize Snowflake SP Query. +- **Minor**: Hide description tooltip for tag edit mode. +- **Minor**: BigQuery Improvement, Hive Partitioned Tables, Nonetype issue resolved +- **Minor**: Typo for datetime attribute. +- **Minor**: Get missing dataProducts and pipeline properties in /customProperties api. +- **Minor**: Improve cron expression validations. +- **Minor**: Change log localization improvement. +- **Minor**: Async test case result deletion. +- **Minor**: Retention period 'Cancel' international display issue. +- **Minor**: Added limits configuration in telemetry payload. ${CollateIconWithLinkMD} +- **Improvement**: Logout user on unsuccessful refresh attempt. +- **Improvement**: Support for Domain hierarchy listing. +- **Improvement**: Avoid usage of CONCAT in WHERE clause. +- **Improvement**: Glossary column width sizes for the resizable columns. +- **Improvement**: Move Recreate Out of executors. +- **Improvement**: Supported the task filter on landing page feed widget. +- **Improvement**: Implement Data Quality Dashboards (Incident Manager + Data Quality). +- **Improvement**: Added loading state, and manage error notification in TestSuite. +- **Improvement**: Enhance Kafka SSL configuration support with consumerConfigSSL. +- **Improvement**: Add prometheus counter for search and database. +- **Improvement**: Retention Application : Delete change_events, activity threads, versions based on admin retention policies. +- **Improvement**: Show displayName for custom dashboards. ${CollateIconWithLinkMD} +- **Improvement**: Support rename for custom dashboard and charts. ${CollateIconWithLinkMD} +- **Improvement**: Improve Onboarding Application. ${CollateIconWithLinkMD} `, }, }, From e6b19d162e819c84e217707aa234e08b175f441f Mon Sep 17 00:00:00 2001 From: karanh37 Date: Wed, 29 Jan 2025 18:05:13 +0530 Subject: [PATCH 195/294] fix missing styles --- openmetadata-ui/src/main/resources/ui/src/styles/tree.less | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/styles/tree.less b/openmetadata-ui/src/main/resources/ui/src/styles/tree.less index 1b71463bfb8e..80b91f77ed1c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/styles/tree.less +++ b/openmetadata-ui/src/main/resources/ui/src/styles/tree.less @@ -55,7 +55,9 @@ } .ant-tree-switcher-icon { - color: black; + width: 12px; + height: 12px; + color: @grey-4; } .execution-node-container { From e518f2297ab1d848bf74faefc9d8bf16b974b265 Mon Sep 17 00:00:00 2001 From: karanh37 Date: Wed, 29 Jan 2025 18:05:36 +0530 Subject: [PATCH 196/294] Revert "fix missing styles" This reverts commit e6b19d162e819c84e217707aa234e08b175f441f. --- openmetadata-ui/src/main/resources/ui/src/styles/tree.less | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/styles/tree.less b/openmetadata-ui/src/main/resources/ui/src/styles/tree.less index 80b91f77ed1c..1b71463bfb8e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/styles/tree.less +++ b/openmetadata-ui/src/main/resources/ui/src/styles/tree.less @@ -55,9 +55,7 @@ } .ant-tree-switcher-icon { - width: 12px; - height: 12px; - color: @grey-4; + color: black; } .execution-node-container { From 3fdecfe0d678018e4b2a427e093cd18439b97e48 Mon Sep 17 00:00:00 2001 From: Karan Hotchandani <33024356+karanh37@users.noreply.github.com> Date: Wed, 29 Jan 2025 18:10:10 +0530 Subject: [PATCH 197/294] fix missing tree styles (#19592) --- openmetadata-ui/src/main/resources/ui/src/styles/tree.less | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/styles/tree.less b/openmetadata-ui/src/main/resources/ui/src/styles/tree.less index 1b71463bfb8e..80b91f77ed1c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/styles/tree.less +++ b/openmetadata-ui/src/main/resources/ui/src/styles/tree.less @@ -55,7 +55,9 @@ } .ant-tree-switcher-icon { - color: black; + width: 12px; + height: 12px; + color: @grey-4; } .execution-node-container { From 0bbe1477862df067b0aa875462f27adbd7df4045 Mon Sep 17 00:00:00 2001 From: sonika-shah <58761340+sonika-shah@users.noreply.github.com> Date: Wed, 29 Jan 2025 19:14:41 +0530 Subject: [PATCH 198/294] fix : aggregation issue in opensearch while fetching domain hierarchy (#19594) --- .../service/search/opensearch/OpenSearchClient.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java index 069f3ee75e31..d26e742a75f2 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java @@ -1868,7 +1868,10 @@ private static SearchSourceBuilder buildDomainsSearch(String query, int from, in buildSearchQueryBuilder(query, DomainIndex.getFields()); FunctionScoreQueryBuilder queryBuilder = boostScore(queryStringBuilder); HighlightBuilder hb = buildHighlights(new ArrayList<>()); - return searchBuilder(queryBuilder, hb, from, size); + SearchSourceBuilder searchSourceBuilder = searchBuilder(queryBuilder, hb, from, size); + searchSourceBuilder.aggregation( + AggregationBuilders.terms("fqnParts_agg").field("fqnParts").size(1000)); + return addAggregation(searchSourceBuilder); } private static SearchSourceBuilder buildSearchEntitySearch(String query, int from, int size) { From 459e1daa699d30352b1fd38f5d69466e44287fcc Mon Sep 17 00:00:00 2001 From: Mayur Singal <39544459+ulixius9@users.noreply.github.com> Date: Wed, 29 Jan 2025 20:07:27 +0530 Subject: [PATCH 199/294] MINOR: Fix ES Req for Views (#19595) --- .../ingestion/ometa/mixins/es_mixin.py | 50 +++++++++++++++---- .../database/stored_procedures_mixin.py | 14 +++++- 2 files changed, 53 insertions(+), 11 deletions(-) diff --git a/ingestion/src/metadata/ingestion/ometa/mixins/es_mixin.py b/ingestion/src/metadata/ingestion/ometa/mixins/es_mixin.py index fbc315ffdafa..fbd84b4e92e1 100644 --- a/ingestion/src/metadata/ingestion/ometa/mixins/es_mixin.py +++ b/ingestion/src/metadata/ingestion/ometa/mixins/es_mixin.py @@ -402,19 +402,51 @@ def yield_es_view_def( "query": { "bool": { "must": [ - {"term": {"service.name.keyword": service_name}}, { - "term": { - "tableType": [ - TableType.View.value, - TableType.MaterializedView.value, - TableType.SecureView.value, - TableType.Dynamic.value, + "bool": { + "should": [ + {"term": {"service.name.keyword": service_name}} + ] + } + }, + { + "bool": { + "must": [ + { + "bool": { + "should": [ + { + "term": { + "tableType": TableType.View.value + } + }, + { + "term": { + "tableType": TableType.MaterializedView.value + } + }, + { + "term": { + "tableType": TableType.SecureView.value + } + }, + { + "term": { + "tableType": TableType.Dynamic.value + } + }, + ] + } + } ] } }, - {"term": {"deleted": False}}, - {"exists": {"field": "schemaDefinition"}}, + {"bool": {"should": [{"term": {"deleted": False}}]}}, + { + "bool": { + "should": [{"exists": {"field": "schemaDefinition"}}] + } + }, ] } } diff --git a/ingestion/src/metadata/ingestion/source/database/stored_procedures_mixin.py b/ingestion/src/metadata/ingestion/source/database/stored_procedures_mixin.py index f7190c696811..7dba89daefd6 100644 --- a/ingestion/src/metadata/ingestion/source/database/stored_procedures_mixin.py +++ b/ingestion/src/metadata/ingestion/source/database/stored_procedures_mixin.py @@ -244,8 +244,18 @@ def procedure_lineage_generator(self) -> Iterable[ProcedureAndQuery]: "query": { "bool": { "must": [ - {"term": {"service.name.keyword": self.service_name}}, - {"term": {"deleted": False}}, + { + "bool": { + "should": [ + { + "term": { + "service.name.keyword": self.service_name + } + } + ] + } + }, + {"bool": {"should": [{"term": {"deleted": False}}]}}, ] } } From 6b039ad76dbc5461cf7b621c3ea58c091d484758 Mon Sep 17 00:00:00 2001 From: Pranita Fulsundar Date: Wed, 29 Jan 2025 20:10:06 +0530 Subject: [PATCH 200/294] minor: domain owner edit icon and display of announcement message (#19593) (cherry picked from commit 060ab6e6acc20b2faf775058149aa49eff028d2f) --- .../DocumentationTab.component.tsx | 46 +++++++++++-------- .../AnnouncementCard/AnnouncementCard.tsx | 13 +++--- 2 files changed, 33 insertions(+), 26 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainTabs/DocumentationTab/DocumentationTab.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainTabs/DocumentationTab/DocumentationTab.component.tsx index 7bca1004ce42..a61d3c64c339 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainTabs/DocumentationTab/DocumentationTab.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainTabs/DocumentationTab/DocumentationTab.component.tsx @@ -197,26 +197,32 @@ const DocumentationTab = ({ {t('label.owner-plural')} - {editOwnerPermission && domain.owners && ( - handleUpdatedOwner(updatedUser)}> - -