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 5b897503acf..f25782da4b4 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 @@ -21,7 +21,10 @@ import { Typography, } from 'antd'; import { useForm } from 'antd/lib/form/Form'; +import { AxiosError } from 'axios'; import classNames from 'classnames'; +import cryptoRandomString from 'crypto-random-string-with-promisify-polyfill'; +import { isEmpty, snakeCase } from 'lodash'; import { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ReactComponent as ColumnIcon } from '../../../../assets/svg/ic-column.svg'; @@ -33,10 +36,12 @@ import { import { ENTITY_NAME_REGEX } from '../../../../constants/regex.constants'; import { SearchIndex } from '../../../../enums/search.enum'; import { TagSource } from '../../../../generated/api/domains/createDataProduct'; +import { CreateTestCase } from '../../../../generated/api/tests/createTestCase'; import { Table } from '../../../../generated/entity/data/table'; -import { TagLabel } from '../../../../generated/tests/testCase'; +import { TagLabel, TestCase } from '../../../../generated/tests/testCase'; import { EntityType, + TestDataType, TestDefinition, TestPlatform, } from '../../../../generated/tests/testDefinition'; @@ -46,15 +51,26 @@ import { FormItemLayout, } from '../../../../interface/FormUtils.interface'; import { TableSearchSource } from '../../../../interface/search.interface'; +import testCaseClassBase from '../../../../pages/IncidentManager/IncidentManagerDetailPage/TestCaseClassBase'; import { searchQuery } from '../../../../rest/searchAPI'; import { getTableDetailsByFQN } from '../../../../rest/tableAPI'; -import { getListTestDefinitions } from '../../../../rest/testAPI'; -import { filterSelectOptions } from '../../../../utils/CommonUtils'; +import { + createTestCase, + getListTestCaseBySearch, + getListTestDefinitions, +} from '../../../../rest/testAPI'; +import { + filterSelectOptions, + replaceAllSpacialCharWith_, +} from '../../../../utils/CommonUtils'; import { getEntityName } from '../../../../utils/EntityUtils'; import { generateFormFields } from '../../../../utils/formUtils'; +import { generateEntityLink } from '../../../../utils/TableUtils'; +import { showErrorToast, showSuccessToast } from '../../../../utils/ToastUtils'; import { AsyncSelect } from '../../../common/AsyncSelect/AsyncSelect'; import SelectionCardGroup from '../../../common/SelectionCardGroup/SelectionCardGroup'; import { SelectionOption } from '../../../common/SelectionCardGroup/SelectionCardGroup.interface'; +import { TestCaseFormType } from '../AddDataQualityTest.interface'; import ParameterForm from './ParameterForm'; import './TestCaseFormV1.less'; @@ -63,7 +79,7 @@ export interface TestCaseFormV1Props { drawerProps?: DrawerProps; className?: string; table?: Table; - onFormSubmit?: (values: FormValues) => void; + onFormSubmit?: (testCase: TestCase) => void; onCancel?: () => void; initialValues?: Partial; loading?: boolean; @@ -79,6 +95,9 @@ interface FormValues { tags?: TagLabel[]; glossaryTerms?: TagLabel[]; computePassedFailedRowCount?: boolean; + useDynamicAssertion?: boolean; + params?: Record; + parameterValues?: Array<{ name: string; value: string }>; } type TablesCache = Map; @@ -127,27 +146,48 @@ const TestCaseFormV1: FC = ({ onFormSubmit, onCancel, loading: externalLoading = false, -}) => { +}: TestCaseFormV1Props) => { const { t } = useTranslation(); const [form] = useForm(); const [loading, setLoading] = useState(false); const [testDefinitions, setTestDefinitions] = useState([]); const [selectedTestDefinition, setSelectedTestDefinition] = useState(); + const [selectedTestType, setSelectedTestType] = useState( + initialValues?.testTypeId + ); const [currentColumnType, setCurrentColumnType] = useState(); const [isInitialized, setIsInitialized] = useState(false); const [selectedTableData, setSelectedTableData] = useState( table ); const [tablesCache, setTablesCache] = useState(new Map()); + const [existingTestCases, setExistingTestCases] = useState([]); const selectedTestLevel = Form.useWatch('testLevel', form); const selectedTable = Form.useWatch('selectedTable', form); const selectedColumn = Form.useWatch('selectedColumn', form); + const useDynamicAssertion = Form.useWatch('useDynamicAssertion', form); const handleSubmit = async (values: FormValues) => { setLoading(true); try { - await onFormSubmit?.(values); + const testCaseObj = createTestCaseObj(values); + const createdTestCase = await createTestCase(testCaseObj); + + // Pass created test case to parent + onFormSubmit?.(createdTestCase); + // Close drawer if in drawer mode + if (isDrawer) { + onCancel?.(); + } + // Show success message + showSuccessToast( + t('message.entity-created-successfully', { + entity: t('label.test-case'), + }) + ); + } catch (error) { + showErrorToast(error as AxiosError); } finally { setLoading(false); } @@ -157,6 +197,21 @@ const TestCaseFormV1: FC = ({ onCancel?.(); }; + const handleValuesChange = useCallback( + (changedValues: Partial) => { + if (changedValues.testTypeId) { + // Handle test type selection + setSelectedTestType(changedValues.testTypeId); + const testDef = testDefinitions.find( + (definition) => + definition.fullyQualifiedName === changedValues.testTypeId + ); + setSelectedTestDefinition(testDef); + } + }, + [testDefinitions] + ); + const isFormLoading = loading || externalLoading; // Reusable action buttons component @@ -273,13 +328,42 @@ const TestCaseFormV1: FC = ({ } }, []); - // Initialize form with default values + const fetchExistingTestCases = useCallback(async () => { + if (!selectedTableData && !selectedTable) { + return; + } + + try { + const entityFqn = selectedTableData?.fullyQualifiedName || selectedTable; + if (!entityFqn) { + return; + } + + const { data } = await getListTestCaseBySearch({ + limit: PAGE_SIZE_LARGE, + entityLink: `<#E::table::${entityFqn}>`, + }); + + const testCaseNames = data.map((testCase) => testCase.name); + setExistingTestCases(testCaseNames); + } catch (error) { + setExistingTestCases([]); + } + }, [selectedTableData, selectedTable]); + + // Initialize form on mount and update params when test definition changes useEffect(() => { if (!isInitialized) { - form.setFieldsValue({ testLevel: TestLevel.TABLE }); setIsInitialized(true); + + // Set proper params with type handling if we have initial parameter values + if (initialValues?.parameterValues?.length && selectedTestDefinition) { + form.setFieldsValue({ + params: getParamsValue(), + }); + } } - }, [form, isInitialized]); + }, [isInitialized, initialValues?.parameterValues, selectedTestDefinition]); // Handle test level changes useEffect(() => { @@ -297,7 +381,7 @@ const TestCaseFormV1: FC = ({ }); setSelectedTestDefinition(undefined); } - }, [selectedTestLevel, fetchTestDefinitions, form]); + }, [selectedTestLevel, fetchTestDefinitions]); // Handle table selection: use cached data or fetch if needed useEffect(() => { @@ -314,6 +398,105 @@ const TestCaseFormV1: FC = ({ form.setFieldsValue({ selectedColumn: undefined }); }, [selectedTable, table, tablesCache, fetchSelectedTableData, form]); + // Fetch existing test cases when table data is available + useEffect(() => { + fetchExistingTestCases(); + }, [fetchExistingTestCases]); + + const getSelectedTestDefinition = useCallback(() => { + const testType = isEmpty(initialValues?.testTypeId) + ? selectedTestType + : initialValues?.testTypeId; + + return testDefinitions.find( + (definition) => definition.fullyQualifiedName === testType + ); + }, [initialValues?.testTypeId, selectedTestType, testDefinitions]); + + const getParamsValue = useCallback(() => { + return initialValues?.parameterValues?.reduce( + (acc, curr) => ({ + ...acc, + [curr.name || '']: + getSelectedTestDefinition()?.parameterDefinition?.[0].dataType === + TestDataType.Array + ? (JSON.parse(curr?.value || '[]') as string[]).map((val) => ({ + value: val, + })) + : curr?.value, + }), + {} + ); + }, [initialValues?.parameterValues, getSelectedTestDefinition]); + + // Compute initial params without test definition dependency for form initialization + const getInitialParamsValue = useMemo(() => { + if (!initialValues?.parameterValues?.length) { + return undefined; + } + + return initialValues.parameterValues.reduce( + (acc, curr) => ({ + ...acc, + [curr.name || '']: curr?.value, + }), + {} + ); + }, [initialValues?.parameterValues]); + + const createTestCaseObj = useCallback( + (value: FormValues): CreateTestCase => { + const selectedDefinition = getSelectedTestDefinition(); + const tableName = + selectedTableData?.name || selectedTable || table?.name || ''; + const columnName = selectedColumn; + + const name = + value.testName?.trim() || + `${replaceAllSpacialCharWith_(columnName ?? tableName)}_${snakeCase( + selectedTestType + )}_${cryptoRandomString({ + length: 4, + type: 'alphanumeric', + })}`; + + // Generate entity link based on test level + const entityFqn = + selectedTableData?.fullyQualifiedName || + selectedTable || + table?.fullyQualifiedName || + ''; + const isColumnLevel = selectedTestLevel === TestLevel.COLUMN; + const entityLink = generateEntityLink( + isColumnLevel ? `${entityFqn}.${columnName}` : entityFqn, + isColumnLevel + ); + + return { + name, + displayName: name, + computePassedFailedRowCount: value.computePassedFailedRowCount, + entityLink, + testDefinition: value.testTypeId ?? '', + description: isEmpty(value.description) ? undefined : value.description, + tags: [...(value.tags ?? []), ...(value.glossaryTerms ?? [])], + ...testCaseClassBase.getCreateTestCaseObject( + value as TestCaseFormType, + selectedDefinition + ), + }; + }, + [ + getSelectedTestDefinition, + selectedTableData, + selectedTable, + table, + selectedColumn, + selectedTestType, + selectedTestLevel, + ] + ); + // Handle column selection and fetch test definitions with column type useEffect(() => { if ( @@ -369,7 +552,7 @@ const TestCaseFormV1: FC = ({ ); const generateParamsField = useMemo(() => { - if (selectedTestDefinition?.parameterDefinition) { + if (selectedTestDefinition?.parameterDefinition && !useDynamicAssertion) { return ( = ({ } return null; - }, [selectedTestDefinition, selectedTableData]); + }, [selectedTestDefinition, selectedTableData, useDynamicAssertion]); const isComputeRowCountFieldVisible = useMemo(() => { return selectedTestDefinition?.supportsRowLevelPassedFailed ?? false; }, [selectedTestDefinition]); + const isDynamicAssertionSupported = useMemo(() => { + return selectedTestDefinition?.supportsDynamicAssertion ?? false; + }, [selectedTestDefinition]); + const testDetailsFormFields: FieldProp[] = useMemo( () => [ { @@ -406,6 +593,19 @@ const TestCaseFormV1: FC = ({ max: 256, }), }, + { + validator: (_, value) => { + if (value && existingTestCases.includes(value)) { + return Promise.reject( + t('message.entity-already-exists', { + entity: t('label.name'), + }) + ); + } + + return Promise.resolve(); + }, + }, ], props: { 'data-testid': 'test-case-name', @@ -457,7 +657,25 @@ const TestCaseFormV1: FC = ({ }, }, ], - [initialValues?.description, initialValues?.tags, t] + [initialValues?.description, initialValues?.tags, existingTestCases, t] + ); + + const dynamicAssertionField: FieldProp[] = useMemo( + () => [ + { + name: 'useDynamicAssertion', + label: t('label.use-dynamic-assertion'), + type: FieldTypes.SWITCH, + required: false, + props: { + 'data-testid': 'use-dynamic-assertion', + className: 'use-dynamic-assertion-switch', + }, + id: 'root/useDynamicAssertion', + formItemLayout: FormItemLayout.HORIZONTAL, + }, + ], + [t] ); const computeRowCountField: FieldProp[] = useMemo( @@ -489,10 +707,23 @@ const TestCaseFormV1: FC = ({ className )}>
+ name="testCaseFormV1" + preserve={false} + onFinish={handleSubmit} + onValuesChange={handleValuesChange}> = ({ /> + {isDynamicAssertionSupported && + generateFormFields(dynamicAssertionField)} + {selectedTestDefinition && generateParamsField} @@ -576,11 +810,11 @@ const TestCaseFormV1: FC = ({ return ( + {...drawerProps} + onClose={onCancel}>
{formContent}
); 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 b4de55cc127..69a63f343e5 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 @@ -143,8 +143,8 @@ const DataQualityPage = () => { entity: t('label.test-case'), }), open: isTestCaseModalOpen, - onClose: handleCloseTestCaseModal, }} + onCancel={handleCloseTestCaseModal} /> );