mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-09-03 14:13:06 +00:00
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:
parent
4cb5987438
commit
2aa5431488
@ -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;
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -147,8 +147,10 @@ export const AsyncSelect = ({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOptions(searchText, 1);
|
||||
}, [searchText]);
|
||||
if (!restProps.disabled) {
|
||||
fetchOptions(searchText, 1);
|
||||
}
|
||||
}, [searchText, restProps.disabled]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user