Enhance Data Quality Test Case Form UI

- Improved layout and styling for the Test Case Form, including margin adjustments and label styling.
- Added functionality for selecting tables and columns with dynamic fetching and caching.
- Implemented test type selection with corresponding parameter forms based on selected definitions.
- Updated AsyncSelect component to handle disabled state correctly.
- Adjusted SelectionCardGroup styles for better visual consistency.
This commit is contained in:
Shailesh Parmar 2025-07-01 19:26:56 +05:30
parent 4cb5987438
commit 2aa5431488
5 changed files with 389 additions and 12 deletions

View File

@ -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;

View File

@ -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<FormValues>;
}
interface FormValues {
testLevel: TestLevel;
selectedTable?: string;
selectedColumn?: string;
testTypeId?: string;
}
type TablesCache = Map<string, TableSearchSource>;
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: <TableIcon />,
},
{
value: 'column',
value: TestLevel.COLUMN,
label: 'Column Level',
description: 'Test applied on column',
icon: <ColumnIcon />,
},
];
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<TestCaseFormV1Props> = ({
className,
drawerProps,
initialValues,
isDrawer = false,
table,
onFormSubmit,
}) => {
const [form] = useForm<FormValues>();
const [testDefinitions, setTestDefinitions] = useState<TestDefinition[]>([]);
const [selectedTestDefinition, setSelectedTestDefinition] =
useState<TestDefinition>();
const [currentColumnType, setCurrentColumnType] = useState<string>();
const [isInitialized, setIsInitialized] = useState(false);
const [selectedTableData, setSelectedTableData] = useState<Table | undefined>(
table
);
const [tablesCache, setTablesCache] = useState<TablesCache>(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: (
<div data-testid={testDef.fullyQualifiedName}>
<Typography.Paragraph className="m-b-0">
{getEntityName(testDef)}
</Typography.Paragraph>
<Typography.Paragraph className="m-b-0 text-grey-muted text-xs">
{testDef.description}
</Typography.Paragraph>
</div>
),
value: testDef.fullyQualifiedName ?? '',
labelValue: getEntityName(testDef),
})),
[testDefinitions]
);
const generateParamsField = useMemo(() => {
if (selectedTestDefinition?.parameterDefinition) {
return (
<ParameterForm
definition={selectedTestDefinition}
table={selectedTableData}
/>
);
}
return null;
}, [selectedTestDefinition, selectedTableData]);
const formContent = (
<div
@ -90,6 +347,63 @@ const TestCaseFormV1: FC<TestCaseFormV1Props> = ({
rules={[{ required: true, message: 'Please select test level' }]}>
<SelectionCardGroup options={TEST_LEVEL_OPTIONS} />
</Form.Item>
<Card className="select-table-card">
<Form.Item
label="Select Table"
name="selectedTable"
rules={[{ required: true, message: 'Please select a table' }]}>
<AsyncSelect
allowClear
enableInfiniteScroll
showSearch
api={fetchTables}
placeholder="Select one or more table at a time"
size="large"
/>
</Form.Item>
{selectedTestLevel === TestLevel.COLUMN && selectedTable && (
<Form.Item
label="Select Column"
name="selectedColumn"
rules={[{ required: true, message: 'Please select a column' }]}>
<Select
allowClear
showSearch
filterOption={(input, option) =>
(option?.label ?? '')
.toLowerCase()
.includes(input.toLowerCase())
}
loading={!selectedTableData}
options={columnOptions}
placeholder="Select a column"
size="large"
/>
</Form.Item>
)}
</Card>
<Card className="test-type-card">
<Form.Item
label="Test Type"
name="testTypeId"
rules={[{ required: true, message: 'Please select a test type' }]}
tooltip={selectedTestDefinition?.description}>
<Select
showSearch
filterOption={filterSelectOptions}
options={testTypeOptions}
placeholder="Select a test type"
popupClassName="no-wrap-option"
size="large"
onChange={handleTestDefinitionChange}
/>
</Form.Item>
{selectedTestDefinition && generateParamsField}
</Card>
</Form>
</div>
);

View File

@ -147,8 +147,10 @@ export const AsyncSelect = ({
);
useEffect(() => {
fetchOptions(searchText, 1);
}, [searchText]);
if (!restProps.disabled) {
fetchOptions(searchText, 1);
}
}, [searchText, restProps.disabled]);
return (
<Select

View File

@ -20,7 +20,7 @@
.selection-card {
flex: 1;
border: 1px solid @grey-200;
border-radius: @border-radius-xs;
border-radius: @border-radius-sm;
cursor: pointer;
transition: all 0.3s ease;
background: @grey-50;

View File

@ -13,7 +13,7 @@
import { Button, Card, Col, Row, Tabs } from 'antd';
import { isEmpty } from 'lodash';
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router-dom';
import ManageButton from '../../components/common/EntityPageInfos/ManageButton/ManageButton';
@ -38,6 +38,19 @@ const DataQualityPage = () => {
const { t } = useTranslation();
const { permissions } = usePermissionProvider();
const { testSuite: testSuitePermission } = permissions;
// Add state for modal open/close
const [isTestCaseModalOpen, setIsTestCaseModalOpen] = useState(false);
// Add handlers for modal
const handleOpenTestCaseModal = () => {
setIsTestCaseModalOpen(true);
};
const handleCloseTestCaseModal = () => {
setIsTestCaseModalOpen(false);
};
const menuItems = useMemo(() => {
const data = DataQualityClassBase.getDataQualityTab();
@ -93,7 +106,10 @@ const DataQualityPage = () => {
)}
{activeTab === DataQualityPageTabs.TEST_CASES &&
testSuitePermission?.Create && (
<Button data-testid="add-test-case-btn" type="primary">
<Button
data-testid="add-test-case-btn"
type="primary"
onClick={handleOpenTestCaseModal}>
{t('label.add-entity', {
entity: t('label.test-case'),
})}
@ -126,7 +142,8 @@ const DataQualityPage = () => {
title: t('label.add-entity', {
entity: t('label.test-case'),
}),
open: false,
open: isTestCaseModalOpen,
onClose: handleCloseTestCaseModal,
}}
/>
</DataQualityProvider>