diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/components/TestCaseFormV1.less b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/components/TestCaseFormV1.less index 693fe075847..e6a1b5cd2c6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/components/TestCaseFormV1.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataQuality/AddDataQualityTest/components/TestCaseFormV1.less @@ -13,6 +13,50 @@ @import (reference) '../../../../styles/variables.less'; .test-case-form-v1 { + .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; + } + + .select-table-card, + .test-type-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; + } + } + + .test-type-card { + margin-bottom: 0; + } + .test-level-section { margin-bottom: @size-lg; 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 9c636def69a..98160bfeb07 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 @@ -10,64 +10,321 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Drawer, DrawerProps, Form } from 'antd'; +import { Card, Drawer, DrawerProps, Form, Select, Typography } from 'antd'; import { useForm } from 'antd/lib/form/Form'; import classNames from 'classnames'; -import { FC, useEffect } from 'react'; +import { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { ReactComponent as ColumnIcon } from '../../../../assets/svg/ic-column.svg'; import { ReactComponent as TableIcon } from '../../../../assets/svg/ic-format-table.svg'; +import { + PAGE_SIZE_LARGE, + PAGE_SIZE_MEDIUM, +} from '../../../../constants/constants'; +import { SearchIndex } from '../../../../enums/search.enum'; +import { Table } from '../../../../generated/entity/data/table'; +import { + EntityType, + TestDefinition, + TestPlatform, +} from '../../../../generated/tests/testDefinition'; +import { TableSearchSource } from '../../../../interface/search.interface'; +import { searchQuery } from '../../../../rest/searchAPI'; +import { getTableDetailsByFQN } from '../../../../rest/tableAPI'; +import { getListTestDefinitions } from '../../../../rest/testAPI'; +import { filterSelectOptions } from '../../../../utils/CommonUtils'; +import { getEntityName } from '../../../../utils/EntityUtils'; +import { AsyncSelect } from '../../../common/AsyncSelect/AsyncSelect'; import SelectionCardGroup from '../../../common/SelectionCardGroup/SelectionCardGroup'; import { SelectionOption } from '../../../common/SelectionCardGroup/SelectionCardGroup.interface'; +import ParameterForm from './ParameterForm'; import './TestCaseFormV1.less'; export interface TestCaseFormV1Props { isDrawer?: boolean; drawerProps?: DrawerProps; className?: string; + table?: Table; onFormSubmit?: (values: FormValues) => void; initialValues?: Partial; } interface FormValues { testLevel: TestLevel; + selectedTable?: string; + selectedColumn?: string; + testTypeId?: string; } +type TablesCache = Map; + export enum TestLevel { TABLE = 'table', COLUMN = 'column', } +// Constants const TEST_LEVEL_OPTIONS: SelectionOption[] = [ { - value: 'table', + value: TestLevel.TABLE, label: 'Table Level', description: 'Test applied on table', icon: , }, { - value: 'column', + value: TestLevel.COLUMN, label: 'Column Level', description: 'Test applied on column', icon: , }, ]; +const TABLE_SEARCH_FIELDS: (keyof TableSearchSource)[] = [ + 'name', + 'fullyQualifiedName', + 'displayName', + 'columns', +]; + +// Helper function to convert TableSearchSource to Table +const convertSearchSourceToTable = (searchSource: TableSearchSource): Table => + ({ + ...searchSource, + columns: searchSource.columns || [], + } as Table); + +/** + * TestCaseFormV1 - An improved form component for creating test cases + * + * Features: + * - Progressive test level selection (Table/Column) + * - Smart table caching with column data + * - Dynamic test type filtering based on column data types + * - Efficient column selection without additional API calls + * - Dynamic parameter form rendering based on selected test definition + */ const TestCaseFormV1: FC = ({ className, drawerProps, initialValues, isDrawer = false, + table, onFormSubmit, }) => { const [form] = useForm(); + const [testDefinitions, setTestDefinitions] = useState([]); + const [selectedTestDefinition, setSelectedTestDefinition] = + useState(); + const [currentColumnType, setCurrentColumnType] = useState(); + const [isInitialized, setIsInitialized] = useState(false); + const [selectedTableData, setSelectedTableData] = useState( + table + ); + const [tablesCache, setTablesCache] = useState(new Map()); + const selectedTestLevel = Form.useWatch('testLevel', form); + const selectedTable = Form.useWatch('selectedTable', form); + const selectedColumn = Form.useWatch('selectedColumn', form); + const selectedTestType = Form.useWatch('testTypeId', form); const handleSubmit = (values: FormValues) => { onFormSubmit?.(values); }; + const fetchTables = useCallback(async (searchValue = '', page = 1) => { + try { + const response = await searchQuery({ + query: searchValue ? `*${searchValue}*` : '*', + pageNumber: page, + pageSize: PAGE_SIZE_MEDIUM, + searchIndex: SearchIndex.TABLE, + includeFields: TABLE_SEARCH_FIELDS, + trackTotalHits: true, + }); + + const data = response.hits.hits.map((hit) => { + // Cache the table data for later use + setTablesCache((prev) => { + const newCache = new Map(prev); + newCache.set( + hit._source.fullyQualifiedName ?? hit._source.name, + hit._source + ); + + return newCache; + }); + + return { + label: hit._source.fullyQualifiedName, + value: hit._source.fullyQualifiedName, + data: hit._source, + }; + }); + + // Return data in the format expected by AsyncSelect for infinite scroll + return { + data, + paging: { + total: response.hits.total.value, + }, + }; + } catch (error) { + return { + data: [], + paging: { total: 0 }, + }; + } + }, []); + + const columnOptions = useMemo(() => { + if (!selectedTableData?.columns) { + return []; + } + + return selectedTableData.columns.map((column) => ({ + label: column.name, + value: column.name, + data: column, + })); + }, [selectedTableData]); + + const fetchTestDefinitions = useCallback( + async (columnType?: string) => { + try { + const { data } = await getListTestDefinitions({ + limit: PAGE_SIZE_LARGE, + entityType: + selectedTestLevel === TestLevel.COLUMN + ? EntityType.Column + : EntityType.Table, + testPlatform: TestPlatform.OpenMetadata, + supportedDataType: columnType, + }); + + setTestDefinitions(data); + } catch (error) { + setTestDefinitions([]); + } + }, + [selectedTestLevel] + ); + + const fetchSelectedTableData = useCallback(async (tableFqn: string) => { + try { + const tableData = await getTableDetailsByFQN(tableFqn, { + fields: 'columns', + }); + setSelectedTableData(tableData); + } catch (error) { + setSelectedTableData(undefined); + } + }, []); + + // Initialize form with default values useEffect(() => { - form.setFieldsValue({ testLevel: TestLevel.TABLE }); - }, [form]); + if (!isInitialized) { + form.setFieldsValue({ testLevel: TestLevel.TABLE }); + setIsInitialized(true); + } + }, [form, isInitialized]); + + // Handle test level changes + useEffect(() => { + if (selectedTestLevel) { + // Fetch appropriate test definitions + fetchTestDefinitions(); + + // Reset dependent fields + form.setFieldsValue({ + testTypeId: undefined, + selectedColumn: + selectedTestLevel === TestLevel.TABLE + ? undefined + : form.getFieldValue('selectedColumn'), + }); + setSelectedTestDefinition(undefined); + } + }, [selectedTestLevel, fetchTestDefinitions, form]); + + // Handle table selection: use cached data or fetch if needed + useEffect(() => { + if (selectedTable) { + const cachedTableData = tablesCache.get(selectedTable); + if (cachedTableData) { + setSelectedTableData(convertSearchSourceToTable(cachedTableData)); + } else { + fetchSelectedTableData(selectedTable); + } + } else { + setSelectedTableData(table); + } + form.setFieldsValue({ selectedColumn: undefined }); + }, [selectedTable, table, tablesCache, fetchSelectedTableData, form]); + + // Handle column selection and fetch test definitions with column type + useEffect(() => { + if ( + selectedColumn && + selectedTableData && + selectedTestLevel === TestLevel.COLUMN + ) { + const selectedColumnData = selectedTableData.columns?.find( + (column) => column.name === selectedColumn + ); + + if (selectedColumnData?.dataType !== currentColumnType) { + fetchTestDefinitions(selectedColumnData?.dataType); + setCurrentColumnType(selectedColumnData?.dataType); + } + } + }, [ + selectedColumn, + selectedTableData, + currentColumnType, + selectedTestLevel, + fetchTestDefinitions, + ]); + + // Handle test type selection + const handleTestDefinitionChange = useCallback( + (value: string) => { + const testDefinition = testDefinitions.find( + (definition) => definition.fullyQualifiedName === value + ); + setSelectedTestDefinition(testDefinition); + }, + [testDefinitions] + ); + + const testTypeOptions = useMemo( + () => + testDefinitions.map((testDef) => ({ + label: ( +
+ + {getEntityName(testDef)} + + + {testDef.description} + +
+ ), + value: testDef.fullyQualifiedName ?? '', + labelValue: getEntityName(testDef), + })), + [testDefinitions] + ); + + const generateParamsField = useMemo(() => { + if (selectedTestDefinition?.parameterDefinition) { + return ( + + ); + } + + return null; + }, [selectedTestDefinition, selectedTableData]); const formContent = (
= ({ rules={[{ required: true, message: 'Please select test level' }]}> + + + + + + + {selectedTestLevel === TestLevel.COLUMN && selectedTable && ( + + + + + {selectedTestDefinition && generateParamsField} +
); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelect/AsyncSelect.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelect/AsyncSelect.tsx index 6ed02d61d20..3875d5efd86 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelect/AsyncSelect.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelect/AsyncSelect.tsx @@ -147,8 +147,10 @@ export const AsyncSelect = ({ ); useEffect(() => { - fetchOptions(searchText, 1); - }, [searchText]); + if (!restProps.disabled) { + fetchOptions(searchText, 1); + } + }, [searchText, restProps.disabled]); return (