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.
@ -37,7 +37,7 @@ To deploy OpenMetadata, check the Deployment guides.
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***`
@ -75,6 +75,16 @@ 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,7 +15,16 @@ 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")
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.
@ -24,9 +33,9 @@ $$
$$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);