From cb0fd485cfb0f432d30bf4b8a01a70c9d4bac2c5 Mon Sep 17 00:00:00 2001 From: Shailesh Parmar Date: Thu, 3 Jul 2025 14:59:52 +0530 Subject: [PATCH] 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. --- .../components/TestCaseFormV1.tsx | 8 +- .../BundleSuiteForm.interface.ts | 39 ++ .../BundleSuiteForm/BundleSuiteForm.less | 118 +++++ .../BundleSuiteForm/BundleSuiteForm.tsx | 459 ++++++++++++++++++ .../src/pages/DataQuality/DataQualityPage.tsx | 63 ++- 5 files changed, 665 insertions(+), 22 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/DataQuality/BundleSuiteForm/BundleSuiteForm.interface.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/DataQuality/BundleSuiteForm/BundleSuiteForm.less create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/DataQuality/BundleSuiteForm/BundleSuiteForm.tsx diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/components/TestCaseFormV1.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/components/TestCaseFormV1.tsx index e22e6edebf9..8d5df8c9a8a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/components/TestCaseFormV1.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/components/TestCaseFormV1.tsx @@ -41,6 +41,7 @@ import { } from '../../../../constants/constants'; import { ENTITY_NAME_REGEX } from '../../../../constants/regex.constants'; import { DEFAULT_SCHEDULE_CRON_DAILY } from '../../../../constants/Schedular.constants'; +import { useAirflowStatus } from '../../../../context/AirflowStatusProvider/AirflowStatusProvider'; import { useLimitStore } from '../../../../context/LimitsProvider/useLimitsStore'; import { SearchIndex } from '../../../../enums/search.enum'; import { TagSource } from '../../../../generated/api/domains/createDataProduct'; @@ -161,6 +162,7 @@ const TestCaseFormV1: FC = ({ const { t } = useTranslation(); const { config } = useLimitStore(); const [form] = useForm(); + const { isAirflowAvailable } = useAirflowStatus(); // ============================================= // HOOKS - State (grouped by functionality) @@ -759,7 +761,9 @@ const TestCaseFormV1: FC = ({ }; const ingestion = await addIngestionPipeline(ingestionPayload); - await deployIngestionPipelineById(ingestion.id ?? ''); + if (isAirflowAvailable) { + await deployIngestionPipelineById(ingestion.id ?? ''); + } } showSuccessToast( @@ -959,7 +963,7 @@ const TestCaseFormV1: FC = ({ initialValues={{ testLevel: TestLevel.TABLE, ...testCaseClassBase.initialFormValues(), - testName: replaceAllSpacialCharWith_(initialValues?.testName ?? ''), + testName: initialValues?.testName, testTypeId: initialValues?.testTypeId, params: getInitialParamsValue, tags: initialValues?.tags || [], diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/BundleSuiteForm/BundleSuiteForm.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/BundleSuiteForm/BundleSuiteForm.interface.ts new file mode 100644 index 00000000000..874a4a48d55 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/BundleSuiteForm/BundleSuiteForm.interface.ts @@ -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; +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/BundleSuiteForm/BundleSuiteForm.less b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/BundleSuiteForm/BundleSuiteForm.less new file mode 100644 index 00000000000..b901f396a7a --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/BundleSuiteForm/BundleSuiteForm.less @@ -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; + } +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/BundleSuiteForm/BundleSuiteForm.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/BundleSuiteForm/BundleSuiteForm.tsx new file mode 100644 index 00000000000..847b1867462 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/BundleSuiteForm/BundleSuiteForm.tsx @@ -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 = ({ + className, + drawerProps, + isDrawer = false, + onCancel, + onSuccess, + initialValues, +}) => { + // ============================================= + // HOOKS - External + // ============================================= + const { t } = useTranslation(); + const navigate = useNavigate(); + const [form] = useForm(); + const { config } = useLimitStore(); + const { currentUser } = useApplicationStore(); + const { isAirflowAvailable } = useAirflowStatus(); + + // ============================================= + // HOOKS - State + // ============================================= + const [isSubmitting, setIsSubmitting] = useState(false); + const [selectedTestCases, setSelectedTestCases] = useState( + 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 = ( + + + + + ); + + const formContent = ( +
+
+ {!isDrawer && ( + + {t('label.create-entity', { entity: t('label.bundle-suite') })} + + )} + + {/* Basic Information */} + + {generateFormFields(basicInfoFormFields)} + + + {/* Test Case Selection */} + + + tc.name)} + showButton={false} + onChange={handleTestCaseSelection} + /> + + + + {/* Scheduler - Always Visible */} + +
+ {t('label.schedule-for-entity', { + entity: t('label.test-suite'), + })} +
+ + {generateFormFields(schedulerFormFields)} + + + + + + {/* Debug Log and Raise on Error switches */} +
+ + +
+ + {t('label.enable-debug-log')} + + + + +
+ + +
+ + {t('label.raise-on-error')} + + + + +
+ +
+
+
+
+ + {!isDrawer && ( +
{renderActionButtons}
+ )} +
+ ); + + const drawerFooter = ( +
{renderActionButtons}
+ ); + + if (isDrawer) { + return ( + +
{formContent}
+
+ ); + } + + return formContent; +}; + +export default BundleSuiteForm; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DataQuality/DataQualityPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DataQuality/DataQualityPage.tsx index 69a63f343e5..aed42db067f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/DataQuality/DataQualityPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/DataQuality/DataQualityPage.tsx @@ -15,12 +15,12 @@ import { Button, Card, Col, Row, Tabs } from 'antd'; import { isEmpty } from 'lodash'; import { useMemo, useState } from 'react'; 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 TabsLabel from '../../components/common/TabsLabel/TabsLabel.component'; import TestCaseFormV1 from '../../components/DataQuality/AddDataQualityTest/components/TestCaseFormV1'; +import BundleSuiteForm from '../../components/DataQuality/BundleSuiteForm/BundleSuiteForm'; import PageHeader from '../../components/PageHeader/PageHeader.component'; -import { ROUTES } from '../../constants/constants'; import { usePermissionProvider } from '../../context/PermissionProvider/PermissionProvider'; import { EntityType } from '../../enums/entity.enum'; import { withPageLayout } from '../../hoc/withPageLayout'; @@ -41,6 +41,7 @@ const DataQualityPage = () => { // Add state for modal open/close const [isTestCaseModalOpen, setIsTestCaseModalOpen] = useState(false); + const [isBundleSuiteModalOpen, setIsBundleSuiteModalOpen] = useState(false); // Add handlers for modal const handleOpenTestCaseModal = () => { @@ -51,6 +52,14 @@ const DataQualityPage = () => { setIsTestCaseModalOpen(false); }; + const handleOpenBundleSuiteModal = () => { + setIsBundleSuiteModalOpen(true); + }; + + const handleCloseBundleSuiteModal = () => { + setIsBundleSuiteModalOpen(false); + }; + const menuItems = useMemo(() => { const data = DataQualityClassBase.getDataQualityTab(); @@ -94,15 +103,14 @@ const DataQualityPage = () => { {activeTab === DataQualityPageTabs.TEST_SUITES && testSuitePermission?.Create && ( - - - + type="primary" + onClick={handleOpenBundleSuiteModal}> + {t('label.add-entity', { + entity: t('label.bundle-suite-plural'), + })} + )} {activeTab === DataQualityPageTabs.TEST_CASES && testSuitePermission?.Create && ( @@ -136,16 +144,31 @@ const DataQualityPage = () => { /> - + {isTestCaseModalOpen && ( + + )} + {isBundleSuiteModalOpen && ( + + )} ); };