Add Bundle Suite Form and Integrate with Data Quality Page

- Introduced a new BundleSuiteForm component for creating and managing test suites, including form fields for name, description, and test case selection.
- Implemented styling for the Bundle Suite Form to enhance user experience and visual consistency.
- Integrated the BundleSuiteForm into the DataQualityPage, allowing users to open the form as a modal for adding new test suites.
- Added state management for modal visibility and handlers for opening and closing the Bundle Suite modal.
- Enhanced the overall layout and functionality of the Data Quality page to accommodate the new test suite feature.
This commit is contained in:
Shailesh Parmar 2025-07-03 14:59:52 +05:30
parent e5e2387208
commit cb0fd485cf
5 changed files with 665 additions and 22 deletions

View File

@ -41,6 +41,7 @@ import {
} from '../../../../constants/constants'; } from '../../../../constants/constants';
import { ENTITY_NAME_REGEX } from '../../../../constants/regex.constants'; import { ENTITY_NAME_REGEX } from '../../../../constants/regex.constants';
import { DEFAULT_SCHEDULE_CRON_DAILY } from '../../../../constants/Schedular.constants'; import { DEFAULT_SCHEDULE_CRON_DAILY } from '../../../../constants/Schedular.constants';
import { useAirflowStatus } from '../../../../context/AirflowStatusProvider/AirflowStatusProvider';
import { useLimitStore } from '../../../../context/LimitsProvider/useLimitsStore'; import { useLimitStore } from '../../../../context/LimitsProvider/useLimitsStore';
import { SearchIndex } from '../../../../enums/search.enum'; import { SearchIndex } from '../../../../enums/search.enum';
import { TagSource } from '../../../../generated/api/domains/createDataProduct'; import { TagSource } from '../../../../generated/api/domains/createDataProduct';
@ -161,6 +162,7 @@ const TestCaseFormV1: FC<TestCaseFormV1Props> = ({
const { t } = useTranslation(); const { t } = useTranslation();
const { config } = useLimitStore(); const { config } = useLimitStore();
const [form] = useForm<FormValues>(); const [form] = useForm<FormValues>();
const { isAirflowAvailable } = useAirflowStatus();
// ============================================= // =============================================
// HOOKS - State (grouped by functionality) // HOOKS - State (grouped by functionality)
@ -759,7 +761,9 @@ const TestCaseFormV1: FC<TestCaseFormV1Props> = ({
}; };
const ingestion = await addIngestionPipeline(ingestionPayload); const ingestion = await addIngestionPipeline(ingestionPayload);
await deployIngestionPipelineById(ingestion.id ?? ''); if (isAirflowAvailable) {
await deployIngestionPipelineById(ingestion.id ?? '');
}
} }
showSuccessToast( showSuccessToast(
@ -959,7 +963,7 @@ const TestCaseFormV1: FC<TestCaseFormV1Props> = ({
initialValues={{ initialValues={{
testLevel: TestLevel.TABLE, testLevel: TestLevel.TABLE,
...testCaseClassBase.initialFormValues(), ...testCaseClassBase.initialFormValues(),
testName: replaceAllSpacialCharWith_(initialValues?.testName ?? ''), testName: initialValues?.testName,
testTypeId: initialValues?.testTypeId, testTypeId: initialValues?.testTypeId,
params: getInitialParamsValue, params: getInitialParamsValue,
tags: initialValues?.tags || [], tags: initialValues?.tags || [],

View File

@ -0,0 +1,39 @@
/*
* 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 { DrawerProps } from 'antd';
import { TestCase } from '../../../generated/tests/testCase';
import { TestSuite } from '../../../generated/tests/testSuite';
export interface BundleSuiteFormProps {
isDrawer?: boolean;
drawerProps?: DrawerProps;
className?: string;
onCancel?: () => void;
onSuccess?: (testSuite: TestSuite) => void;
initialValues?: {
name?: string;
description?: string;
testCases?: TestCase[];
};
}
export type BundleSuiteFormData = {
name: string;
description?: string;
testCases: TestCase[] | string[];
cron?: string;
enableDebugLog?: boolean;
raiseOnError?: boolean;
pipelineName?: string;
};

View File

@ -0,0 +1,118 @@
/*
* 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 (reference) '../../../styles/variables.less';
.bundle-suite-form {
position: relative;
min-height: 100vh;
padding-bottom: 80px; // Space for fixed buttons
.ant-form-item {
margin-bottom: @size-lg;
&:last-child {
margin-bottom: 0;
}
}
.ant-form-item-label > label {
color: @grey-800;
font-weight: @font-medium;
font-size: @font-size-base;
}
.basic-info-card,
.test-case-selection-card,
.scheduler-card {
background-color: @grey-1;
border: 1px solid @grey-200;
border-radius: @border-radius-sm;
margin-bottom: @size-lg;
.ant-card-body {
padding: @size-sm @size-mlg @size-mlg;
}
.ant-form-item {
margin-bottom: @size-lg;
&:last-child {
margin-bottom: 0;
}
}
.ant-form-item-label > label {
color: @grey-800;
font-weight: @font-medium;
font-size: @font-size-base;
}
.card-title {
color: @grey-800;
font-size: @size-md;
font-weight: @font-medium;
margin-bottom: @size-mlg;
line-height: 1.4;
}
}
.scheduler-card {
.selection-card {
background-color: @white;
&.selected,
&:hover {
background-color: @blue-22;
}
}
}
.bundle-suite-form-actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: @white;
border-top: 1px solid @grey-200;
padding: @size-sm @size-mlg;
display: flex;
justify-content: flex-end;
gap: @size-sm;
z-index: 1000;
.ant-btn {
min-width: 80px;
}
}
&.drawer-mode {
min-height: auto;
padding-bottom: 0;
.bundle-suite-form-actions {
display: none;
}
}
&.standalone-mode {
.drawer-footer-actions {
display: none;
}
}
.block-editor-wrapper.block-editor-wrapper--bar-menu .om-block-editor {
background-color: @white;
max-height: 150px;
}
}

View File

@ -0,0 +1,459 @@
/*
* 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 {
Button,
Card,
Col,
Drawer,
Form,
Row,
Space,
Switch,
Typography,
} from 'antd';
import { useForm } from 'antd/lib/form/Form';
import { AxiosError } from 'axios';
import classNames from 'classnames';
import { isEmpty } from 'lodash';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { DEFAULT_SCHEDULE_CRON_DAILY } from '../../../constants/Schedular.constants';
import { useAirflowStatus } from '../../../context/AirflowStatusProvider/AirflowStatusProvider';
import { useLimitStore } from '../../../context/LimitsProvider/useLimitsStore';
import { OwnerType } from '../../../enums/user.enum';
import {
ConfigType,
CreateIngestionPipeline,
PipelineType,
} from '../../../generated/api/services/ingestionPipelines/createIngestionPipeline';
import { CreateTestSuite } from '../../../generated/api/tests/createTestSuite';
import { LogLevels } from '../../../generated/entity/services/ingestionPipelines/ingestionPipeline';
import { TestCase } from '../../../generated/tests/testCase';
import { TestSuite } from '../../../generated/tests/testSuite';
import { useApplicationStore } from '../../../hooks/useApplicationStore';
import { FieldProp, FieldTypes } from '../../../interface/FormUtils.interface';
import {
addIngestionPipeline,
deployIngestionPipelineById,
} from '../../../rest/ingestionPipelineAPI';
import {
addTestCaseToLogicalTestSuite,
createTestSuites,
} from '../../../rest/testAPI';
import {
getNameFromFQN,
replaceAllSpacialCharWith_,
} from '../../../utils/CommonUtils';
import { getEntityName } from '../../../utils/EntityUtils';
import { generateFormFields } from '../../../utils/formUtils';
import { getScheduleOptionsFromSchedules } from '../../../utils/SchedularUtils';
import { getIngestionName } from '../../../utils/ServiceUtils';
import { generateUUID } from '../../../utils/StringsUtils';
import { showErrorToast, showSuccessToast } from '../../../utils/ToastUtils';
import ScheduleIntervalV1 from '../../Settings/Services/AddIngestion/Steps/ScheduleIntervalV1';
import { AddTestCaseList } from '../AddTestCaseList/AddTestCaseList.component';
import {
BundleSuiteFormData,
BundleSuiteFormProps,
} from './BundleSuiteForm.interface';
import './BundleSuiteForm.less';
// =============================================
// MAIN COMPONENT
// =============================================
const BundleSuiteForm: React.FC<BundleSuiteFormProps> = ({
className,
drawerProps,
isDrawer = false,
onCancel,
onSuccess,
initialValues,
}) => {
// =============================================
// HOOKS - External
// =============================================
const { t } = useTranslation();
const navigate = useNavigate();
const [form] = useForm<BundleSuiteFormData>();
const { config } = useLimitStore();
const { currentUser } = useApplicationStore();
const { isAirflowAvailable } = useAirflowStatus();
// =============================================
// HOOKS - State
// =============================================
const [isSubmitting, setIsSubmitting] = useState(false);
const [selectedTestCases, setSelectedTestCases] = useState<TestCase[]>(
initialValues?.testCases || []
);
// =============================================
// HOOKS - Memoized Values
// =============================================
const pipelineSchedules = useMemo(() => {
return config?.limits?.config.featureLimits.find(
(feature) => feature.name === 'dataQuality'
)?.pipelineSchedules;
}, [config]);
const schedulerOptions = useMemo(() => {
if (isEmpty(pipelineSchedules) || !pipelineSchedules) {
return undefined;
}
return getScheduleOptionsFromSchedules(pipelineSchedules);
}, [pipelineSchedules]);
// Form field definitions
const basicInfoFormFields: FieldProp[] = useMemo(
() => [
{
name: 'name',
label: t('label.name'),
type: FieldTypes.TEXT,
required: true,
placeholder: t('label.enter-entity', { entity: t('label.name') }),
props: { 'data-testid': 'test-suite-name' },
id: 'root/name',
rules: [
{
max: 256,
message: t('message.entity-maximum-size', {
entity: t('label.name'),
max: 256,
}),
},
],
},
{
name: 'description',
label: t('label.description'),
type: FieldTypes.DESCRIPTION,
required: false,
placeholder: t('label.enter-entity', {
entity: t('label.description'),
}),
props: { 'data-testid': 'test-suite-description', rows: 3 },
id: 'root/description',
},
],
[t]
);
const schedulerFormFields: FieldProp[] = useMemo(
() => [
{
name: 'pipelineName',
label: t('label.name'),
type: FieldTypes.TEXT,
required: false,
placeholder: t('label.enter-entity', { entity: t('label.name') }),
props: { 'data-testid': 'pipeline-name' },
id: 'root/pipelineName',
},
],
[t]
);
// =============================================
// HOOKS - Effects
// =============================================
// Initialize form values
useEffect(() => {
if (initialValues) {
form.setFieldsValue({
name: initialValues.name || '',
description: initialValues.description || '',
testCases: initialValues.testCases || [],
});
}
}, [initialValues, form]);
// =============================================
// HOOKS - Callbacks
// =============================================
const handleTestCaseSelection = useCallback(
(testCases: TestCase[]) => {
setSelectedTestCases(testCases);
form.setFieldValue('testCases', testCases);
},
[form]
);
const createAndDeployPipeline = async (
testSuite: TestSuite,
formData: BundleSuiteFormData
) => {
try {
const testSuiteName = replaceAllSpacialCharWith_(
getNameFromFQN(testSuite.fullyQualifiedName ?? testSuite.name)
);
const pipelineName =
formData.pipelineName ||
getIngestionName(testSuiteName, PipelineType.TestSuite);
const ingestionPayload: CreateIngestionPipeline = {
airflowConfig: {
scheduleInterval: formData.cron,
},
displayName: pipelineName,
name: generateUUID(),
loggerLevel: formData.enableDebugLog ? LogLevels.Debug : LogLevels.Info,
pipelineType: PipelineType.TestSuite,
raiseOnError: formData.raiseOnError ?? true,
service: {
id: testSuite.id ?? '',
type: 'testSuite',
},
sourceConfig: {
config: {
type: ConfigType.TestSuite,
},
},
};
const pipeline = await addIngestionPipeline(ingestionPayload);
if (isAirflowAvailable) {
await deployIngestionPipelineById(pipeline.id ?? '');
}
showSuccessToast(
t('message.pipeline-deployed-successfully', {
pipelineName: getEntityName(pipeline),
})
);
} catch (error) {
showErrorToast(
error as AxiosError,
t('server.create-entity-error', {
entity: t('label.pipeline'),
})
);
}
};
const createTestSuiteWithPipeline = async (formData: BundleSuiteFormData) => {
const testSuitePayload: CreateTestSuite = {
name: formData.name,
description: formData.description,
owners: currentUser?.id
? [{ id: currentUser.id, type: OwnerType.USER }]
: [],
};
const testSuite = await createTestSuites(testSuitePayload);
await addTestCaseToLogicalTestSuite({
testCaseIds: selectedTestCases.map((testCase) => testCase.id ?? ''),
testSuiteId: testSuite.id ?? '',
});
if (formData.cron) {
await createAndDeployPipeline(testSuite, formData);
}
return testSuite;
};
const handleFormSubmit = async (values: BundleSuiteFormData) => {
setIsSubmitting(true);
try {
const formData = {
...values,
testCases: selectedTestCases,
};
const testSuite = await createTestSuiteWithPipeline(formData);
onSuccess?.(testSuite);
showSuccessToast(
t('message.entity-created-successfully', {
entity: t('label.test-suite'),
})
);
if (isDrawer) {
onCancel?.();
}
} catch (error) {
showErrorToast(
error as AxiosError,
t('server.create-entity-error', {
entity: t('label.test-suite'),
})
);
} finally {
setIsSubmitting(false);
}
};
const handleCancel = () => {
if (onCancel) {
onCancel();
} else {
navigate(-1);
}
};
const renderActionButtons = (
<Space size={16}>
<Button data-testid="cancel-button" onClick={handleCancel}>
{t('label.cancel')}
</Button>
<Button
data-testid="submit-button"
form="bundle-suite-form"
htmlType="submit"
loading={isSubmitting}
type="primary">
{t('label.create')}
</Button>
</Space>
);
const formContent = (
<div
className={classNames(
'bundle-suite-form',
{
'drawer-mode': isDrawer,
'standalone-mode': !isDrawer,
},
className
)}>
<Form
form={form}
id="bundle-suite-form"
initialValues={{
raiseOnError: true,
cron: DEFAULT_SCHEDULE_CRON_DAILY,
enableDebugLog: false,
...initialValues,
}}
layout="vertical"
onFinish={handleFormSubmit}>
{!isDrawer && (
<Typography.Title level={4}>
{t('label.create-entity', { entity: t('label.bundle-suite') })}
</Typography.Title>
)}
{/* Basic Information */}
<Card className="basic-info-card">
{generateFormFields(basicInfoFormFields)}
</Card>
{/* Test Case Selection */}
<Card className="test-case-selection-card">
<Form.Item
label={t('label.test-case-plural')}
name="testCases"
rules={[
{
required: true,
message: t('label.field-required', {
field: t('label.test-case-plural'),
}),
},
]}>
<AddTestCaseList
selectedTest={selectedTestCases.map((tc) => tc.name)}
showButton={false}
onChange={handleTestCaseSelection}
/>
</Form.Item>
</Card>
{/* Scheduler - Always Visible */}
<Card className="scheduler-card">
<div className="card-title">
{t('label.schedule-for-entity', {
entity: t('label.test-suite'),
})}
</div>
{generateFormFields(schedulerFormFields)}
<Form.Item label={t('label.schedule-interval')} name="cron">
<ScheduleIntervalV1 includePeriodOptions={schedulerOptions} />
</Form.Item>
{/* Debug Log and Raise on Error switches */}
<div style={{ marginTop: '24px' }}>
<Row gutter={[24, 16]}>
<Col span={12}>
<div className="d-flex justify-between align-center">
<Typography.Text className="font-medium">
{t('label.enable-debug-log')}
</Typography.Text>
<Form.Item
name="enableDebugLog"
style={{ marginBottom: 0 }}
valuePropName="checked">
<Switch />
</Form.Item>
</div>
</Col>
<Col span={12}>
<div className="d-flex justify-between align-center">
<Typography.Text className="font-medium">
{t('label.raise-on-error')}
</Typography.Text>
<Form.Item
name="raiseOnError"
style={{ marginBottom: 0 }}
valuePropName="checked">
<Switch />
</Form.Item>
</div>
</Col>
</Row>
</div>
</Card>
</Form>
{!isDrawer && (
<div className="bundle-suite-form-actions">{renderActionButtons}</div>
)}
</div>
);
const drawerFooter = (
<div className="drawer-footer-actions">{renderActionButtons}</div>
);
if (isDrawer) {
return (
<Drawer
destroyOnClose
closable={false}
footer={drawerFooter}
maskClosable={false}
placement="right"
size="large"
{...drawerProps}
onClose={onCancel}>
<div className="drawer-form-content">{formContent}</div>
</Drawer>
);
}
return formContent;
};
export default BundleSuiteForm;

View File

@ -15,12 +15,12 @@ import { Button, Card, Col, Row, Tabs } from 'antd';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import ManageButton from '../../components/common/EntityPageInfos/ManageButton/ManageButton'; import ManageButton from '../../components/common/EntityPageInfos/ManageButton/ManageButton';
import TabsLabel from '../../components/common/TabsLabel/TabsLabel.component'; import TabsLabel from '../../components/common/TabsLabel/TabsLabel.component';
import TestCaseFormV1 from '../../components/DataQuality/AddDataQualityTest/components/TestCaseFormV1'; import TestCaseFormV1 from '../../components/DataQuality/AddDataQualityTest/components/TestCaseFormV1';
import BundleSuiteForm from '../../components/DataQuality/BundleSuiteForm/BundleSuiteForm';
import PageHeader from '../../components/PageHeader/PageHeader.component'; import PageHeader from '../../components/PageHeader/PageHeader.component';
import { ROUTES } from '../../constants/constants';
import { usePermissionProvider } from '../../context/PermissionProvider/PermissionProvider'; import { usePermissionProvider } from '../../context/PermissionProvider/PermissionProvider';
import { EntityType } from '../../enums/entity.enum'; import { EntityType } from '../../enums/entity.enum';
import { withPageLayout } from '../../hoc/withPageLayout'; import { withPageLayout } from '../../hoc/withPageLayout';
@ -41,6 +41,7 @@ const DataQualityPage = () => {
// Add state for modal open/close // Add state for modal open/close
const [isTestCaseModalOpen, setIsTestCaseModalOpen] = useState(false); const [isTestCaseModalOpen, setIsTestCaseModalOpen] = useState(false);
const [isBundleSuiteModalOpen, setIsBundleSuiteModalOpen] = useState(false);
// Add handlers for modal // Add handlers for modal
const handleOpenTestCaseModal = () => { const handleOpenTestCaseModal = () => {
@ -51,6 +52,14 @@ const DataQualityPage = () => {
setIsTestCaseModalOpen(false); setIsTestCaseModalOpen(false);
}; };
const handleOpenBundleSuiteModal = () => {
setIsBundleSuiteModalOpen(true);
};
const handleCloseBundleSuiteModal = () => {
setIsBundleSuiteModalOpen(false);
};
const menuItems = useMemo(() => { const menuItems = useMemo(() => {
const data = DataQualityClassBase.getDataQualityTab(); const data = DataQualityClassBase.getDataQualityTab();
@ -94,15 +103,14 @@ const DataQualityPage = () => {
<Col className="d-flex justify-end" span={8}> <Col className="d-flex justify-end" span={8}>
{activeTab === DataQualityPageTabs.TEST_SUITES && {activeTab === DataQualityPageTabs.TEST_SUITES &&
testSuitePermission?.Create && ( testSuitePermission?.Create && (
<Link <Button
data-testid="add-test-suite-btn" data-testid="add-test-suite-btn"
to={ROUTES.ADD_TEST_SUITES}> type="primary"
<Button type="primary"> onClick={handleOpenBundleSuiteModal}>
{t('label.add-entity', { {t('label.add-entity', {
entity: t('label.bundle-suite-plural'), entity: t('label.bundle-suite-plural'),
})} })}
</Button> </Button>
</Link>
)} )}
{activeTab === DataQualityPageTabs.TEST_CASES && {activeTab === DataQualityPageTabs.TEST_CASES &&
testSuitePermission?.Create && ( testSuitePermission?.Create && (
@ -136,16 +144,31 @@ const DataQualityPage = () => {
/> />
</Col> </Col>
</Row> </Row>
<TestCaseFormV1 {isTestCaseModalOpen && (
isDrawer <TestCaseFormV1
drawerProps={{ isDrawer
title: t('label.add-entity', { drawerProps={{
entity: t('label.test-case'), title: t('label.add-entity', {
}), entity: t('label.test-case'),
open: isTestCaseModalOpen, }),
}} open: isTestCaseModalOpen,
onCancel={handleCloseTestCaseModal} }}
/> onCancel={handleCloseTestCaseModal}
/>
)}
{isBundleSuiteModalOpen && (
<BundleSuiteForm
isDrawer
drawerProps={{
title: t('label.add-entity', {
entity: t('label.bundle-suite-plural'),
}),
open: isBundleSuiteModalOpen,
}}
onCancel={handleCloseBundleSuiteModal}
onSuccess={handleCloseBundleSuiteModal}
/>
)}
</DataQualityProvider> </DataQualityProvider>
); );
}; };