diff --git a/ingestion/src/metadata/ingestion/source/dashboard/qlikcloud/client.py b/ingestion/src/metadata/ingestion/source/dashboard/qlikcloud/client.py index 181bd3a44ee..707719d2b52 100644 --- a/ingestion/src/metadata/ingestion/source/dashboard/qlikcloud/client.py +++ b/ingestion/src/metadata/ingestion/source/dashboard/qlikcloud/client.py @@ -29,6 +29,8 @@ from metadata.ingestion.source.dashboard.qlikcloud.constants import ( from metadata.ingestion.source.dashboard.qlikcloud.models import ( QlikApp, QlikAppResponse, + QlikSpace, + QlikSpaceResponse, ) from metadata.ingestion.source.dashboard.qliksense.models import ( QlikDataModelResult, @@ -105,7 +107,7 @@ class QlikCloudClient: def get_dashboard_charts(self, dashboard_id: str) -> List[QlikSheet]: """ - Get dahsboard chart list + Get dashboard chart list """ try: self.connect_websocket(dashboard_id) @@ -164,7 +166,7 @@ class QlikCloudClient: def get_dashboard_models(self) -> List[QlikTable]: """ - Get dahsboard data models + Get dashboard data models """ try: self._websocket_send_request(APP_LOADMODEL_REQ) @@ -181,3 +183,24 @@ class QlikCloudClient: logger.debug(traceback.format_exc()) logger.warning("Failed to fetch the dashboard datamodels") return [] + + def get_projects_list(self) -> Iterable[QlikSpace]: + """ + Get list of all spaces + """ + try: + link = f"/v1/spaces?limit={API_LIMIT}" + while True: + resp_spaces = self.client.get(link) + if resp_spaces: + resp = QlikSpaceResponse(**resp_spaces) + yield from resp.spaces + if resp.links and resp.links.next and resp.links.next.href: + link = resp.links.next.href.replace( + f"{self.config.hostPort}{API_VERSION}", "" + ) + else: + break + except Exception: + logger.debug(traceback.format_exc()) + logger.warning("Failed to fetch the space list") diff --git a/ingestion/src/metadata/ingestion/source/dashboard/qlikcloud/metadata.py b/ingestion/src/metadata/ingestion/source/dashboard/qlikcloud/metadata.py index 5dd60d1efeb..353149d8f52 100644 --- a/ingestion/src/metadata/ingestion/source/dashboard/qlikcloud/metadata.py +++ b/ingestion/src/metadata/ingestion/source/dashboard/qlikcloud/metadata.py @@ -11,7 +11,7 @@ """QlikCloud source module""" import traceback -from typing import Iterable, List, Optional +from typing import Dict, Iterable, List, Optional from metadata.generated.schema.api.data.createChart import CreateChartRequest from metadata.generated.schema.api.data.createDashboard import CreateDashboardRequest @@ -42,11 +42,15 @@ from metadata.ingestion.api.models import Either from metadata.ingestion.api.steps import InvalidSourceException from metadata.ingestion.ometa.ometa_api import OpenMetadata from metadata.ingestion.source.dashboard.qlikcloud.client import QlikCloudClient -from metadata.ingestion.source.dashboard.qlikcloud.models import QlikApp +from metadata.ingestion.source.dashboard.qlikcloud.models import ( + QlikApp, + QlikSpace, + QlikSpaceType, +) from metadata.ingestion.source.dashboard.qliksense.metadata import QliksenseSource from metadata.ingestion.source.dashboard.qliksense.models import QlikTable from metadata.utils import fqn -from metadata.utils.filters import filter_by_chart +from metadata.utils.filters import filter_by_chart, filter_by_project from metadata.utils.fqn import build_es_fqn_search_string from metadata.utils.helpers import clean_uri from metadata.utils.logger import ingestion_logger @@ -81,9 +85,41 @@ class QlikcloudSource(QliksenseSource): metadata: OpenMetadata, ): super().__init__(config, metadata) + self.projects_map: Dict[str, QlikSpace] = {} self.collections: List[QlikApp] = [] self.data_models: List[QlikTable] = [] + def prepare(self): + """ + Get all spaces/projects from QlikCloud to filter out dashboards. + """ + spaces = self.client.get_projects_list() + for space in spaces: + self.projects_map[space.id] = space + self.projects_map[""] = QlikSpace( + name="Personal", + description="Represents personal space of QlikCloud.", + id="", # dashboards under personal space have spaceId="" + type=QlikSpaceType.PERSONAL, + ) + + return super().prepare() + + def filter_projects_by_type(self, project: QlikSpace) -> bool: + """ + Filter space based on space types configured in connection config. + """ + spaceTypes = self.service_connection.spaceTypes + if spaceTypes is None: + return False + return project.type.value not in [space_type.value for space_type in spaceTypes] + + def is_personal_project(self, project: QlikSpace) -> bool: + """ + Check if space is a personal space. + """ + return project.type == QlikSpaceType.PERSONAL + def filter_draft_dashboard(self, dashboard: QlikApp) -> bool: # When only published(non-draft) dashboards are allowed, filter dashboard based on "published" flag from QlikApp return (not self.source_config.includeDraftDashboard) and ( @@ -98,6 +134,15 @@ class QlikcloudSource(QliksenseSource): if self.filter_draft_dashboard(dashboard): # Skip unpublished dashboards continue + project = self.projects_map[dashboard.space_id] + if self.filter_projects_by_type(project): + # Skip dashboard based on space type filter + continue + if not self.is_personal_project(project) and filter_by_project( + self.service_connection.projectFilterPattern, project.name + ): + # Skip dashboard based on project filter pattern + continue # clean data models for next iteration self.data_models = [] yield dashboard @@ -127,9 +172,11 @@ class QlikcloudSource(QliksenseSource): name=EntityName(dashboard_details.id), sourceUrl=SourceUrl(dashboard_url), displayName=dashboard_details.name, - description=Markdown(dashboard_details.description) - if dashboard_details.description - else None, + description=( + Markdown(dashboard_details.description) + if dashboard_details.description + else None + ), project=self.context.get().project_name, charts=[ FullyQualifiedEntityName( @@ -248,9 +295,11 @@ class QlikcloudSource(QliksenseSource): right=CreateChartRequest( name=EntityName(chart.qInfo.qId), displayName=chart.qMeta.title, - description=Markdown(chart.qMeta.description) - if chart.qMeta.description - else None, + description=( + Markdown(chart.qMeta.description) + if chart.qMeta.description + else None + ), chartType=ChartType.Other, sourceUrl=SourceUrl(chart_url), service=self.context.get().dashboard_service, diff --git a/ingestion/src/metadata/ingestion/source/dashboard/qlikcloud/models.py b/ingestion/src/metadata/ingestion/source/dashboard/qlikcloud/models.py index 1e2ccea2cc5..11046f785fd 100644 --- a/ingestion/src/metadata/ingestion/source/dashboard/qlikcloud/models.py +++ b/ingestion/src/metadata/ingestion/source/dashboard/qlikcloud/models.py @@ -11,9 +11,44 @@ """ QlikCloud Models """ +from enum import Enum from typing import List, Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator + + +class QlikSpaceType(Enum): + MANAGED = "Managed" + SHARED = "Shared" + PERSONAL = "Personal" + + +# Space Models +class QlikSpace(BaseModel): + """QlikCloud Space Model""" + + name: Optional[str] = None + description: Optional[str] = None + id: str + type: QlikSpaceType + + # Field validator for normalizing and validating space type + @field_validator("type", mode="before") + @classmethod + def normalize_and_validate_type(cls, value): + """ + Normalize the space type by capitalizing the input value and + ensure it corresponds to a valid QlikSpaceType enum. + + Args: + value (str): The space type to validate. + + Returns: + QlikSpaceType: The corresponding enum member of QlikSpaceType. + """ + if isinstance(value, str): + value = value.capitalize() + return QlikSpaceType(value) # App Models @@ -24,6 +59,7 @@ class QlikApp(BaseModel): name: Optional[str] = None id: str app_id: Optional[str] = Field(None, alias="resourceId") + space_id: Optional[str] = Field("", alias="spaceId") published: Optional[bool] = None @@ -35,6 +71,13 @@ class QlikLinks(BaseModel): next: Optional[QlikLink] = None +class QlikSpaceResponse(BaseModel): + """QlikCloud Spaces List""" + + spaces: Optional[List[QlikSpace]] = Field(None, alias="data") + links: Optional[QlikLinks] = None + + class QlikAppResponse(BaseModel): """QlikCloud Apps List""" diff --git a/ingestion/tests/unit/topology/dashboard/test_qlikcloud.py b/ingestion/tests/unit/topology/dashboard/test_qlikcloud.py index 35a2ababc5e..6eb97eb6a51 100644 --- a/ingestion/tests/unit/topology/dashboard/test_qlikcloud.py +++ b/ingestion/tests/unit/topology/dashboard/test_qlikcloud.py @@ -20,6 +20,9 @@ import pytest from metadata.generated.schema.api.data.createChart import CreateChartRequest from metadata.generated.schema.api.data.createDashboard import CreateDashboardRequest +from metadata.generated.schema.entity.services.connections.dashboard.qlikCloudConnection import ( + SpaceType, +) from metadata.generated.schema.entity.services.dashboardService import ( DashboardConnection, DashboardService, @@ -33,7 +36,11 @@ from metadata.ingestion.api.models import Either from metadata.ingestion.ometa.ometa_api import OpenMetadata from metadata.ingestion.source.dashboard.qlikcloud.client import QlikCloudClient from metadata.ingestion.source.dashboard.qlikcloud.metadata import QlikcloudSource -from metadata.ingestion.source.dashboard.qlikcloud.models import QlikApp +from metadata.ingestion.source.dashboard.qlikcloud.models import ( + QlikApp, + QlikSpace, + QlikSpaceType, +) from metadata.ingestion.source.dashboard.qliksense.models import ( QlikSheet, QlikSheetInfo, @@ -69,6 +76,43 @@ mock_qlikcloud_config = { }, } +MOCK_MANAGED_PROJECT_1_ID = "100" +MOCK_MANAGED_PROJECT_2_ID = "101" +MOCK_SHARED_PROJECT_1_ID = "102" +MOCK_PERSONAL_PROJECT_ID = "" +MOCK_PROJECTS = [ + QlikSpace( + name="managed-space-1", + description="managed space", + id=MOCK_MANAGED_PROJECT_1_ID, + type=QlikSpaceType.MANAGED, + ), + QlikSpace( + name="managed-space-2", + description="managed space", + id=MOCK_MANAGED_PROJECT_2_ID, + type=QlikSpaceType.MANAGED, + ), + QlikSpace( + name="shared-space-1", + description="shared space", + id=MOCK_SHARED_PROJECT_1_ID, + type=QlikSpaceType.SHARED, + ), +] +MOCK_PERSONAL_PROJECT = QlikSpace( + name="Personal", + description="Represents personal space of QlikCloud.", + id=MOCK_PERSONAL_PROJECT_ID, + type=QlikSpaceType.PERSONAL, +) +MOCK_PROJECTS_MAP = { + MOCK_MANAGED_PROJECT_1_ID: MOCK_PROJECTS[0], + MOCK_MANAGED_PROJECT_2_ID: MOCK_PROJECTS[1], + MOCK_SHARED_PROJECT_1_ID: MOCK_PROJECTS[2], + MOCK_PERSONAL_PROJECT_ID: MOCK_PERSONAL_PROJECT, +} + MOCK_DASHBOARD_SERVICE = DashboardService( id="c3eb265f-5445-4ad3-ba5e-797d3a3071bb", name="qlikcloud_source_test", @@ -129,25 +173,52 @@ EXPECTED_CHARTS = [ MOCK_DASHBOARDS = [ QlikApp( - name="sample unpublished dashboard", + name="sample managed app's unpublished dashboard", id="201", - description="sample unpublished dashboard", + description="sample managed app's unpublished dashboard", published=False, + spaceId=MOCK_MANAGED_PROJECT_1_ID, ), QlikApp( - name="sample published dashboard", + name="sample managed app's published dashboard", id="202", - description="sample published dashboard", + description="sample managed app's published dashboard", published=True, + spaceId=MOCK_MANAGED_PROJECT_1_ID, ), QlikApp( - name="sample published dashboard", + name="sample managed app's published dashboard", id="203", - description="sample published dashboard", + description="sample managed app's published dashboard", published=True, + spaceId=MOCK_MANAGED_PROJECT_2_ID, + ), + QlikApp( + name="sample shared app's unpublished dashboard", + id="204", + description="sample shared app's unpublished dashboard", + published=False, + spaceId=MOCK_SHARED_PROJECT_1_ID, + ), + QlikApp( + name="sample shared app's published dashboard", + id="205", + description="sample shared app's published dashboard", + published=True, + spaceId=MOCK_SHARED_PROJECT_1_ID, + ), + QlikApp( + name="sample personal app's published dashboard", + id="206", + description="sample personal app's published dashboard", + published=True, + spaceId=MOCK_PERSONAL_PROJECT_ID, ), ] -DRAFT_DASHBOARDS_IN_MOCK_DASHBOARDS = 1 +DRAFT_DASHBOARDS_IN_MOCK_DASHBOARDS = 2 +MANAGED_APP_DASHBOARD_IN_MOCK_DASHBOARDS = 3 +SHARED_APP_DASHBOARD_IN_MOCK_DASHBOARDS = 2 +PERSONAL_APP_DASHBOARD_IN_MOCK_DASHBOARDS = 1 class QlikCloudUnitTest(TestCase): @@ -176,6 +247,38 @@ class QlikCloudUnitTest(TestCase): ] = MOCK_DASHBOARD_SERVICE.fullyQualifiedName.root self.qlikcloud.context.get().__dict__["project_name"] = None + @pytest.mark.order(0) + def test_prepare(self): + with patch.object( + QlikCloudClient, "get_projects_list", return_value=MOCK_PROJECTS + ): + self.qlikcloud.prepare() + + assert len(self.qlikcloud.projects_map) == len(MOCK_PROJECTS_MAP), ( + f"Expected projects_map to have {len(MOCK_PROJECTS_MAP) + 1} entries, " + f"but got {len(self.qlikcloud.projects_map)}" + ) + + for space_id, expected_space in MOCK_PROJECTS_MAP.items(): + mapped_space = self.qlikcloud.projects_map.get(space_id) + assert ( + mapped_space == expected_space + ), f"Expected {expected_space} for spaceId {space_id}, but got {mapped_space}" + + personal_space = self.qlikcloud.projects_map.get("") + assert ( + personal_space is not None + ), "Expected the 'Personal' space to be added to the map." + assert ( + personal_space.name == "Personal" + ), "The 'Personal' space name is incorrect." + assert ( + personal_space.id == "" + ), "The 'Personal' space id should be empty string." + assert ( + personal_space.type == QlikSpaceType.PERSONAL + ), "The 'Personal' space type is incorrect." + @pytest.mark.order(1) def test_dashboard(self): dashboard_list = [] @@ -215,3 +318,102 @@ class QlikCloudUnitTest(TestCase): if self.qlikcloud.filter_draft_dashboard(dashboard): draft_dashboards_count += 1 assert draft_dashboards_count == DRAFT_DASHBOARDS_IN_MOCK_DASHBOARDS + + @pytest.mark.order(5) + def test_managed_app_dashboard(self): + with patch.object( + QlikCloudClient, "get_projects_list", return_value=MOCK_PROJECTS + ): + self.qlikcloud.prepare() + + managed_app_dashboards_count = 0 + self.qlikcloud.service_connection.spaceTypes = [ + SpaceType.Shared, + SpaceType.Personal, + ] + for dashboard in MOCK_DASHBOARDS: + space = self.qlikcloud.projects_map[dashboard.space_id] + if self.qlikcloud.filter_projects_by_type(space): + managed_app_dashboards_count += 1 + assert managed_app_dashboards_count == MANAGED_APP_DASHBOARD_IN_MOCK_DASHBOARDS + + @pytest.mark.order(6) + def test_shared_app_dashboard(self): + with patch.object( + QlikCloudClient, "get_projects_list", return_value=MOCK_PROJECTS + ): + self.qlikcloud.prepare() + + shared_app_dashboards_count = 0 + self.qlikcloud.service_connection.spaceTypes = [ + SpaceType.Managed, + SpaceType.Personal, + ] + for dashboard in MOCK_DASHBOARDS: + space = self.qlikcloud.projects_map[dashboard.space_id] + if self.qlikcloud.filter_projects_by_type(space): + shared_app_dashboards_count += 1 + assert shared_app_dashboards_count == SHARED_APP_DASHBOARD_IN_MOCK_DASHBOARDS + + @pytest.mark.order(7) + def test_personal_app_dashboard(self): + with patch.object( + QlikCloudClient, "get_projects_list", return_value=MOCK_PROJECTS + ): + self.qlikcloud.prepare() + + personal_app_dashboards_count = 0 + self.qlikcloud.service_connection.spaceTypes = [ + SpaceType.Managed, + SpaceType.Shared, + ] + for dashboard in MOCK_DASHBOARDS: + space = self.qlikcloud.projects_map[dashboard.space_id] + if self.qlikcloud.filter_projects_by_type(space): + personal_app_dashboards_count += 1 + assert ( + personal_app_dashboards_count == PERSONAL_APP_DASHBOARD_IN_MOCK_DASHBOARDS + ) + + @pytest.mark.order(8) + def test_space_type_filter_dashboard(self): + with patch.object( + QlikCloudClient, "get_projects_list", return_value=MOCK_PROJECTS + ): + self.qlikcloud.prepare() + + space_type_filtered_dashboards_count = 0 + self.qlikcloud.service_connection.spaceTypes = [SpaceType.Personal] + for dashboard in MOCK_DASHBOARDS: + space = self.qlikcloud.projects_map[dashboard.space_id] + if self.qlikcloud.filter_projects_by_type(space): + space_type_filtered_dashboards_count += 1 + assert ( + space_type_filtered_dashboards_count + == MANAGED_APP_DASHBOARD_IN_MOCK_DASHBOARDS + + SHARED_APP_DASHBOARD_IN_MOCK_DASHBOARDS + ) + + space_type_filtered_dashboards_count = 0 + self.qlikcloud.service_connection.spaceTypes = [SpaceType.Shared] + for dashboard in MOCK_DASHBOARDS: + space = self.qlikcloud.projects_map[dashboard.space_id] + if self.qlikcloud.filter_projects_by_type(space): + space_type_filtered_dashboards_count += 1 + assert ( + space_type_filtered_dashboards_count + == MANAGED_APP_DASHBOARD_IN_MOCK_DASHBOARDS + + PERSONAL_APP_DASHBOARD_IN_MOCK_DASHBOARDS + ) + + space_type_filtered_dashboards_count = 0 + self.qlikcloud.service_connection.spaceTypes = [SpaceType.Managed] + for dashboard in MOCK_DASHBOARDS: + space = self.qlikcloud.projects_map[dashboard.space_id] + if self.qlikcloud.filter_projects_by_type(space): + space_type_filtered_dashboards_count += 1 + assert ( + space_type_filtered_dashboards_count + == SHARED_APP_DASHBOARD_IN_MOCK_DASHBOARDS + + PERSONAL_APP_DASHBOARD_IN_MOCK_DASHBOARDS + ) diff --git a/openmetadata-docs/content/v1.7.x-SNAPSHOT/connectors/dashboard/qlikcloud/index.md b/openmetadata-docs/content/v1.7.x-SNAPSHOT/connectors/dashboard/qlikcloud/index.md index 1171b6f78ba..907ae9af420 100644 --- a/openmetadata-docs/content/v1.7.x-SNAPSHOT/connectors/dashboard/qlikcloud/index.md +++ b/openmetadata-docs/content/v1.7.x-SNAPSHOT/connectors/dashboard/qlikcloud/index.md @@ -7,8 +7,8 @@ slug: /connectors/dashboard/qlikcloud name="Qlik Cloud" stage="PROD" platform="OpenMetadata" - availableFeatures=["Dashboards", "Charts", "Datamodels", "Lineage", "Column Lineage"] - unavailableFeatures=["Owners", "Tags", "Projects"] + availableFeatures=["Projects", "Dashboards", "Charts", "Datamodels", "Lineage", "Column Lineage"] + unavailableFeatures=["Owners", "Tags"] / %} In this section, we provide guides and references to use the Qlik Cloud connector. @@ -30,14 +30,14 @@ To deploy OpenMetadata, check the Deployment guides. ## Metadata Ingestion -{% partial - file="/v1.7/connectors/metadata-ingestion-ui.md" +{% partial + file="/v1.7/connectors/metadata-ingestion-ui.md" variables={ connector: "QlikCloud", selectServicePath: "/images/v1.7/connectors/qlikcloud/select-service.png", addNewServicePath: "/images/v1.7/connectors/qlikcloud/add-new-service.png", serviceConnectionPath: "/images/v1.7/connectors/qlikcloud/service-connection.png", -} + } /%} {% stepsContainer %} @@ -47,6 +47,7 @@ To deploy OpenMetadata, check the Deployment guides. - **Qlik Cloud Host Port**: This field refers to the base url of your Qlik Cloud Portal, will be used for generating the redirect links for dashboards and charts. Example: `https://.qlikcloud.com` - **Qlik Cloud API Token**: Enter the API token for Qlik Cloud APIs access. Refer to [this](https://help.qlik.com/en-US/cloud-services/Subsystems/Hub/Content/Sense_Hub/Admin/mc-generate-api-keys.htm) document for more details about. Example: `eyJhbGciOiJFU***`. +- **Qlik Cloud Space Types**: Select relevant space types of Qlik Cloud to filter the dashboards ingested into the platform. Example: `Personal`, `Shared`, `Managed`. {% /extraContent %} diff --git a/openmetadata-docs/content/v1.7.x-SNAPSHOT/connectors/dashboard/qlikcloud/yaml.md b/openmetadata-docs/content/v1.7.x-SNAPSHOT/connectors/dashboard/qlikcloud/yaml.md index 6a061b6cacb..5a1ec23f8ab 100644 --- a/openmetadata-docs/content/v1.7.x-SNAPSHOT/connectors/dashboard/qlikcloud/yaml.md +++ b/openmetadata-docs/content/v1.7.x-SNAPSHOT/connectors/dashboard/qlikcloud/yaml.md @@ -7,8 +7,8 @@ slug: /connectors/dashboard/qlikcloud/yaml name="Qlik Cloud" stage="PROD" platform="OpenMetadata" - availableFeatures=["Dashboards", "Charts", "Datamodels", "Lineage"] - unavailableFeatures=["Owners", "Tags", "Projects"] + availableFeatures=[ "Projects", "Dashboards", "Charts", "Datamodels", "Lineage"] + unavailableFeatures=["Owners", "Tags"] / %} In this section, we provide guides and references to use the PowerBI connector. @@ -59,7 +59,7 @@ This is a sample config for Qlik Cloud: **token**: Qlik Cloud API Access Token -Enter the JWT Bearer token generated from Qlik Management Console->API-Keys . Refer to [this](https://help.qlik.com/en-US/cloud-services/Subsystems/Hub/Content/Sense_Hub/Admin/mc-generate-api-keys.htm) document for more details about +Enter the JWT Bearer token generated from Qlik Management Console->API-Keys . Refer to [this](https://help.qlik.com/en-US/cloud-services/Subsystems/Hub/Content/Sense_Hub/Admin/mc-generate-api-keys.htm) document for more details. Example: `eyJhbGciOiJFU***` @@ -69,12 +69,22 @@ Example: `eyJhbGciOiJFU***` **hostPort**: Qlik Cloud Tenant URL -This field refers to the base url of your Qlik Cloud Portal, will be used for generating the redirect links for dashboards and charts. +This field refers to the base url of your Qlik Cloud Portal, will be used for generating the redirect links for dashboards and charts. Example: `https://.qlikcloud.com` {% /codeInfo %} +{% codeInfo srNumber=3 %} + +**spaceTypes**: Qlik Cloud Space Types + +Select relevant space types of Qlik Cloud to filter the dashboards ingested into the platform. + +Example: `Personal`, `Shared`, `Managed` + +{% /codeInfo %} + {% partial file="/v1.7/connectors/yaml/dashboard/source-config-def.md" /%} @@ -98,7 +108,10 @@ source: token: eyJhbGciOiJFU*** ``` ```yaml {% srNumber=2 %} - hostPort: http://localhost:2000 + hostPort: https://.qlikcloud.com +``` +```yaml {% srNumber=3 %} + spaceTypes: ["Personal", "Shared", "Managed"] ``` {% partial file="/v1.7/connectors/yaml/dashboard/source-config.md" /%} diff --git a/openmetadata-docs/images/v1.7/connectors/qlikcloud/add-new-service.png b/openmetadata-docs/images/v1.7/connectors/qlikcloud/add-new-service.png index 2dfa1e066d2..1df2a4409bc 100644 Binary files a/openmetadata-docs/images/v1.7/connectors/qlikcloud/add-new-service.png and b/openmetadata-docs/images/v1.7/connectors/qlikcloud/add-new-service.png differ diff --git a/openmetadata-docs/images/v1.7/connectors/qlikcloud/select-service.png b/openmetadata-docs/images/v1.7/connectors/qlikcloud/select-service.png index a4870cc298b..f7f1d4d2830 100644 Binary files a/openmetadata-docs/images/v1.7/connectors/qlikcloud/select-service.png and b/openmetadata-docs/images/v1.7/connectors/qlikcloud/select-service.png differ diff --git a/openmetadata-docs/images/v1.7/connectors/qlikcloud/service-connection.png b/openmetadata-docs/images/v1.7/connectors/qlikcloud/service-connection.png index f8a18d91529..331a14ad280 100644 Binary files a/openmetadata-docs/images/v1.7/connectors/qlikcloud/service-connection.png and b/openmetadata-docs/images/v1.7/connectors/qlikcloud/service-connection.png differ diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/services/connections/dashboard/qlikCloudConnection.json b/openmetadata-spec/src/main/resources/json/schema/entity/services/connections/dashboard/qlikCloudConnection.json index 1c1e8028a98..b7ac94a746b 100644 --- a/openmetadata-spec/src/main/resources/json/schema/entity/services/connections/dashboard/qlikCloudConnection.json +++ b/openmetadata-spec/src/main/resources/json/schema/entity/services/connections/dashboard/qlikCloudConnection.json @@ -33,6 +33,18 @@ "type": "string", "format": "uri" }, + "spaceTypes": { + "title": "Space Types", + "description": "Space types of Qlik Cloud to filter the dashboards ingested into the platform.", + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "enum": ["Managed", "Shared", "Personal"] + }, + "default": ["Managed", "Shared", "Personal"], + "minItems": 1 + }, "dashboardFilterPattern": { "description": "Regex to exclude or include dashboards that matches the pattern.", "$ref": "../../../../type/filterPattern.json#/definitions/filterPattern", diff --git a/openmetadata-ui/src/main/resources/ui/public/locales/en-US/Dashboard/QlikCloud.md b/openmetadata-ui/src/main/resources/ui/public/locales/en-US/Dashboard/QlikCloud.md index 793f148609f..81b173ecc4f 100644 --- a/openmetadata-ui/src/main/resources/ui/public/locales/en-US/Dashboard/QlikCloud.md +++ b/openmetadata-ui/src/main/resources/ui/public/locales/en-US/Dashboard/QlikCloud.md @@ -15,18 +15,27 @@ You can find further information on the Qlik Cloud connector in the [docs](https ## Connection Details $$section -### Qlik Cloud Hostport $(id="hostPort") +### Qlik Cloud API Token $(id="token") -This field refers to the base url of your Qlik Cloud Portal, will be used for generating the redirect links for dashboards and charts. +API token for Qlik Cloud APIs access. Refer to [this](https://help.qlik.com/en-US/cloud-services/Subsystems/Hub/Content/Sense_Hub/Admin/mc-generate-api-keys.htm) document for more details. + +Example: `eyJhbGciOiJFU***` +$$ + + +$$section +### Qlik Cloud Host Port $(id="hostPort") + +This field refers to the base url of your Qlik Cloud Portal, will be used for generating the redirect links for dashboards and charts. Example: `https://.qlikcloud.com` $$ $$section -### Qlik Cloud API Token $(id="token") +### Qlik Cloud Space Types $(id="spaceTypes") -API token for Qlik Cloud APIs access. Refer to [this](https://help.qlik.com/en-US/cloud-services/Subsystems/Hub/Content/Sense_Hub/Admin/mc-generate-api-keys.htm) document for more details about +Select relevant space types of Qlik Cloud to filter the dashboards ingested into the platform. -Example: `eyJhbGciOiJFU***` +Example: `Personal`, `Shared`, `Managed` $$ \ No newline at end of file diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/ServiceConnectionDetails/ServiceConnectionDetails.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/ServiceConnectionDetails/ServiceConnectionDetails.test.tsx index 468e7e839e9..de242fc0ffe 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/ServiceConnectionDetails/ServiceConnectionDetails.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/ServiceConnectionDetails/ServiceConnectionDetails.test.tsx @@ -76,6 +76,7 @@ const databaseSchema = { password: 'testPassword', username: 'testUsername', database: 'test_db', + scope: ['test_scope1', 'test_scope2'], connectionArguments: { arg1: 'connectionArguments1', arg2: 'connectionArguments2', @@ -175,6 +176,17 @@ describe('ServiceConnectionDetails', () => { expect(await screen.queryAllByTestId('input-field')[3]).toHaveValue( 'test_db' ); + expect(await screen.findByText('scope:')).toBeInTheDocument(); + expect( + await screen + .queryAllByTestId('input-field')[4] + .querySelector('span[title=test_scope1]') + ).toHaveTextContent('test_scope1'); + expect( + await screen + .queryAllByTestId('input-field')[4] + .querySelector('span[title=test_scope2]') + ).toHaveTextContent('test_scope2'); }); services.map((service) => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JSONSchemaTemplate/WorkflowArrayFieldTemplate.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JSONSchemaTemplate/WorkflowArrayFieldTemplate.test.tsx new file mode 100644 index 00000000000..9ead1a10665 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JSONSchemaTemplate/WorkflowArrayFieldTemplate.test.tsx @@ -0,0 +1,149 @@ +/* + * 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 { FieldProps, IdSchema, Registry } from '@rjsf/utils'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { t } from 'i18next'; +import React from 'react'; +import { MOCK_WORKFLOW_ARRAY_FIELD_TEMPLATE } from '../../../../../mocks/Templates.mock'; +import WorkflowArrayFieldTemplate from './WorkflowArrayFieldTemplate'; + +const mockOnFocus = jest.fn(); +const mockOnBlur = jest.fn(); +const mockOnChange = jest.fn(); + +const mockBaseProps = { + onFocus: mockOnFocus, + onBlur: mockOnBlur, + onChange: mockOnChange, + registry: {} as Registry, +}; + +const mockWorkflowArrayFieldTemplateProps: FieldProps = { + ...mockBaseProps, + ...MOCK_WORKFLOW_ARRAY_FIELD_TEMPLATE, +}; + +describe('Test WorkflowArrayFieldTemplate Component', () => { + it('Should render workflow array field component', async () => { + render( + + ); + + const arrayField = screen.getByTestId('workflow-array-field-template'); + + expect(arrayField).toBeInTheDocument(); + }); + + it('Should display field title when uniqueItems is not true', () => { + render( + + ); + + expect( + screen.getByText('Workflow Array Field Template') + ).toBeInTheDocument(); + }); + + it('Should not display field title when uniqueItems is true', () => { + render( + + ); + + expect( + screen.queryByText('Workflow Array Field Template') + ).not.toBeInTheDocument(); + }); + + it('Should call handleFocus with correct id when focused', () => { + render( + + ); + + fireEvent.focus(screen.getByTestId('workflow-array-field-template')); + + expect(mockOnFocus).toHaveBeenCalledWith( + 'root/workflow-array-field-template' + ); + }); + + it('Should call handleBlur with correct id and value when blurred', () => { + render( + + ); + + fireEvent.blur(screen.getByTestId('workflow-array-field-template')); + + expect(mockOnBlur).toHaveBeenCalledWith( + 'root/workflow-array-field-template', + mockWorkflowArrayFieldTemplateProps.formData + ); + }); + + it('Should be disabled when disabled prop is true', () => { + render( + + ); + + const selectElement = screen.getByTestId('workflow-array-field-template'); + + expect(selectElement).toHaveClass('ant-select-disabled'); + }); + + it('Should set the filter pattern placeholder only for relevant fields', () => { + render( + + ); + + const placeholderText = screen.getByText( + t('message.filter-pattern-placeholder') as string + ); + + expect(placeholderText).toBeInTheDocument(); + }); + + it('Should not set filter pattern placeholder for other fields', () => { + render( + + ); + + const placeholderText = screen + .getByTestId('workflow-array-field-template') + .querySelector('span.ant-select-selection-placeholder'); + + expect(placeholderText).toHaveTextContent(''); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JSONSchemaTemplate/WorkflowArrayFieldTemplate.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JSONSchemaTemplate/WorkflowArrayFieldTemplate.tsx index cd023d0e54a..cbfae2ca3ea 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JSONSchemaTemplate/WorkflowArrayFieldTemplate.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JSONSchemaTemplate/WorkflowArrayFieldTemplate.tsx @@ -10,36 +10,71 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { FieldProps } from '@rjsf/utils'; import { Col, Row, Select, Typography } from 'antd'; import { t } from 'i18next'; -import { startCase } from 'lodash'; +import { isArray, isObject, startCase } from 'lodash'; import React from 'react'; const WorkflowArrayFieldTemplate = (props: FieldProps) => { + const isFilterPatternField = (id: string) => { + return /FilterPattern/.test(id); + }; + const handleFocus = () => { let id = props.idSchema.$id; - if (/FilterPattern/.test(id)) { + if (isFilterPatternField(id)) { id = id.split('/').slice(0, 2).join('/'); } props.formContext?.handleFocus?.(id); }; + const generateOptions = () => { + if ( + isObject(props.schema.items) && + !isArray(props.schema.items) && + props.schema.items.type === 'string' && + isArray(props.schema.items.enum) + ) { + return (props.schema.items.enum as string[]).map((option) => ({ + label: option, + value: option, + })); + } + + return undefined; + }; + + const id = props.idSchema.$id; + const value = props.formData ?? []; + const placeholder = isFilterPatternField(id) + ? t('message.filter-pattern-placeholder') + : ''; + const options = generateOptions(); + return ( - {startCase(props.name)} + {/* Display field title only if uniqueItems is not true to remove duplicate title set + automatically due to an unknown behavior */} + {props.schema.uniqueItems !== true && ( + {startCase(props.name)} + )} + {isArray(value) ? ( + + )} @@ -280,8 +294,8 @@ export const getKeyValues = ({ return null; } - // Handle non-object values - if (!isObject(value)) { + // Handle non-object and array values + if (!isObject(value) || isArray(value)) { const { description, format, title } = schemaPropertyObject[key] || {}; return renderInputField(key, value, description, format, title);