Fixes #19690: Add QlikCloud dashboard filter by space name type (#20315)

This commit is contained in:
Mohit Tilala 2025-04-01 13:00:50 +05:30 committed by GitHub
parent 1ee5b63a5d
commit 7ad97afa62
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 667 additions and 55 deletions

View File

@ -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")

View File

@ -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,

View File

@ -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"""

View File

@ -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
)

View File

@ -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 %}

View File

@ -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

View File

@ -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",

View File

@ -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`
$$

View File

@ -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) => {

View File

@ -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('');
});
});

View File

@ -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}
/>

View File

@ -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
*

View File

@ -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: {},
};

View File

@ -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);