Enhance Test Case Form Functionality and UI

- Added dynamic assertion toggle and validation for test case names to prevent duplicates.
- Improved form initialization with default values and enhanced parameter handling.
- Integrated existing test case fetching based on selected tables and columns.
- Updated form submission logic to create test cases and display success/error messages.
- Enhanced overall user experience with better state management and UI adjustments.
This commit is contained in:
Shailesh Parmar 2025-07-01 22:43:56 +05:30
parent 13a62cb431
commit 8d2dde74b0
2 changed files with 252 additions and 18 deletions

View File

@ -21,7 +21,10 @@ import {
Typography, Typography,
} from 'antd'; } from 'antd';
import { useForm } from 'antd/lib/form/Form'; import { useForm } from 'antd/lib/form/Form';
import { AxiosError } from 'axios';
import classNames from 'classnames'; 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 { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ReactComponent as ColumnIcon } from '../../../../assets/svg/ic-column.svg'; import { ReactComponent as ColumnIcon } from '../../../../assets/svg/ic-column.svg';
@ -33,10 +36,12 @@ import {
import { ENTITY_NAME_REGEX } from '../../../../constants/regex.constants'; import { ENTITY_NAME_REGEX } from '../../../../constants/regex.constants';
import { SearchIndex } from '../../../../enums/search.enum'; import { SearchIndex } from '../../../../enums/search.enum';
import { TagSource } from '../../../../generated/api/domains/createDataProduct'; import { TagSource } from '../../../../generated/api/domains/createDataProduct';
import { CreateTestCase } from '../../../../generated/api/tests/createTestCase';
import { Table } from '../../../../generated/entity/data/table'; import { Table } from '../../../../generated/entity/data/table';
import { TagLabel } from '../../../../generated/tests/testCase'; import { TagLabel, TestCase } from '../../../../generated/tests/testCase';
import { import {
EntityType, EntityType,
TestDataType,
TestDefinition, TestDefinition,
TestPlatform, TestPlatform,
} from '../../../../generated/tests/testDefinition'; } from '../../../../generated/tests/testDefinition';
@ -46,15 +51,26 @@ import {
FormItemLayout, FormItemLayout,
} from '../../../../interface/FormUtils.interface'; } from '../../../../interface/FormUtils.interface';
import { TableSearchSource } from '../../../../interface/search.interface'; import { TableSearchSource } from '../../../../interface/search.interface';
import testCaseClassBase from '../../../../pages/IncidentManager/IncidentManagerDetailPage/TestCaseClassBase';
import { searchQuery } from '../../../../rest/searchAPI'; import { searchQuery } from '../../../../rest/searchAPI';
import { getTableDetailsByFQN } from '../../../../rest/tableAPI'; import { getTableDetailsByFQN } from '../../../../rest/tableAPI';
import { getListTestDefinitions } from '../../../../rest/testAPI'; import {
import { filterSelectOptions } from '../../../../utils/CommonUtils'; createTestCase,
getListTestCaseBySearch,
getListTestDefinitions,
} from '../../../../rest/testAPI';
import {
filterSelectOptions,
replaceAllSpacialCharWith_,
} from '../../../../utils/CommonUtils';
import { getEntityName } from '../../../../utils/EntityUtils'; import { getEntityName } from '../../../../utils/EntityUtils';
import { generateFormFields } from '../../../../utils/formUtils'; import { generateFormFields } from '../../../../utils/formUtils';
import { generateEntityLink } from '../../../../utils/TableUtils';
import { showErrorToast, showSuccessToast } from '../../../../utils/ToastUtils';
import { AsyncSelect } from '../../../common/AsyncSelect/AsyncSelect'; import { AsyncSelect } from '../../../common/AsyncSelect/AsyncSelect';
import SelectionCardGroup from '../../../common/SelectionCardGroup/SelectionCardGroup'; import SelectionCardGroup from '../../../common/SelectionCardGroup/SelectionCardGroup';
import { SelectionOption } from '../../../common/SelectionCardGroup/SelectionCardGroup.interface'; import { SelectionOption } from '../../../common/SelectionCardGroup/SelectionCardGroup.interface';
import { TestCaseFormType } from '../AddDataQualityTest.interface';
import ParameterForm from './ParameterForm'; import ParameterForm from './ParameterForm';
import './TestCaseFormV1.less'; import './TestCaseFormV1.less';
@ -63,7 +79,7 @@ export interface TestCaseFormV1Props {
drawerProps?: DrawerProps; drawerProps?: DrawerProps;
className?: string; className?: string;
table?: Table; table?: Table;
onFormSubmit?: (values: FormValues) => void; onFormSubmit?: (testCase: TestCase) => void;
onCancel?: () => void; onCancel?: () => void;
initialValues?: Partial<FormValues>; initialValues?: Partial<FormValues>;
loading?: boolean; loading?: boolean;
@ -79,6 +95,9 @@ interface FormValues {
tags?: TagLabel[]; tags?: TagLabel[];
glossaryTerms?: TagLabel[]; glossaryTerms?: TagLabel[];
computePassedFailedRowCount?: boolean; computePassedFailedRowCount?: boolean;
useDynamicAssertion?: boolean;
params?: Record<string, string | { [key: string]: string }[]>;
parameterValues?: Array<{ name: string; value: string }>;
} }
type TablesCache = Map<string, TableSearchSource>; type TablesCache = Map<string, TableSearchSource>;
@ -127,27 +146,48 @@ const TestCaseFormV1: FC<TestCaseFormV1Props> = ({
onFormSubmit, onFormSubmit,
onCancel, onCancel,
loading: externalLoading = false, loading: externalLoading = false,
}) => { }: TestCaseFormV1Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [form] = useForm<FormValues>(); const [form] = useForm<FormValues>();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [testDefinitions, setTestDefinitions] = useState<TestDefinition[]>([]); const [testDefinitions, setTestDefinitions] = useState<TestDefinition[]>([]);
const [selectedTestDefinition, setSelectedTestDefinition] = const [selectedTestDefinition, setSelectedTestDefinition] =
useState<TestDefinition>(); useState<TestDefinition>();
const [selectedTestType, setSelectedTestType] = useState<string | undefined>(
initialValues?.testTypeId
);
const [currentColumnType, setCurrentColumnType] = useState<string>(); const [currentColumnType, setCurrentColumnType] = useState<string>();
const [isInitialized, setIsInitialized] = useState(false); const [isInitialized, setIsInitialized] = useState(false);
const [selectedTableData, setSelectedTableData] = useState<Table | undefined>( const [selectedTableData, setSelectedTableData] = useState<Table | undefined>(
table table
); );
const [tablesCache, setTablesCache] = useState<TablesCache>(new Map()); const [tablesCache, setTablesCache] = useState<TablesCache>(new Map());
const [existingTestCases, setExistingTestCases] = useState<string[]>([]);
const selectedTestLevel = Form.useWatch('testLevel', form); const selectedTestLevel = Form.useWatch('testLevel', form);
const selectedTable = Form.useWatch('selectedTable', form); const selectedTable = Form.useWatch('selectedTable', form);
const selectedColumn = Form.useWatch('selectedColumn', form); const selectedColumn = Form.useWatch('selectedColumn', form);
const useDynamicAssertion = Form.useWatch('useDynamicAssertion', form);
const handleSubmit = async (values: FormValues) => { const handleSubmit = async (values: FormValues) => {
setLoading(true); setLoading(true);
try { 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 { } finally {
setLoading(false); setLoading(false);
} }
@ -157,6 +197,21 @@ const TestCaseFormV1: FC<TestCaseFormV1Props> = ({
onCancel?.(); onCancel?.();
}; };
const handleValuesChange = useCallback(
(changedValues: Partial<FormValues>) => {
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; const isFormLoading = loading || externalLoading;
// Reusable action buttons component // Reusable action buttons component
@ -273,13 +328,42 @@ const TestCaseFormV1: FC<TestCaseFormV1Props> = ({
} }
}, []); }, []);
// 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(() => { useEffect(() => {
if (!isInitialized) { if (!isInitialized) {
form.setFieldsValue({ testLevel: TestLevel.TABLE });
setIsInitialized(true); 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 // Handle test level changes
useEffect(() => { useEffect(() => {
@ -297,7 +381,7 @@ const TestCaseFormV1: FC<TestCaseFormV1Props> = ({
}); });
setSelectedTestDefinition(undefined); setSelectedTestDefinition(undefined);
} }
}, [selectedTestLevel, fetchTestDefinitions, form]); }, [selectedTestLevel, fetchTestDefinitions]);
// Handle table selection: use cached data or fetch if needed // Handle table selection: use cached data or fetch if needed
useEffect(() => { useEffect(() => {
@ -314,6 +398,105 @@ const TestCaseFormV1: FC<TestCaseFormV1Props> = ({
form.setFieldsValue({ selectedColumn: undefined }); form.setFieldsValue({ selectedColumn: undefined });
}, [selectedTable, table, tablesCache, fetchSelectedTableData, form]); }, [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 // Handle column selection and fetch test definitions with column type
useEffect(() => { useEffect(() => {
if ( if (
@ -369,7 +552,7 @@ const TestCaseFormV1: FC<TestCaseFormV1Props> = ({
); );
const generateParamsField = useMemo(() => { const generateParamsField = useMemo(() => {
if (selectedTestDefinition?.parameterDefinition) { if (selectedTestDefinition?.parameterDefinition && !useDynamicAssertion) {
return ( return (
<ParameterForm <ParameterForm
definition={selectedTestDefinition} definition={selectedTestDefinition}
@ -379,12 +562,16 @@ const TestCaseFormV1: FC<TestCaseFormV1Props> = ({
} }
return null; return null;
}, [selectedTestDefinition, selectedTableData]); }, [selectedTestDefinition, selectedTableData, useDynamicAssertion]);
const isComputeRowCountFieldVisible = useMemo(() => { const isComputeRowCountFieldVisible = useMemo(() => {
return selectedTestDefinition?.supportsRowLevelPassedFailed ?? false; return selectedTestDefinition?.supportsRowLevelPassedFailed ?? false;
}, [selectedTestDefinition]); }, [selectedTestDefinition]);
const isDynamicAssertionSupported = useMemo(() => {
return selectedTestDefinition?.supportsDynamicAssertion ?? false;
}, [selectedTestDefinition]);
const testDetailsFormFields: FieldProp[] = useMemo( const testDetailsFormFields: FieldProp[] = useMemo(
() => [ () => [
{ {
@ -406,6 +593,19 @@ const TestCaseFormV1: FC<TestCaseFormV1Props> = ({
max: 256, 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: { props: {
'data-testid': 'test-case-name', 'data-testid': 'test-case-name',
@ -457,7 +657,25 @@ const TestCaseFormV1: FC<TestCaseFormV1Props> = ({
}, },
}, },
], ],
[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( const computeRowCountField: FieldProp[] = useMemo(
@ -489,10 +707,23 @@ const TestCaseFormV1: FC<TestCaseFormV1Props> = ({
className className
)}> )}>
<Form <Form
data-testid="test-case-form-v1"
form={form} form={form}
initialValues={initialValues} initialValues={{
testLevel: TestLevel.TABLE,
...testCaseClassBase.initialFormValues(),
testName: replaceAllSpacialCharWith_(initialValues?.testName ?? ''),
testTypeId: initialValues?.testTypeId,
params: getInitialParamsValue,
tags: initialValues?.tags || [],
useDynamicAssertion: initialValues?.useDynamicAssertion ?? false,
...initialValues,
}}
layout="vertical" layout="vertical"
onFinish={handleSubmit}> name="testCaseFormV1"
preserve={false}
onFinish={handleSubmit}
onValuesChange={handleValuesChange}>
<Form.Item <Form.Item
label="Select on which element your test should be performed" label="Select on which element your test should be performed"
name="testLevel" name="testLevel"
@ -551,6 +782,9 @@ const TestCaseFormV1: FC<TestCaseFormV1Props> = ({
/> />
</Form.Item> </Form.Item>
{isDynamicAssertionSupported &&
generateFormFields(dynamicAssertionField)}
{selectedTestDefinition && generateParamsField} {selectedTestDefinition && generateParamsField}
</Card> </Card>
@ -576,11 +810,11 @@ const TestCaseFormV1: FC<TestCaseFormV1Props> = ({
return ( return (
<Drawer <Drawer
destroyOnClose destroyOnClose
open
footer={drawerFooter} footer={drawerFooter}
placement="right" placement="right"
size="large" size="large"
{...drawerProps}> {...drawerProps}
onClose={onCancel}>
<div className="drawer-form-content">{formContent}</div> <div className="drawer-form-content">{formContent}</div>
</Drawer> </Drawer>
); );

View File

@ -143,8 +143,8 @@ const DataQualityPage = () => {
entity: t('label.test-case'), entity: t('label.test-case'),
}), }),
open: isTestCaseModalOpen, open: isTestCaseModalOpen,
onClose: handleCloseTestCaseModal,
}} }}
onCancel={handleCloseTestCaseModal}
/> />
</DataQualityProvider> </DataQualityProvider>
); );