Fix: Superset add service form bug (#23569)

* fix: remove default provider from Superset connection schema

* feat: add Superset form handling and connection testing functionality

* Fix the unit test

* Fix Search Index Application spec failure
This commit is contained in:
Aniket Katkar 2025-10-03 14:25:56 +05:30 committed by GitHub
parent 06453a925d
commit 453c57df9f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 408 additions and 25 deletions

View File

@ -41,10 +41,7 @@
{
"$ref": "../database/mysqlConnection.json"
}
],
"default": {
"provider": "db"
}
]
},
"dashboardFilterPattern": {
"description": "Regex to exclude or include dashboards that matches the pattern.",

View File

@ -0,0 +1,56 @@
/*
* Copyright 2025 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { SupersetFormType } from '../support/interfaces/ServiceForm.interface';
export const supersetFormDetails1: SupersetFormType = {
hostPort: 'http://localhost:8088',
connectionType: 'SupersetApiConnection',
connection: {
provider: 'db',
username: 'test-user-1',
password: 'test-password-1',
},
};
export const supersetFormDetails2: SupersetFormType = {
hostPort: 'http://localhost:8085',
connectionType: 'SupersetApiConnection',
connection: {
provider: 'ldap',
username: 'test-user-2',
password: 'test-password-2',
},
};
export const supersetFormDetails3: SupersetFormType = {
hostPort: 'http://localhost:8086',
connectionType: 'PostgresConnection',
connection: {
username: 'test-user-3',
password: 'test-password-3',
hostPort: 'http://localhost:5432',
database: 'test_db',
scheme: 'postgresql+psycopg2',
},
};
export const supersetFormDetails4: SupersetFormType = {
hostPort: 'http://localhost:8086',
connectionType: 'MysqlConnection',
connection: {
username: 'test-user-3',
password: 'test-password-3',
hostPort: 'http://localhost:5432',
scheme: 'mysql+pymysql',
},
};

View File

@ -0,0 +1,166 @@
/*
* Copyright 2024 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { expect } from '@playwright/test';
import {
supersetFormDetails1,
supersetFormDetails2,
supersetFormDetails3,
supersetFormDetails4,
} from '../../constant/serviceForm';
import { redirectToHomePage } from '../../utils/common';
import { waitForAllLoadersToDisappear } from '../../utils/entity';
import { fillSupersetFormDetails } from '../../utils/serviceFormUtils';
import { test } from '../fixtures/pages';
test.describe('Service form functionality', async () => {
test.beforeEach(async ({ page }) => {
await redirectToHomePage(page);
});
test.describe('Superset', () => {
test('Verify form selects are working properly', async ({ page }) => {
await page.goto('/dashboardServices/add-service');
await waitForAllLoadersToDisappear(page);
await page.click(`[data-testid="Superset"]`);
await page.click('[data-testid="next-button"]');
await page.fill('[data-testid="service-name"]', 'test-superset');
await page.click('[data-testid="next-button"]');
// Fill superset form details - 1
await fillSupersetFormDetails({ page, ...supersetFormDetails1 });
const testConnectionResponse1 = page.waitForResponse(
'api/v1/automations/workflows'
);
await page.getByTestId('test-connection-btn').click();
const testConnection1 = await (await testConnectionResponse1).json();
// Verify form details submission - 1
expect(testConnection1.request.connection.config.hostPort).toEqual(
supersetFormDetails1.hostPort
);
expect(
testConnection1.request.connection.config.connection.username
).toEqual(supersetFormDetails1.connection.username);
expect(
testConnection1.request.connection.config.connection.provider
).toEqual(supersetFormDetails1.connection.provider);
await page
.getByTestId('test-connection-modal')
.getByRole('button', { name: 'OK' })
.click();
await page.waitForSelector('[data-testid="test-connection-modal"]', {
state: 'hidden',
});
// Fill superset form details - 2
await fillSupersetFormDetails({ page, ...supersetFormDetails2 });
const testConnectionResponse2 = page.waitForResponse(
'api/v1/automations/workflows'
);
await page.getByTestId('test-connection-btn').click();
const testConnection2 = await (await testConnectionResponse2).json();
// Verify form details submission - 2
expect(testConnection2.request.connection.config.hostPort).toEqual(
supersetFormDetails2.hostPort
);
expect(
testConnection2.request.connection.config.connection.username
).toEqual(supersetFormDetails2.connection.username);
expect(
testConnection2.request.connection.config.connection.provider
).toEqual(supersetFormDetails2.connection.provider);
await page
.getByTestId('test-connection-modal')
.getByRole('button', { name: 'OK' })
.click();
await page.waitForSelector('[data-testid="test-connection-modal"]', {
state: 'hidden',
});
// Fill superset form details - 3
await fillSupersetFormDetails({ page, ...supersetFormDetails3 });
const testConnectionResponse3 = page.waitForResponse(
'api/v1/automations/workflows'
);
await page.getByTestId('test-connection-btn').click();
const testConnection3 = await (await testConnectionResponse3).json();
// Verify form details submission - 3
expect(testConnection3.request.connection.config.hostPort).toEqual(
supersetFormDetails3.hostPort
);
expect(
testConnection3.request.connection.config.connection.username
).toEqual(supersetFormDetails3.connection.username);
expect(
testConnection3.request.connection.config.connection.hostPort
).toEqual(supersetFormDetails3.connection.hostPort);
expect(
testConnection3.request.connection.config.connection.database
).toEqual(supersetFormDetails3.connection.database);
expect(
testConnection3.request.connection.config.connection.scheme
).toEqual(supersetFormDetails3.connection.scheme);
await page
.getByTestId('test-connection-modal')
.getByRole('button', { name: 'OK' })
.click();
await page.waitForSelector('[data-testid="test-connection-modal"]', {
state: 'hidden',
});
// Fill superset form details - 4
await fillSupersetFormDetails({ page, ...supersetFormDetails4 });
const testConnectionResponse4 = page.waitForResponse(
'api/v1/automations/workflows'
);
await page.getByTestId('test-connection-btn').click();
const testConnection4 = await (await testConnectionResponse4).json();
// Verify form details submission - 4
expect(testConnection4.request.connection.config.hostPort).toEqual(
supersetFormDetails4.hostPort
);
expect(
testConnection4.request.connection.config.connection.username
).toEqual(supersetFormDetails4.connection.username);
expect(
testConnection4.request.connection.config.connection.hostPort
).toEqual(supersetFormDetails4.connection.hostPort);
expect(
testConnection4.request.connection.config.connection.scheme
).toEqual(supersetFormDetails4.connection.scheme);
});
});
});

View File

@ -152,7 +152,9 @@ test('Search Index Application', async ({ page }) => {
await clickOutside(page);
await page.locator('[for="root/searchIndexMappingLanguage"]').click();
await page.getByTestId('select-widget').click();
await page
.getByTestId('select-widget-root/searchIndexMappingLanguage')
.click();
await expect(page.getByTestId('select-option-JP')).toBeVisible();

View File

@ -53,8 +53,12 @@ class AirflowIngestionClass extends ServiceBaseClass {
await page.locator('#root\\/hostPort').fill(airflowHostPort);
await page
.locator('#root\\/connection__oneof_select')
.selectOption('BackendConnection');
.getByTestId('select-widget-root/connection__oneof_select')
.getByRole('combobox')
.click({ force: true });
await page.click(
'.ant-select-dropdown:visible [title="BackendConnection"]'
);
}
async deleteService(page: Page) {

View File

@ -65,9 +65,12 @@ class BigQueryIngestionClass extends ServiceBaseClass {
const projectIdTaxonomy =
process.env.PLAYWRIGHT_BQ_PROJECT_ID_TAXONOMY ?? '';
await page.selectOption(
'#root\\/credentials\\/gcpConfig__oneof_select',
'GCP Credentials Values'
await page
.getByTestId('select-widget-root/credentials/gcpConfig__oneof_select')
.getByRole('combobox')
.click({ force: true });
await page.click(
'.ant-select-dropdown:visible [title="GCP Credentials Values"]'
);
await page.fill('#root\\/credentials\\/gcpConfig\\/projectId', projectId);
await checkServiceFieldSectionHighlighting(page, 'projectId');

View File

@ -131,10 +131,11 @@ class RedshiftWithDBTIngestionClass extends ServiceBaseClass {
await page.click('[data-menu-id*="dbt"]');
await page.waitForSelector('#root\\/dbtConfigSource__oneof_select');
await page.selectOption(
'#root\\/dbtConfigSource__oneof_select',
'DBT S3 Config'
);
await page
.getByTestId('select-widget-root/dbtConfigSource__oneof_select')
.getByRole('combobox')
.click({ force: true });
await page.click('.ant-select-dropdown:visible [title="DBT S3 Config"]');
await page.fill(
'#root\\/dbtConfigSource\\/dbtSecurityConfig\\/awsAccessKeyId',
process.env.PLAYWRIGHT_S3_STORAGE_ACCESS_KEY_ID ?? ''

View File

@ -0,0 +1,30 @@
/*
* Copyright 2025 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Page } from '@playwright/test';
export interface SupersetFormType {
hostPort: string;
connectionType: string;
connection: {
username: string;
password: string;
provider?: string;
hostPort?: string;
database?: string;
scheme?: string;
};
}
export interface FillSupersetFormProps extends SupersetFormType {
page: Page;
}

View File

@ -0,0 +1,86 @@
/*
* 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 { FillSupersetFormProps } from '../support/interfaces/ServiceForm.interface';
export const fillSupersetFormDetails = async ({
page,
hostPort,
connectionType,
connection: {
username,
password,
provider,
hostPort: connectionHostPort,
database,
},
}: FillSupersetFormProps) => {
await page.locator('#root\\/hostPort').clear();
await page.fill('#root\\/hostPort', hostPort);
if (connectionType === 'SupersetApiConnection') {
await page
.getByTestId('select-widget-root/connection__oneof_select')
.getByRole('combobox')
.click({ force: true });
await page.click(
`.ant-select-dropdown:visible [title="${connectionType}"]`
);
if (provider) {
await page
.getByTestId('select-widget-root/connection/provider')
.getByRole('combobox')
.click({ force: true });
await page.click(`.ant-select-dropdown:visible [title="${provider}"]`);
}
} else if (
connectionType === 'PostgresConnection' ||
connectionType === 'MysqlConnection'
) {
await page
.getByTestId('select-widget-root/connection__oneof_select')
.getByRole('combobox')
.click({ force: true });
await page.click(
`.ant-select-dropdown:visible [title="${connectionType}"]`
);
if (connectionHostPort) {
await page.locator('#root\\/connection\\/hostPort').clear();
await page.fill('#root\\/connection\\/hostPort', connectionHostPort, {
force: true,
});
}
if (database) {
await page.locator('#root\\/connection\\/database').clear();
await page.fill('#root\\/connection\\/database', database, {
force: true,
});
}
}
await page.locator('#root\\/connection\\/username').clear();
await page.fill('#root\\/connection\\/username', username, { force: true });
if (connectionType === 'SupersetApiConnection') {
await page.locator('#root\\/connection\\/password').clear();
await page.fill('#root\\/connection\\/password', password, {
force: true,
});
} else {
await page.locator('#root\\/connection\\/authType\\/password').clear();
await page.fill('#root\\/connection\\/authType\\/password', password, {
force: true,
});
}
};

View File

@ -460,6 +460,7 @@ const AppRunsHistory = forwardRef(
}
width={800}>
<FormBuilder
capitalizeOptionLabel
hideCancelButton
readonly
useSelectWidget

View File

@ -60,6 +60,7 @@ const ApplicationConfiguration = ({
const formPanel = (
<FormBuilder
capitalizeOptionLabel
useSelectWidget
cancelText={t('label.back')}
formData={appData?.appConfiguration ?? {}}

View File

@ -157,6 +157,7 @@ const ConnectionConfigForm = ({
<Fragment>
<AirflowMessageBanner />
<FormBuilder
useSelectWidget
cancelText={cancelText ?? ''}
fields={customFields}
formData={validConfig}

View File

@ -56,7 +56,9 @@ describe('Test SelectWidget Component', () => {
it('Should render select component', async () => {
render(<SelectWidget {...mockSelectProps} />);
const selectInput = screen.getByTestId('select-widget');
const selectInput = screen.getByTestId(
'select-widget-root/searchIndexMappingLanguage'
);
const treeSelectWidget = screen.queryByText('TreeSelectWidget');
expect(selectInput).toBeInTheDocument();
@ -67,7 +69,7 @@ describe('Test SelectWidget Component', () => {
render(<SelectWidget {...mockSelectProps} disabled />);
const selectInput = await findByRole(
screen.getByTestId('select-widget'),
screen.getByTestId('select-widget-root/searchIndexMappingLanguage'),
'combobox'
);
@ -77,7 +79,9 @@ describe('Test SelectWidget Component', () => {
it('Should call onFocus', async () => {
render(<SelectWidget {...mockSelectProps} />);
const selectInput = screen.getByTestId('select-widget');
const selectInput = screen.getByTestId(
'select-widget-root/searchIndexMappingLanguage'
);
fireEvent.focus(selectInput);
@ -87,7 +91,9 @@ describe('Test SelectWidget Component', () => {
it('Should call onBlur', async () => {
render(<SelectWidget {...mockSelectProps} />);
const selectInput = screen.getByTestId('select-widget');
const selectInput = screen.getByTestId(
'select-widget-root/searchIndexMappingLanguage'
);
fireEvent.blur(selectInput);
@ -98,7 +104,7 @@ describe('Test SelectWidget Component', () => {
render(<SelectWidget {...mockSelectProps} />);
const selectInput = await findByRole(
screen.getByTestId('select-widget'),
screen.getByTestId('select-widget-root/searchIndexMappingLanguage'),
'combobox'
);
@ -118,7 +124,9 @@ describe('Test SelectWidget Component', () => {
it('Should render TreeSelectWidget component if uiFieldType is treeSelect', async () => {
render(<SelectWidget {...mockTreeSelectProps} />);
const selectWidget = screen.queryByTestId('select-widget');
const selectWidget = screen.queryByTestId(
'select-widget-root/searchIndexMappingLanguage'
);
const treeSelectWidget = screen.getByText('TreeSelectWidget');
expect(treeSelectWidget).toBeInTheDocument();

View File

@ -12,7 +12,6 @@
*/
import { WidgetProps } from '@rjsf/utils';
import { Select } from 'antd';
import { capitalize } from 'lodash';
import { FC } from 'react';
import { getPopupContainer } from '../../../../../utils/formUtils';
import TreeSelectWidget from './TreeSelectWidget';
@ -29,7 +28,7 @@ const SelectWidget: FC<WidgetProps> = (props) => {
allowClear
autoFocus={rest.autofocus}
className="d-block w-full"
data-testid="select-widget"
data-testid={`select-widget-${rest.id}`}
disabled={rest.disabled}
getPopupContainer={getPopupContainer}
id={rest.id}
@ -45,11 +44,29 @@ const SelectWidget: FC<WidgetProps> = (props) => {
data-testid={`select-option-${option.label}`}
key={option.value}
value={option.value}>
{capitalize(option.label)}
{rest.capitalizeOptionLabel
? typeof option.label === 'string'
? option.label.charAt(0).toUpperCase() + option.label.slice(1)
: option.label
: option.label}
</Select.Option>
))}
</Select>
);
};
export default SelectWidget;
function withSelectWidget(WrappedComponent: FC<WidgetProps>) {
return function SelectWidget({
capitalizeOptionLabel = false,
...props
}: WidgetProps & { capitalizeOptionLabel?: boolean }) {
return (
<WrappedComponent
{...props}
capitalizeOptionLabel={capitalizeOptionLabel}
/>
);
};
}
export default withSelectWidget(SelectWidget);

View File

@ -13,6 +13,7 @@
import { CheckOutlined } from '@ant-design/icons';
import Form, { FormProps, IChangeEvent } from '@rjsf/core';
import { WidgetProps } from '@rjsf/utils';
import { Button } from 'antd';
import classNames from 'classnames';
import { LoadingState } from 'Models';
@ -42,6 +43,7 @@ export interface Props extends FormProps {
status?: LoadingState;
onCancel?: () => void;
useSelectWidget?: boolean;
capitalizeOptionLabel?: boolean;
}
const FormBuilder = forwardRef<Form, Props>(
@ -60,6 +62,7 @@ const FormBuilder = forwardRef<Form, Props>(
uiSchema,
onFocus,
useSelectWidget = false,
capitalizeOptionLabel = false,
children,
...props
},
@ -78,7 +81,14 @@ const FormBuilder = forwardRef<Form, Props>(
autoComplete: AsyncSelectWidget,
queryBuilder: QueryBuilderWidget,
code: CodeWidget,
...(useSelectWidget && { SelectWidget: SelectWidget }),
...(useSelectWidget && {
SelectWidget: (props: WidgetProps) => (
<SelectWidget
{...props}
capitalizeOptionLabel={capitalizeOptionLabel}
/>
),
}),
};
const handleCancel = () => {