mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-08-18 14:06:59 +00:00
This commit is contained in:
parent
1ee5b63a5d
commit
7ad97afa62
@ -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")
|
||||
|
@ -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,
|
||||
|
@ -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"""
|
||||
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -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://<TenantURL>.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 %}
|
||||
|
||||
|
@ -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://<TenantURL>.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://<TenantURL>.qlikcloud.com
|
||||
```
|
||||
```yaml {% srNumber=3 %}
|
||||
spaceTypes: ["Personal", "Shared", "Managed"]
|
||||
```
|
||||
|
||||
{% partial file="/v1.7/connectors/yaml/dashboard/source-config.md" /%}
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 119 KiB |
Binary file not shown.
Before Width: | Height: | Size: 212 KiB After Width: | Height: | Size: 222 KiB |
Binary file not shown.
Before Width: | Height: | Size: 348 KiB After Width: | Height: | Size: 363 KiB |
@ -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",
|
||||
|
@ -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://<TenantURL>.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`
|
||||
$$
|
@ -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) => {
|
||||
|
@ -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(
|
||||
<WorkflowArrayFieldTemplate {...mockWorkflowArrayFieldTemplateProps} />
|
||||
);
|
||||
|
||||
const arrayField = screen.getByTestId('workflow-array-field-template');
|
||||
|
||||
expect(arrayField).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should display field title when uniqueItems is not true', () => {
|
||||
render(
|
||||
<WorkflowArrayFieldTemplate {...mockWorkflowArrayFieldTemplateProps} />
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText('Workflow Array Field Template')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should not display field title when uniqueItems is true', () => {
|
||||
render(
|
||||
<WorkflowArrayFieldTemplate
|
||||
{...mockWorkflowArrayFieldTemplateProps}
|
||||
schema={{
|
||||
...mockWorkflowArrayFieldTemplateProps.schema,
|
||||
uniqueItems: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByText('Workflow Array Field Template')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should call handleFocus with correct id when focused', () => {
|
||||
render(
|
||||
<WorkflowArrayFieldTemplate
|
||||
{...mockWorkflowArrayFieldTemplateProps}
|
||||
formContext={{ handleFocus: mockOnFocus }}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<WorkflowArrayFieldTemplate
|
||||
{...mockWorkflowArrayFieldTemplateProps}
|
||||
formContext={{ handleBlur: mockOnBlur }}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<WorkflowArrayFieldTemplate
|
||||
{...mockWorkflowArrayFieldTemplateProps}
|
||||
disabled
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<WorkflowArrayFieldTemplate
|
||||
{...mockWorkflowArrayFieldTemplateProps}
|
||||
formData={[]}
|
||||
idSchema={{ $id: 'root/schemaFilterPattern/includes' } as IdSchema}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<WorkflowArrayFieldTemplate
|
||||
{...mockWorkflowArrayFieldTemplateProps}
|
||||
formData={[]}
|
||||
idSchema={{ $id: 'root/spaceTypes' } as IdSchema}
|
||||
/>
|
||||
);
|
||||
|
||||
const placeholderText = screen
|
||||
.getByTestId('workflow-array-field-template')
|
||||
.querySelector('span.ant-select-selection-placeholder');
|
||||
|
||||
expect(placeholderText).toHaveTextContent('');
|
||||
});
|
||||
});
|
@ -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 (
|
||||
<Row>
|
||||
<Col span={24}>
|
||||
<Typography>{startCase(props.name)}</Typography>
|
||||
{/* Display field title only if uniqueItems is not true to remove duplicate title set
|
||||
automatically due to an unknown behavior */}
|
||||
{props.schema.uniqueItems !== true && (
|
||||
<Typography>{startCase(props.name)}</Typography>
|
||||
)}
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Select
|
||||
className="m-t-xss w-full"
|
||||
data-testid="workflow-array-field-template"
|
||||
disabled={props.disabled}
|
||||
id={props.idSchema.$id}
|
||||
mode="tags"
|
||||
open={false}
|
||||
placeholder={t('message.filter-pattern-placeholder')}
|
||||
value={props.formData ?? []}
|
||||
id={id}
|
||||
mode={options ? 'multiple' : 'tags'}
|
||||
open={options ? undefined : false}
|
||||
options={options}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onBlur={() => props.onBlur(id, value)}
|
||||
onChange={(value) => props.onChange(value)}
|
||||
onFocus={handleFocus}
|
||||
/>
|
||||
|
@ -33,7 +33,11 @@ export interface QlikCloudConnection {
|
||||
/**
|
||||
* Regex to exclude or include projects that matches the pattern.
|
||||
*/
|
||||
projectFilterPattern?: FilterPattern;
|
||||
projectFilterPattern?: FilterPattern;
|
||||
/**
|
||||
* Space types of Qlik Cloud to filter the dashboards ingested into the platform.
|
||||
*/
|
||||
spaceTypes?: SpaceType[];
|
||||
supportsMetadataExtraction?: boolean;
|
||||
/**
|
||||
* token to connect to Qlik Cloud.
|
||||
@ -67,6 +71,12 @@ export interface FilterPattern {
|
||||
includes?: string[];
|
||||
}
|
||||
|
||||
export enum SpaceType {
|
||||
Managed = "Managed",
|
||||
Personal = "Personal",
|
||||
Shared = "Shared",
|
||||
}
|
||||
|
||||
/**
|
||||
* Service Type
|
||||
*
|
||||
|
@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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 { IdSchema } from '@rjsf/utils';
|
||||
|
||||
export const MOCK_WORKFLOW_ARRAY_FIELD_TEMPLATE = {
|
||||
autofocus: false,
|
||||
disabled: false,
|
||||
formContext: { handleFocus: undefined },
|
||||
formData: ['test_value1', 'test_value2'],
|
||||
hideError: undefined,
|
||||
id: 'root/workflow-array-field-template',
|
||||
name: 'workflowArrayFieldTemplate',
|
||||
options: {
|
||||
enumOptions: [],
|
||||
},
|
||||
placeholder: '',
|
||||
rawErrors: undefined,
|
||||
readonly: false,
|
||||
required: false,
|
||||
idSchema: {
|
||||
$id: 'root/workflow-array-field-template',
|
||||
} as IdSchema,
|
||||
idSeparator: '/',
|
||||
schema: {
|
||||
description: 'this is array field',
|
||||
title: 'ArrayField',
|
||||
},
|
||||
uiSchema: {},
|
||||
};
|
@ -12,8 +12,8 @@
|
||||
*/
|
||||
|
||||
import { InfoCircleOutlined } from '@ant-design/icons';
|
||||
import { Col, Input, Row, Space, Tooltip, Typography } from 'antd';
|
||||
import { get, isEmpty, isNull, isObject, startCase } from 'lodash';
|
||||
import { Col, Input, Row, Select, Space, Tooltip, Typography } from 'antd';
|
||||
import { get, isArray, isEmpty, isNull, isObject, startCase } from 'lodash';
|
||||
import React, { ReactNode } from 'react';
|
||||
import ErrorPlaceHolder from '../components/common/ErrorWithPlaceholder/ErrorPlaceHolder';
|
||||
import { FILTER_PATTERN_BY_SERVICE_TYPE } from '../constants/ServiceConnection.constants';
|
||||
@ -52,13 +52,27 @@ const renderInputField = (
|
||||
</Space>
|
||||
</Col>
|
||||
<Col span={16}>
|
||||
<Input
|
||||
readOnly
|
||||
className="w-full border-none"
|
||||
data-testid="input-field"
|
||||
type={format === 'password' ? 'password' : 'text'}
|
||||
value={value}
|
||||
/>
|
||||
{isArray(value) ? (
|
||||
<Select
|
||||
allowClear={false}
|
||||
bordered={false}
|
||||
className="w-full border-none"
|
||||
data-testid="input-field"
|
||||
mode="multiple"
|
||||
open={false}
|
||||
removeIcon={null}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
value={value}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
readOnly
|
||||
className="w-full border-none"
|
||||
data-testid="input-field"
|
||||
type={format === 'password' ? 'password' : 'text'}
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
@ -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);
|
||||
|
Loading…
x
Reference in New Issue
Block a user