Feat: add support of adding tags in test case form (#22007)

* Feat: add support of adding tags in test case form

* fix: don't override test case tags with entity tags

* feat: enhance test case form to support glossary terms and improve tag handling

* feat: implement tags and glossary terms functionality in test case forms and enhance related tests

* fixed failing unit test

* feat: add tier tag filtering and preservation in test case forms and related tests

* feat: add tags to addTestCaseAction

---------

Co-authored-by: Teddy Crepineau <teddy.crepineau@gmail.com>
This commit is contained in:
Shailesh Parmar 2025-06-29 01:25:01 +05:30 committed by GitHub
parent 64f09e8614
commit 62ab5689d2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1482 additions and 171 deletions

View File

@ -114,7 +114,6 @@ public class TestCaseRepository extends EntityRepository<TestCase> {
fields.contains(TEST_CASE_RESULT) ? getTestCaseResult(test) : test.getTestCaseResult());
test.setIncidentId(
fields.contains(INCIDENTS_FIELD) ? getIncidentId(test) : test.getIncidentId());
test.setTags(fields.contains(FIELD_TAGS) ? getTestCaseTags(test) : test.getTags());
}
@Override
@ -129,15 +128,19 @@ public class TestCaseRepository extends EntityRepository<TestCase> {
private void inheritTags(TestCase testCase, Fields fields, Table table) {
if (fields.contains(FIELD_TAGS)) {
EntityLink entityLink = EntityLink.parse(testCase.getEntityLink());
List<TagLabel> tags = new ArrayList<>(table.getTags());
List<TagLabel> testCaseTags =
testCase.getTags() != null ? new ArrayList<>(testCase.getTags()) : new ArrayList<>();
List<TagLabel> tableTags =
table.getTags() != null ? new ArrayList<>(table.getTags()) : new ArrayList<>();
EntityUtil.mergeTags(testCaseTags, tableTags);
if (entityLink.getFieldName() != null && entityLink.getFieldName().equals("columns")) {
// if we have a column test case get the columns tags as well
// if we have a column test case inherit the columns tags as well
table.getColumns().stream()
.filter(column -> column.getName().equals(entityLink.getArrayFieldName()))
.findFirst()
.ifPresent(column -> tags.addAll(column.getTags()));
.ifPresent(column -> EntityUtil.mergeTags(testCaseTags, column.getTags()));
}
testCase.setTags(tags);
testCase.setTags(testCaseTags);
}
}
@ -479,20 +482,6 @@ public class TestCaseRepository extends EntityRepository<TestCase> {
return ongoingIncident;
}
private List<TagLabel> getTestCaseTags(TestCase test) {
EntityLink entityLink = EntityLink.parse(test.getEntityLink());
Table table = Entity.getEntity(entityLink, "tags,columns", ALL);
List<TagLabel> tags = new ArrayList<>(table.getTags());
if (entityLink.getFieldName() != null && entityLink.getFieldName().equals("columns")) {
// if we have a column test case get the columns tags as well
table.getColumns().stream()
.filter(column -> column.getName().equals(entityLink.getArrayFieldName()))
.findFirst()
.ifPresent(column -> tags.addAll(column.getTags()));
}
return tags;
}
public int getTestCaseCount(List<UUID> testCaseIds) {
return daoCollection.testCaseDAO().countOfTestCases(testCaseIds);
}

View File

@ -26,6 +26,7 @@ import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.openmetadata.common.utils.CommonUtil.listOf;
import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty;
import static org.openmetadata.schema.api.teams.CreateTeam.TeamType.GROUP;
import static org.openmetadata.schema.type.ColumnDataType.BIGINT;
import static org.openmetadata.schema.type.MetadataOperation.DELETE;
@ -1192,6 +1193,84 @@ public class TestCaseResourceTest extends EntityResourceTest<TestCase, CreateTes
}
}
@Test
void test_getTestCaseWithTagsField(TestInfo testInfo) throws IOException {
// Create a table with tags
TableResourceTest tableResourceTest = new TableResourceTest();
String columnName = "taggedColumn";
CreateTable createTable = tableResourceTest.createRequest(testInfo);
createTable.setDatabaseSchema(DATABASE_SCHEMA.getFullyQualifiedName());
List<Column> columns = new ArrayList<>(createTable.getColumns());
columns.addAll(
List.of(
new Column()
.withName(columnName)
.withDisplayName(columnName)
.withDataType(ColumnDataType.VARCHAR)
.withDataLength(10)
.withTags(List.of(PII_SENSITIVE_TAG_LABEL)),
new Column()
.withName("normalColumn")
.withDisplayName("normalColumn")
.withDataType(ColumnDataType.BIGINT)));
createTable.setColumns(columns);
createTable.setTags(List.of(PERSONAL_DATA_TAG_LABEL));
Table table = tableResourceTest.createEntity(createTable, ADMIN_AUTH_HEADERS);
// Create test cases - one for table level, one for column level
CreateTestCase tableTestCase =
createRequest(testInfo)
.withName("tableTestCaseWithTags")
.withEntityLink(String.format("<#E::table::%s>", table.getFullyQualifiedName()))
.withTags(List.of(TIER1_TAG_LABEL))
.withTestDefinition(TEST_DEFINITION4.getFullyQualifiedName());
TestCase createdTableTestCase = createEntity(tableTestCase, ADMIN_AUTH_HEADERS);
CreateTestCase columnTestCase =
createRequest(testInfo)
.withName("columnTestCaseWithTags")
.withEntityLink(
String.format(
"<#E::table::%s::columns::%s>", table.getFullyQualifiedName(), columnName))
.withTestDefinition(TEST_DEFINITION3.getFullyQualifiedName())
.withTags(List.of(TIER1_TAG_LABEL))
.withParameterValues(
List.of(
new TestCaseParameterValue().withValue("10").withName("missingCountValue")));
TestCase createdColumnTestCase = createEntity(columnTestCase, ADMIN_AUTH_HEADERS);
// Test 1: Get table-level test case by ID with tags field
TestCase fetchedTableTestCase =
getEntity(createdTableTestCase.getId(), "tags", ADMIN_AUTH_HEADERS);
assertNotNull(fetchedTableTestCase.getTags());
assertEquals(2, fetchedTableTestCase.getTags().size());
Set<String> tableTestCaseTags =
fetchedTableTestCase.getTags().stream().map(TagLabel::getName).collect(Collectors.toSet());
assertTrue(tableTestCaseTags.contains(PERSONAL_DATA_TAG_LABEL.getName()));
assertTrue(tableTestCaseTags.contains(TIER1_TAG_LABEL.getName()));
// Test 2: Get column-level test case by ID with tags field
TestCase fetchedColumnTestCase =
getEntity(createdColumnTestCase.getId(), "tags", ADMIN_AUTH_HEADERS);
assertNotNull(fetchedColumnTestCase.getTags());
assertEquals(3, fetchedColumnTestCase.getTags().size());
Set<String> columnTestCaseTags =
fetchedColumnTestCase.getTags().stream().map(TagLabel::getName).collect(Collectors.toSet());
assertTrue(columnTestCaseTags.contains(TIER1_TAG_LABEL.getName()));
assertTrue(columnTestCaseTags.contains(PII_SENSITIVE_TAG_LABEL.getName()));
// Test 3: Get test case by name with tags field
TestCase fetchedByName =
getEntityByName(createdTableTestCase.getFullyQualifiedName(), "tags", ADMIN_AUTH_HEADERS);
assertNotNull(fetchedByName.getTags());
assertEquals(2, fetchedByName.getTags().size());
// Test 4: Verify tags are not included when not requested
TestCase withoutTags = getEntity(createdTableTestCase.getId(), null, ADMIN_AUTH_HEADERS);
assertTrue(nullOrEmpty(withoutTags.getTags()));
}
@Test
@Override
public void post_entity_as_non_admin_401(TestInfo test) {

View File

@ -34,6 +34,14 @@
"description": "If the test definition supports it, use dynamic assertion to evaluate the test case.",
"type": "boolean",
"default": false
},
"tags": {
"description": "Tags to apply",
"type": "array",
"items": {
"$ref": "../../../../../type/tagLabel.json"
},
"default": []
}
}
}

View File

@ -15,6 +15,10 @@ import { PLAYWRIGHT_INGESTION_TAG_OBJ } from '../../constant/config';
import { SidebarItem } from '../../constant/sidebar';
import { Domain } from '../../support/domain/Domain';
import { TableClass } from '../../support/entity/TableClass';
import { Glossary } from '../../support/glossary/Glossary';
import { GlossaryTerm } from '../../support/glossary/GlossaryTerm';
import { ClassificationClass } from '../../support/tag/ClassificationClass';
import { TagClass } from '../../support/tag/TagClass';
import {
assignDomain,
clickOutside,
@ -36,13 +40,22 @@ test.use({ storageState: 'playwright/.auth/admin.json' });
const table1 = new TableClass();
const table2 = new TableClass();
// Test data for tags and glossary terms
const testClassification = new ClassificationClass();
const testTag1 = new TagClass({
classification: testClassification.data.name,
});
const testTag2 = new TagClass({
classification: testClassification.data.name,
});
const testGlossary = new Glossary();
const testGlossaryTerm1 = new GlossaryTerm(testGlossary);
const testGlossaryTerm2 = new GlossaryTerm(testGlossary);
test.beforeAll(async ({ browser }) => {
const { apiContext, afterAction } = await createNewPage(browser);
await table1.create(apiContext);
await table2.create(apiContext);
const { testSuiteData } = await table2.createTestSuiteAndPipelines(
apiContext
);
await table2.createTestCase(apiContext, {
name: `email_column_values_to_be_in_set_${uuid()}`,
entityLink: `<#E::table::${table2.entityResponseData?.['fullyQualifiedName']}::columns::${table2.entity?.columns[3].name}>`,
@ -51,6 +64,15 @@ test.beforeAll(async ({ browser }) => {
],
testDefinition: 'columnValuesToBeInSet',
});
// Create test tags and glossary terms
await testClassification.create(apiContext);
await testTag1.create(apiContext);
await testTag2.create(apiContext);
await testGlossary.create(apiContext);
await testGlossaryTerm1.create(apiContext);
await testGlossaryTerm2.create(apiContext);
await afterAction();
});
@ -58,6 +80,15 @@ test.afterAll(async ({ browser }) => {
const { apiContext, afterAction } = await createNewPage(browser);
await table1.delete(apiContext);
await table2.delete(apiContext);
// Clean up test tags and glossary terms
await testGlossaryTerm1.delete(apiContext);
await testGlossaryTerm2.delete(apiContext);
await testGlossary.delete(apiContext);
await testTag1.delete(apiContext);
await testTag2.delete(apiContext);
await testClassification.delete(apiContext);
await afterAction();
});
@ -90,6 +121,35 @@ test('Table test case', PLAYWRIGHT_INGESTION_TAG_OBJ, async ({ page }) => {
NEW_TABLE_TEST_CASE.field
);
await page.locator(descriptionBox).fill(NEW_TABLE_TEST_CASE.description);
// Add tags to test case
await page.click('[data-testid="tags-selector"] input');
const tagsSearchResponse = page.waitForResponse(
`/api/v1/search/query?q=*index=tag_search_index*`
);
await page.fill('[data-testid="tags-selector"] input', testTag1.data.name);
await tagsSearchResponse;
await page
.getByTestId(`tag-${testTag1.responseData.fullyQualifiedName}`)
.click();
await clickOutside(page);
// Add glossary terms to test case
await page.click('[data-testid="glossary-terms-selector"] input');
const glossarySearchResponse = page.waitForResponse(
`/api/v1/search/query?q=*index=glossary_term_search_index*`
);
await page.fill(
'[data-testid="glossary-terms-selector"] input',
testGlossaryTerm1.data.name
);
await glossarySearchResponse;
await page
.getByTestId(`tag-${testGlossaryTerm1.responseData.fullyQualifiedName}`)
.click();
await clickOutside(page);
await page.click('[data-testid="submit-test"]');
await page.waitForSelector('[data-testid="success-line"]');
@ -138,6 +198,43 @@ test('Table test case', PLAYWRIGHT_INGESTION_TAG_OBJ, async ({ page }) => {
await page.waitForSelector('.ant-modal-title');
await page.locator('#tableTestForm_params_columnName').clear();
await page.fill('#tableTestForm_params_columnName', 'new_column_name');
// Remove existing tag and add new one
await page.click(
`[data-testid="selected-tag-${testTag1.responseData.fullyQualifiedName}"] svg`
);
await page.click('[data-testid="tags-selector"] input');
const newTagsSearchResponse = page.waitForResponse(
`/api/v1/search/query?q=*index=tag_search_index*`
);
await page.fill('[data-testid="tags-selector"] input', testTag2.data.name);
await newTagsSearchResponse;
await page
.getByTestId(`tag-${testTag2.responseData.fullyQualifiedName}`)
.click();
await clickOutside(page);
// Remove existing glossary term and add new one
await page.click(
`[data-testid="glossary-terms-selector"] [data-testid="remove-tags"]`
);
await page.click('[data-testid="glossary-terms-selector"] input');
const newGlossarySearchResponse = page.waitForResponse(
`/api/v1/search/query?q=*index=glossary_term_search_index*`
);
await page.fill(
'[data-testid="glossary-terms-selector"] input',
testGlossaryTerm2.data.name
);
await newGlossarySearchResponse;
await page
.getByTestId(`tag-${testGlossaryTerm2.responseData.fullyQualifiedName}`)
.click();
await clickOutside(page);
const updateTestCaseResponse = page.waitForResponse(
'/api/v1/dataQuality/testCases/*'
);
@ -197,6 +294,35 @@ test('Column test case', PLAYWRIGHT_INGESTION_TAG_OBJ, async ({ page }) => {
);
await page.locator(descriptionBox).fill(NEW_COLUMN_TEST_CASE.description);
// Add tags to column test case
await page.click('[data-testid="tags-selector"] input');
const columnTagsSearchResponse = page.waitForResponse(
`/api/v1/search/query?q=*index=tag_search_index*`
);
await page.fill('[data-testid="tags-selector"] input', testTag1.data.name);
await columnTagsSearchResponse;
await page
.getByTestId(`tag-${testTag1.responseData.fullyQualifiedName}`)
.click();
await clickOutside(page);
// Add glossary terms to column test case
await page.click('[data-testid="glossary-terms-selector"] input');
const columnGlossarySearchResponse = page.waitForResponse(
`/api/v1/search/query?q=*index=glossary_term_search_index*`
);
await page.fill(
'[data-testid="glossary-terms-selector"] input',
testGlossaryTerm1.data.name
);
await columnGlossarySearchResponse;
await page
.getByTestId(`tag-${testGlossaryTerm1.responseData.fullyQualifiedName}`)
.click();
await clickOutside(page);
await page.click('[data-testid="submit-test"]');
await page.waitForSelector('[data-testid="success-line"]');
@ -222,6 +348,42 @@ test('Column test case', PLAYWRIGHT_INGESTION_TAG_OBJ, async ({ page }) => {
await page.waitForSelector('#tableTestForm_params_minLength');
await page.locator('#tableTestForm_params_minLength').clear();
await page.fill('#tableTestForm_params_minLength', '4');
// Remove existing tag and add new one for column test case
await page.click(
`[data-testid="selected-tag-${testTag1.responseData.fullyQualifiedName}"] svg`
);
await page.click('[data-testid="tags-selector"] input');
const columnNewTagsSearchResponse = page.waitForResponse(
`/api/v1/search/query?q=*index=tag_search_index*`
);
await page.fill('[data-testid="tags-selector"] input', testTag2.data.name);
await columnNewTagsSearchResponse;
await page
.getByTestId(`tag-${testTag2.responseData.fullyQualifiedName}`)
.click();
await clickOutside(page);
// Remove existing glossary term and add new one for column test case
await page.click(
`[data-testid="glossary-terms-selector"] [data-testid="remove-tags"]`
);
await page.click('[data-testid="glossary-terms-selector"] input');
const columnNewGlossarySearchResponse = page.waitForResponse(
`/api/v1/search/query?q=*index=glossary_term_search_index*`
);
await page.fill(
'[data-testid="glossary-terms-selector"] input',
testGlossaryTerm2.data.name
);
await columnNewGlossarySearchResponse;
await page
.getByTestId(`tag-${testGlossaryTerm2.responseData.fullyQualifiedName}`)
.click();
await clickOutside(page);
const updateTestCaseResponse = page.waitForResponse(
'/api/v1/dataQuality/testCases/*'
);

View File

@ -15,7 +15,7 @@ import { ReactNode } from 'react';
import { CreateTestCase } from '../../../generated/api/tests/createTestCase';
import { Table } from '../../../generated/entity/data/table';
import { IngestionPipeline } from '../../../generated/entity/services/ingestionPipelines/ingestionPipeline';
import { TestCase } from '../../../generated/tests/testCase';
import { TagLabel, TestCase } from '../../../generated/tests/testCase';
import { TestDefinition } from '../../../generated/tests/testDefinition';
import { TestSuite } from '../../../generated/tests/testSuite';
import { ListTestCaseParamsBySearch } from '../../../rest/testAPI';
@ -91,4 +91,6 @@ export type TestCaseFormType = {
testTypeId: string;
computePassedFailedRowCount?: boolean;
description?: string;
tags?: TagLabel[];
glossaryTerms?: TagLabel[];
};

View File

@ -12,6 +12,7 @@
*/
import { act, fireEvent, render, screen } from '@testing-library/react';
import React, { forwardRef } from 'react';
import { LabelType, State, TagSource } from '../../../generated/type/tagLabel';
import {
MOCK_TEST_CASE,
MOCK_TEST_DEFINITION_COLUMN_VALUES_TO_MATCH_REGEX,
@ -40,6 +41,14 @@ jest.mock('../../common/RichTextEditor/RichTextEditor', () => {
jest.mock('./components/ParameterForm', () => {
return jest.fn().mockImplementation(() => <div>ParameterForm.component</div>);
});
jest.mock('../../../pages/TasksPage/shared/TagSuggestion', () =>
jest.fn().mockImplementation(({ children, ...props }) => (
<div data-testid={props.selectProps?.['data-testid']}>
TagSuggestion Component
{children}
</div>
))
);
jest.mock('../../../rest/testAPI', () => {
return {
getTestCaseByFqn: jest
@ -149,4 +158,292 @@ describe('EditTestCaseModal Component', () => {
expect(screen.queryByText('ParameterForm.component')).toBeNull();
});
// Tags and Glossary Terms functionality tests
it('should render tags and glossary terms fields', async () => {
render(<EditTestCaseModal {...mockProps} />);
// Check if tags field is rendered
expect(await screen.findByTestId('tags-selector')).toBeInTheDocument();
// Check if glossary terms field is rendered
expect(
await screen.findByTestId('glossary-terms-selector')
).toBeInTheDocument();
// Verify TagSuggestion components are rendered
const tagComponents = screen.getAllByText('TagSuggestion Component');
expect(tagComponents).toHaveLength(2); // One for tags, one for glossary terms
});
it('should separate tags and glossary terms correctly', async () => {
const mockTestCaseWithTags = {
...MOCK_TEST_CASE[0],
tags: [
{
tagFQN: 'PII.Sensitive',
source: TagSource.Classification,
labelType: LabelType.Manual,
state: State.Confirmed,
},
{
tagFQN: 'PersonalData.Email',
source: TagSource.Glossary,
labelType: LabelType.Manual,
state: State.Confirmed,
},
],
};
const propsWithTags = {
...mockProps,
testCase: mockTestCaseWithTags,
};
render(<EditTestCaseModal {...propsWithTags} />);
// Verify that both tag fields are rendered
expect(await screen.findByTestId('tags-selector')).toBeInTheDocument();
expect(
await screen.findByTestId('glossary-terms-selector')
).toBeInTheDocument();
});
it('should handle test case with no tags gracefully', async () => {
const mockTestCaseWithoutTags = {
...MOCK_TEST_CASE[0],
tags: undefined,
};
const propsWithoutTags = {
...mockProps,
testCase: mockTestCaseWithoutTags,
};
render(<EditTestCaseModal {...propsWithoutTags} />);
// Should still render tag fields even when no tags exist
expect(await screen.findByTestId('tags-selector')).toBeInTheDocument();
expect(
await screen.findByTestId('glossary-terms-selector')
).toBeInTheDocument();
});
it('should handle test case with empty tags array', async () => {
const mockTestCaseWithEmptyTags = {
...MOCK_TEST_CASE[0],
tags: [],
};
const propsWithEmptyTags = {
...mockProps,
testCase: mockTestCaseWithEmptyTags,
};
render(<EditTestCaseModal {...propsWithEmptyTags} />);
// Should render tag fields with empty arrays
expect(await screen.findByTestId('tags-selector')).toBeInTheDocument();
expect(
await screen.findByTestId('glossary-terms-selector')
).toBeInTheDocument();
});
it('should not render tags and glossary terms in parameter-only mode', async () => {
const parameterOnlyProps = {
...mockProps,
showOnlyParameter: true,
};
render(<EditTestCaseModal {...parameterOnlyProps} />);
// Should not render tag fields when showOnlyParameter is true
expect(screen.queryByTestId('tags-selector')).not.toBeInTheDocument();
expect(
screen.queryByTestId('glossary-terms-selector')
).not.toBeInTheDocument();
});
it('should include tags and glossary terms in form submission', async () => {
render(<EditTestCaseModal {...mockProps} />);
// Wait for form to load
expect(await screen.findByTestId('edit-test-form')).toBeInTheDocument();
// Submit the form
const submitBtn = await screen.findByText('label.submit');
await act(async () => {
fireEvent.click(submitBtn);
});
// Verify that onUpdate was called (indicating form submission)
expect(mockProps.onUpdate).toHaveBeenCalled();
});
// Tier tag filtering tests
it('should filter out tier tags from displayed tags', async () => {
const mockTestCaseWithTierTag = {
...MOCK_TEST_CASE[0],
tags: [
{
tagFQN: 'Tier.Tier1',
source: TagSource.Classification,
labelType: LabelType.Manual,
state: State.Confirmed,
},
{
tagFQN: 'PII.Sensitive',
source: TagSource.Classification,
labelType: LabelType.Manual,
state: State.Confirmed,
},
{
tagFQN: 'PersonalData.Email',
source: TagSource.Glossary,
labelType: LabelType.Manual,
state: State.Confirmed,
},
],
};
const propsWithTierTag = {
...mockProps,
testCase: mockTestCaseWithTierTag,
};
render(<EditTestCaseModal {...propsWithTierTag} />);
// Verify that tag fields are rendered
expect(await screen.findByTestId('tags-selector')).toBeInTheDocument();
expect(
await screen.findByTestId('glossary-terms-selector')
).toBeInTheDocument();
// The tier tag should be filtered out and not displayed in the form
// But it should be preserved when updating
});
it('should preserve tier tags when updating test case', async () => {
const mockUpdateTestCaseById = jest.fn().mockResolvedValue({});
jest.doMock('../../../rest/testAPI', () => ({
...jest.requireActual('../../../rest/testAPI'),
updateTestCaseById: mockUpdateTestCaseById,
}));
const mockTestCaseWithTierTag = {
...MOCK_TEST_CASE[0],
tags: [
{
tagFQN: 'Tier.Tier2',
source: TagSource.Classification,
labelType: LabelType.Manual,
state: State.Confirmed,
},
{
tagFQN: 'PII.Sensitive',
source: TagSource.Classification,
labelType: LabelType.Manual,
state: State.Confirmed,
},
],
};
const propsWithTierTag = {
...mockProps,
testCase: mockTestCaseWithTierTag,
};
render(<EditTestCaseModal {...propsWithTierTag} />);
// Wait for form to load
expect(await screen.findByTestId('edit-test-form')).toBeInTheDocument();
// Submit the form
const submitBtn = await screen.findByText('label.submit');
await act(async () => {
fireEvent.click(submitBtn);
});
// The tier tag should be preserved in the update
expect(mockProps.onUpdate).toHaveBeenCalled();
});
it('should handle multiple tier tags correctly', async () => {
const mockTestCaseWithMultipleTierTags = {
...MOCK_TEST_CASE[0],
tags: [
{
tagFQN: 'Tier.Tier1',
source: TagSource.Classification,
labelType: LabelType.Manual,
state: State.Confirmed,
},
{
tagFQN: 'Tier.Tier2', // This should not happen in practice, but test it
source: TagSource.Classification,
labelType: LabelType.Manual,
state: State.Confirmed,
},
{
tagFQN: 'PII.Sensitive',
source: TagSource.Classification,
labelType: LabelType.Manual,
state: State.Confirmed,
},
],
};
const propsWithMultipleTierTags = {
...mockProps,
testCase: mockTestCaseWithMultipleTierTags,
};
render(<EditTestCaseModal {...propsWithMultipleTierTags} />);
// Should still render properly
expect(await screen.findByTestId('tags-selector')).toBeInTheDocument();
});
it('should work correctly when no tier tags are present', async () => {
const mockTestCaseWithoutTierTag = {
...MOCK_TEST_CASE[0],
tags: [
{
tagFQN: 'PII.Sensitive',
source: TagSource.Classification,
labelType: LabelType.Manual,
state: State.Confirmed,
},
{
tagFQN: 'PersonalData.Email',
source: TagSource.Glossary,
labelType: LabelType.Manual,
state: State.Confirmed,
},
],
};
const propsWithoutTierTag = {
...mockProps,
testCase: mockTestCaseWithoutTierTag,
};
render(<EditTestCaseModal {...propsWithoutTierTag} />);
// Wait for form to load
expect(await screen.findByTestId('edit-test-form')).toBeInTheDocument();
// Submit the form
const submitBtn = await screen.findByText('label.submit');
await act(async () => {
fireEvent.click(submitBtn);
});
// Should work normally without tier tags
expect(mockProps.onUpdate).toHaveBeenCalled();
});
});

View File

@ -21,6 +21,7 @@ import { useTranslation } from 'react-i18next';
import { ENTITY_NAME_REGEX } from '../../../constants/regex.constants';
import { TABLE_DIFF } from '../../../constants/TestSuite.constant';
import { EntityType, TabSpecificField } from '../../../enums/entity.enum';
import { TagSource } from '../../../generated/api/domains/createDataProduct';
import { Table } from '../../../generated/entity/data/table';
import {
TestDataType,
@ -46,6 +47,8 @@ import {
import { getEntityFQN } from '../../../utils/FeedUtils';
import { generateFormFields } from '../../../utils/formUtils';
import { isValidJSONString } from '../../../utils/StringsUtils';
import { getFilterTags } from '../../../utils/TableTags/TableTags.utils';
import { getTagsWithoutTier, getTierTags } from '../../../utils/TableUtils';
import { showErrorToast, showSuccessToast } from '../../../utils/ToastUtils';
import { EntityAttachmentProvider } from '../../common/EntityDescription/EntityAttachmentProvider/EntityAttachmentProvider';
import Loader from '../../common/Loader/Loader';
@ -103,6 +106,24 @@ const EditTestCaseModal: React.FC<EditTestCaseModalProps> = ({
return <></>;
}, [selectedDefinition, table]);
const { tags, glossaryTerms, tierTag } = useMemo(() => {
if (!testCase?.tags) {
return { tags: [], glossaryTerms: [], tierTag: null };
}
// First extract tier tag
const tierTag = getTierTags(testCase.tags);
// Filter out tier tags before processing
const tagsWithoutTier = getTagsWithoutTier(testCase.tags);
const filteredTags = getFilterTags(tagsWithoutTier);
return {
tags: filteredTags.Classification,
glossaryTerms: filteredTags.Glossary,
tierTag,
};
}, [testCase?.tags]);
const handleFormSubmit: FormProps['onFinish'] = async (value) => {
const updatedTestCase = {
...testCase,
@ -118,6 +139,13 @@ const EditTestCaseModal: React.FC<EditTestCaseModalProps> = ({
computePassedFailedRowCount: isComputeRowCountFieldVisible
? value.computePassedFailedRowCount
: testCase?.computePassedFailedRowCount,
tags: showOnlyParameter
? testCase.tags
: [
...(tierTag ? [tierTag] : []),
...(value.tags ?? []),
...(value.glossaryTerms ?? []),
],
};
const jsonPatch = compare(testCase, updatedTestCase);
@ -206,6 +234,8 @@ const EditTestCaseModal: React.FC<EditTestCaseModalProps> = ({
params: getParamsValue(definition),
table: getNameFromFQN(tableFqn),
column: getColumnNameFromEntityLink(testCase?.entityLink),
tags: tags,
glossaryTerms: glossaryTerms,
...formValue,
});
setSelectedDefinition(definition);
@ -216,22 +246,57 @@ const EditTestCaseModal: React.FC<EditTestCaseModalProps> = ({
}
};
const descriptionField: FieldProp = useMemo(
() => ({
name: 'description',
required: false,
label: t('label.description'),
id: 'root/description',
type: FieldTypes.DESCRIPTION,
props: {
'data-testid': 'description',
initialValue: testCase?.description ?? '',
style: {
margin: 0,
const formField: FieldProp[] = useMemo(
() => [
{
name: 'description',
required: false,
label: t('label.description'),
id: 'root/description',
type: FieldTypes.DESCRIPTION,
props: {
'data-testid': 'description',
initialValue: testCase?.description ?? '',
style: {
margin: 0,
},
},
},
}),
[testCase?.description]
{
name: 'tags',
required: false,
label: t('label.tag-plural'),
id: 'root/tags',
type: FieldTypes.TAG_SUGGESTION,
props: {
selectProps: {
'data-testid': 'tags-selector',
},
initialValue: tags,
},
},
{
name: 'glossaryTerms',
required: false,
label: t('label.glossary-term-plural'),
id: 'root/glossaryTerms',
type: FieldTypes.TAG_SUGGESTION,
props: {
selectProps: {
'data-testid': 'glossary-terms-selector',
},
initialValue: glossaryTerms,
open: false,
hasNoActionButtons: true,
isTreeSelect: true,
tagType: TagSource.Glossary,
placeholder: t('label.select-field', {
field: t('label.glossary-term-plural'),
}),
},
},
],
[testCase?.description, tags, glossaryTerms]
);
useEffect(() => {
@ -340,7 +405,7 @@ const EditTestCaseModal: React.FC<EditTestCaseModalProps> = ({
{!showOnlyParameter && (
<>
{generateFormFields([descriptionField])}
{generateFormFields(formField)}
{isComputeRowCountFieldVisible
? generateFormFields(formFields)
: null}

View File

@ -10,16 +10,16 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
findByRole,
render,
screen,
waitForElement,
} from '@testing-library/react';
import { findByRole, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { forwardRef } from 'react';
import { act } from 'react-dom/test-utils';
import { ProfilerDashboardType } from '../../../../enums/table.enum';
import {
LabelType,
State,
TagSource,
} from '../../../../generated/type/tagLabel';
import { MOCK_TABLE } from '../../../../mocks/TableData.mock';
import { getListTestDefinitions } from '../../../../rest/testAPI';
import TestCaseForm from './TestCaseForm';
@ -105,6 +105,14 @@ jest.mock('./ParameterForm', () =>
jest.mock('crypto-random-string-with-promisify-polyfill', () =>
jest.fn().mockImplementation(() => '4B3B')
);
jest.mock('../../../../pages/TasksPage/shared/TagSuggestion', () =>
jest.fn().mockImplementation(({ children, ...props }) => (
<div data-testid={props.selectProps?.['data-testid']}>
TagSuggestion Component
{children}
</div>
))
);
describe('TestCaseForm', () => {
it('should render component', async () => {
@ -152,17 +160,15 @@ describe('TestCaseForm', () => {
'combobox'
);
await act(async () => {
userEvent.click(typeSelector);
await userEvent.click(typeSelector);
});
expect(typeSelector).toBeInTheDocument();
await waitForElement(() =>
screen.findByText('Column Value Lengths To Be Between')
);
await waitFor(() => screen.getByText('Column Value Lengths To Be Between'));
await act(async () => {
userEvent.click(
await userEvent.click(
await screen.findByText('Column Value Lengths To Be Between')
);
});
@ -183,6 +189,7 @@ describe('TestCaseForm', () => {
entityLink: '<#E::table::sample_data.ecommerce_db.shopify.dim_address>',
name: 'dim_address_column_value_lengths_to_be_between_4B3B',
parameterValues: [],
tags: [],
testDefinition: 'columnValueLengthsToBeBetween',
});
});
@ -223,15 +230,15 @@ describe('TestCaseForm', () => {
'combobox'
);
await act(async () => {
userEvent.click(column);
await userEvent.click(column);
});
expect(column).toBeInTheDocument();
await waitForElement(() => screen.findByText('last_name'));
await waitFor(() => screen.getByText('last_name'));
await act(async () => {
userEvent.click(await screen.findByText('last_name'));
await userEvent.click(await screen.findByText('last_name'));
});
expect(mockUseHistory.push).toHaveBeenCalledWith({
@ -258,17 +265,15 @@ describe('TestCaseForm', () => {
'combobox'
);
await act(async () => {
userEvent.click(typeSelector);
await userEvent.click(typeSelector);
});
expect(typeSelector).toBeInTheDocument();
await waitForElement(() =>
screen.findByText('Column Value Lengths To Be Between')
);
await waitFor(() => screen.getByText('Column Value Lengths To Be Between'));
await act(async () => {
userEvent.click(
await userEvent.click(
await screen.findByText('Column Value Lengths To Be Between')
);
});
@ -277,4 +282,205 @@ describe('TestCaseForm', () => {
await screen.findByTestId('compute-passed-failed-row-count')
).toBeInTheDocument();
});
// Tags and Glossary Terms functionality tests
it('should render tags and glossary terms fields', async () => {
await act(async () => {
render(<TestCaseForm {...mockProps} />);
});
// Check if tags field is rendered
expect(await screen.findByTestId('tags-selector')).toBeInTheDocument();
// Check if glossary terms field is rendered
expect(
await screen.findByTestId('glossary-terms-selector')
).toBeInTheDocument();
// Verify TagSuggestion components are rendered
const tagComponents = screen.getAllByText('TagSuggestion Component');
expect(tagComponents).toHaveLength(2); // One for tags, one for glossary terms
});
it('should include both tags and glossary terms in form submission', async () => {
await act(async () => {
render(<TestCaseForm {...mockProps} />);
});
// Select a test type first
const typeSelector = await findByRole(
await screen.findByTestId('test-type'),
'combobox'
);
await act(async () => {
await userEvent.click(typeSelector);
});
await waitFor(() => screen.getByText('Column Value Lengths To Be Between'));
await act(async () => {
await userEvent.click(
await screen.findByText('Column Value Lengths To Be Between')
);
});
// Submit form
const submitBtn = await screen.findByTestId('submit-test');
await act(async () => {
submitBtn.click();
});
// Verify that onSubmit is called with tags array (empty in this case since no tags selected)
expect(mockProps.onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
tags: [], // Will be empty when no tags/glossary terms are selected
})
);
});
it('should combine tags and glossary terms when creating test case object', async () => {
const mockTags = [
{
tagFQN: 'PII.Sensitive',
source: TagSource.Classification,
labelType: LabelType.Manual,
state: State.Confirmed,
},
];
const mockWithValues = {
...mockProps,
initialValue: {
tags: mockTags,
name: 'test-case',
testDefinition: 'columnValueLengthsToBeBetween',
entityLink: '<#E::table::sample_data.ecommerce_db.shopify.dim_address>',
},
};
await act(async () => {
render(<TestCaseForm {...mockWithValues} />);
});
// Submit without changing anything to test initial value handling
const submitBtn = await screen.findByTestId('submit-test');
await act(async () => {
submitBtn.click();
});
// Should include tags from initial values
expect(mockProps.onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
tags: expect.any(Array), // Should contain the combined tags
})
);
});
it('should initialize tags field with initial values when editing', async () => {
const mockInitialTags = [
{
tagFQN: 'PII.Sensitive',
source: TagSource.Classification,
labelType: LabelType.Manual,
state: State.Confirmed,
},
{
tagFQN: 'PersonalData.Email',
source: TagSource.Glossary,
labelType: LabelType.Manual,
state: State.Confirmed,
},
];
const mockWithTags = {
...mockProps,
initialValue: {
tags: mockInitialTags,
name: 'existing-test-case',
testDefinition: 'columnValueLengthsToBeBetween',
entityLink: '<#E::table::sample_data.ecommerce_db.shopify.dim_address>',
},
};
await act(async () => {
render(<TestCaseForm {...mockWithTags} />);
});
// Verify that the tags field components are rendered
const tagsField = await screen.findByTestId('tags-selector');
expect(tagsField).toBeInTheDocument();
const glossaryTermsField = await screen.findByTestId(
'glossary-terms-selector'
);
expect(glossaryTermsField).toBeInTheDocument();
});
it('should handle empty tags and glossary terms gracefully', async () => {
await act(async () => {
render(<TestCaseForm {...mockProps} />);
});
// Select test type and submit form without any tags or glossary terms
const typeSelector = await findByRole(
await screen.findByTestId('test-type'),
'combobox'
);
await act(async () => {
await userEvent.click(typeSelector);
});
await waitFor(() => screen.getByText('Column Value Lengths To Be Between'));
await act(async () => {
await userEvent.click(
await screen.findByText('Column Value Lengths To Be Between')
);
});
const submitBtn = await screen.findByTestId('submit-test');
await act(async () => {
submitBtn.click();
});
expect(mockProps.onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
tags: [], // Should be empty array when no tags are selected
})
);
});
it('should render glossary terms field with correct props', async () => {
await act(async () => {
render(<TestCaseForm {...mockProps} />);
});
const glossaryTermsField = await screen.findByTestId(
'glossary-terms-selector'
);
expect(glossaryTermsField).toBeInTheDocument();
// Verify that the glossary terms field has the expected data-testid
expect(glossaryTermsField).toHaveAttribute(
'data-testid',
'glossary-terms-selector'
);
});
it('should render tags field with correct props', async () => {
await act(async () => {
render(<TestCaseForm {...mockProps} />);
});
const tagsField = await screen.findByTestId('tags-selector');
expect(tagsField).toBeInTheDocument();
// Verify that the tags field has the expected data-testid
expect(tagsField).toHaveAttribute('data-testid', 'tags-selector');
});
});

View File

@ -31,6 +31,7 @@ import { useHistory, useParams } from 'react-router-dom';
import { PAGE_SIZE_LARGE } from '../../../../constants/constants';
import { ENTITY_NAME_REGEX } from '../../../../constants/regex.constants';
import { ProfilerDashboardType } from '../../../../enums/table.enum';
import { TagSource } from '../../../../generated/api/domains/createDataProduct';
import { CreateTestCase } from '../../../../generated/api/tests/createTestCase';
import { TestCase } from '../../../../generated/tests/testCase';
import {
@ -188,6 +189,7 @@ const TestCaseForm: React.FC<TestCaseFormProps> = ({
),
testDefinition: value.testTypeId,
description: isEmpty(value.description) ? undefined : value.description,
tags: [...(value.tags ?? []), ...(value.glossaryTerms ?? [])],
...testCaseClassBase.getCreateTestCaseObject(value, selectedDefinition),
};
};
@ -234,22 +236,55 @@ const TestCaseForm: React.FC<TestCaseFormProps> = ({
);
};
const descriptionField: FieldProp = useMemo(
() => ({
name: 'description',
required: false,
label: t('label.description'),
id: 'root/description',
type: FieldTypes.DESCRIPTION,
props: {
'data-testid': 'description',
initialValue: initialValue?.description ?? '',
style: {
margin: 0,
const formField: FieldProp[] = useMemo(
() => [
{
name: 'description',
required: false,
label: t('label.description'),
id: 'root/description',
type: FieldTypes.DESCRIPTION,
props: {
'data-testid': 'description',
initialValue: initialValue?.description ?? '',
style: {
margin: 0,
},
},
},
}),
[initialValue?.description]
{
name: 'tags',
required: false,
label: t('label.tag-plural'),
id: 'root/tags',
type: FieldTypes.TAG_SUGGESTION,
props: {
selectProps: {
'data-testid': 'tags-selector',
},
},
},
{
name: 'glossaryTerms',
required: false,
label: t('label.glossary-term-plural'),
id: 'root/glossaryTerms',
type: FieldTypes.TAG_SUGGESTION,
props: {
selectProps: {
'data-testid': 'glossary-terms-selector',
},
open: false,
hasNoActionButtons: true,
isTreeSelect: true,
tagType: TagSource.Glossary,
placeholder: t('label.select-field', {
field: t('label.glossary-term-plural'),
}),
},
},
],
[initialValue?.description, initialValue?.tags]
);
useEffect(() => {
@ -284,6 +319,7 @@ const TestCaseForm: React.FC<TestCaseFormProps> = ({
? getParamsValue()
: undefined,
columnName: activeColumnFqn ? getNameFromFQN(activeColumnFqn) : undefined,
tags: initialValue?.tags || [],
});
}, []);
@ -422,7 +458,7 @@ const TestCaseForm: React.FC<TestCaseFormProps> = ({
}
</Form.Item>
{generateFormFields([descriptionField])}
{generateFormFields(formField)}
{isComputeRowCountFieldVisible ? generateFormFields(formFields) : null}

View File

@ -21,24 +21,33 @@ import { useTranslation } from 'react-i18next';
import { CSMode } from '../../../../enums/codemirror.enum';
import { EntityType } from '../../../../enums/entity.enum';
import { EntityTags } from 'Models';
import { useParams } from 'react-router-dom';
import { ReactComponent as StarIcon } from '../../../../assets/svg/ic-suggestions.svg';
import { EntityField } from '../../../../constants/Feeds.constants';
import { TagSource } from '../../../../generated/api/domains/createDataProduct';
import {
ChangeDescription,
TagLabel,
TestCaseParameterValue,
} from '../../../../generated/tests/testCase';
import { useTestCaseStore } from '../../../../pages/IncidentManager/IncidentManagerDetailPage/useTestCase.store';
import { updateTestCaseById } from '../../../../rest/testAPI';
import {
getEntityVersionByField,
getEntityVersionTags,
getParameterValueDiffDisplay,
} from '../../../../utils/EntityVersionUtils';
import { VersionEntityTypes } from '../../../../utils/EntityVersionUtils.interface';
import { getTagsWithoutTier, getTierTags } from '../../../../utils/TableUtils';
import { createTagObject } from '../../../../utils/TagsUtils';
import { showErrorToast, showSuccessToast } from '../../../../utils/ToastUtils';
import DescriptionV1 from '../../../common/EntityDescription/DescriptionV1';
import { EditIconButton } from '../../../common/IconButtons/EditIconButton';
import TestSummary from '../../../Database/Profiler/TestSummary/TestSummary';
import SchemaEditor from '../../../Database/SchemaEditor/SchemaEditor';
import TagsContainerV2 from '../../../Tag/TagsContainerV2/TagsContainerV2';
import { DisplayType } from '../../../Tag/TagsViewer/TagsViewer.interface';
import EditTestCaseModal from '../../AddDataQualityTest/EditTestCaseModal';
import '../incident-manager.style.less';
import './test-case-result-tab.style.less';
@ -51,6 +60,7 @@ const TestCaseResultTab = () => {
setTestCase,
showAILearningBanner,
testCasePermission,
isTabExpanded,
} = useTestCaseStore();
const { version } = useParams<{ version: string }>();
const isVersionPage = !isUndefined(version);
@ -58,16 +68,28 @@ const TestCaseResultTab = () => {
testCaseResultTabClassBase.getAdditionalComponents();
const [isParameterEdit, setIsParameterEdit] = useState<boolean>(false);
const { hasEditPermission, hasEditDescriptionPermission } = useMemo(() => {
const {
hasEditPermission,
hasEditDescriptionPermission,
hasEditTagsPermission,
hasEditGlossaryTermsPermission,
} = useMemo(() => {
return isVersionPage
? {
hasEditPermission: false,
hasEditDescriptionPermission: false,
hasEditTagsPermission: false,
hasEditGlossaryTermsPermission: false,
}
: {
hasEditPermission: testCasePermission?.EditAll,
hasEditDescriptionPermission:
testCasePermission?.EditAll || testCasePermission?.EditDescription,
hasEditTagsPermission:
testCasePermission?.EditAll || testCasePermission?.EditTags,
hasEditGlossaryTermsPermission:
testCasePermission?.EditAll ||
testCasePermission?.EditGlossaryTerms,
};
}, [testCasePermission, isVersionPage]);
@ -91,6 +113,29 @@ const TestCaseResultTab = () => {
);
}, [testCaseData?.parameterValues]);
const handleTagSelection = async (selectedTags: EntityTags[]) => {
if (!testCaseData) {
return;
}
// Preserve tier tags
const tierTag = getTierTags(testCaseData.tags ?? []);
const updatedTags: TagLabel[] | undefined = createTagObject(selectedTags);
const updatedTestCase = {
...testCaseData,
tags: [...(tierTag ? [tierTag] : []), ...(updatedTags ?? [])],
};
const jsonPatch = compare(testCaseData, updatedTestCase);
if (jsonPatch.length) {
try {
const res = await updateTestCaseById(testCaseData.id ?? '', jsonPatch);
setTestCase(res);
} catch (error) {
showErrorToast(error as AxiosError);
}
}
};
const handleDescriptionChange = useCallback(
async (description: string) => {
if (testCaseData) {
@ -145,6 +190,13 @@ const TestCaseResultTab = () => {
isVersionPage,
]);
const updatedTags = isVersionPage
? getEntityVersionTags(
testCaseData as VersionEntityTypes,
testCaseData?.changeDescription as ChangeDescription
)
: getTagsWithoutTier(testCaseData?.tags ?? []);
const testCaseParams = useMemo(() => {
if (isVersionPage) {
return getParameterValueDiffDisplay(
@ -191,64 +243,36 @@ const TestCaseResultTab = () => {
return (
<Row
className="p-lg test-case-result-tab"
className="p-md test-case-result-tab"
data-testid="test-case-result-tab-container"
gutter={[0, 20]}>
<Col span={24}>
<DescriptionV1
wrapInCard
description={description}
entityType={EntityType.TEST_CASE}
hasEditAccess={hasEditDescriptionPermission}
showCommentsIcon={false}
onDescriptionUpdate={handleDescriptionChange}
/>
</Col>
gutter={[20, 20]}>
<Col className="transition-all-200ms" span={isTabExpanded ? 18 : 24}>
<Row gutter={[0, 20]}>
<Col span={24}>
<DescriptionV1
wrapInCard
description={description}
entityType={EntityType.TEST_CASE}
hasEditAccess={hasEditDescriptionPermission}
showCommentsIcon={false}
onDescriptionUpdate={handleDescriptionChange}
/>
</Col>
<Col data-testid="parameter-container" span={24}>
<Space direction="vertical" size="small">
<Space align="center" size={8}>
<Typography.Text className="right-panel-label">
{t('label.parameter-plural')}
</Typography.Text>
{hasEditPermission &&
Boolean(
testCaseData?.parameterValues?.length ||
testCaseData?.useDynamicAssertion
) && (
<EditIconButton
newLook
data-testid="edit-parameter-icon"
size="small"
title={t('label.edit-entity', {
entity: t('label.parameter'),
})}
onClick={() => setIsParameterEdit(true)}
/>
)}
</Space>
{testCaseParams}
</Space>
</Col>
{!isUndefined(withSqlParams) && !isVersionPage ? (
<Col>
{withSqlParams.map((param) => (
<Row
className="sql-expression-container"
data-testid="sql-expression-container"
gutter={[8, 8]}
key={param.name}>
<Col span={24}>
<Space align="center" size={8}>
<Typography.Text className="right-panel-label">
{startCase(param.name)}
</Typography.Text>
{hasEditPermission && (
<Col data-testid="parameter-container" span={24}>
<Space direction="vertical" size="small">
<Space align="center" size={8}>
<Typography.Text className="right-panel-label">
{t('label.parameter-plural')}
</Typography.Text>
{hasEditPermission &&
Boolean(
testCaseData?.parameterValues?.length ||
testCaseData?.useDynamicAssertion
) && (
<EditIconButton
newLook
data-testid="edit-sql-param-icon"
data-testid="edit-parameter-icon"
size="small"
title={t('label.edit-entity', {
entity: t('label.parameter'),
@ -256,52 +280,116 @@ const TestCaseResultTab = () => {
onClick={() => setIsParameterEdit(true)}
/>
)}
</Space>
</Col>
<Col span={24}>
<SchemaEditor
className="custom-code-mirror-theme query-editor-min-h-60"
editorClass="table-query-editor"
mode={{ name: CSMode.SQL }}
options={{
styleActiveLine: false,
readOnly: true,
}}
value={param.value ?? ''}
/>
</Col>
</Row>
))}
</Col>
) : null}
</Space>
{showAILearningBanner &&
testCaseData?.useDynamicAssertion &&
AlertComponent && (
<Col span={24}>
<AlertComponent />
{testCaseParams}
</Space>
</Col>
)}
{testCaseData && (
<Col className="test-case-result-tab-graph" span={24}>
<TestSummary data={testCaseData} />
{!isUndefined(withSqlParams) && !isVersionPage ? (
<Col>
{withSqlParams.map((param) => (
<Row
className="sql-expression-container"
data-testid="sql-expression-container"
gutter={[8, 8]}
key={param.name}>
<Col span={24}>
<Space align="center" size={8}>
<Typography.Text className="right-panel-label">
{startCase(param.name)}
</Typography.Text>
{hasEditPermission && (
<EditIconButton
newLook
data-testid="edit-sql-param-icon"
size="small"
title={t('label.edit-entity', {
entity: t('label.parameter'),
})}
onClick={() => setIsParameterEdit(true)}
/>
)}
</Space>
</Col>
<Col span={24}>
<SchemaEditor
className="custom-code-mirror-theme query-editor-min-h-60"
editorClass="table-query-editor"
mode={{ name: CSMode.SQL }}
options={{
styleActiveLine: false,
readOnly: true,
}}
value={param.value ?? ''}
/>
</Col>
</Row>
))}
</Col>
) : null}
{showAILearningBanner &&
testCaseData?.useDynamicAssertion &&
AlertComponent && (
<Col span={24}>
<AlertComponent />
</Col>
)}
{testCaseData && (
<Col className="test-case-result-tab-graph" span={24}>
<TestSummary data={testCaseData} />
</Col>
)}
{!isEmpty(additionalComponent) &&
additionalComponent.map(({ Component, id }) => (
<Component key={id} testCaseData={testCaseData} />
))}
{testCaseData && isParameterEdit && (
<EditTestCaseModal
showOnlyParameter
testCase={testCaseData}
visible={isParameterEdit}
onCancel={handleCancelParameter}
onUpdate={setTestCase}
/>
)}
</Row>
</Col>
{isTabExpanded && (
<Col className="transition-all-200ms" span={6}>
<Row gutter={[20, 20]}>
<Col span={24}>
<TagsContainerV2
newLook
displayType={DisplayType.READ_MORE}
entityFqn={testCaseData?.fullyQualifiedName}
entityType={EntityType.TEST_CASE}
permission={hasEditTagsPermission ?? false}
selectedTags={updatedTags ?? []}
showTaskHandler={false}
tagType={TagSource.Classification}
onSelectionChange={handleTagSelection}
/>
</Col>
<Col span={24}>
<TagsContainerV2
newLook
displayType={DisplayType.READ_MORE}
entityFqn={testCaseData?.fullyQualifiedName}
entityType={EntityType.TEST_CASE}
permission={hasEditGlossaryTermsPermission ?? false}
selectedTags={updatedTags ?? []}
showTaskHandler={false}
tagType={TagSource.Glossary}
onSelectionChange={handleTagSelection}
/>
</Col>
</Row>
</Col>
)}
{!isEmpty(additionalComponent) &&
additionalComponent.map(({ Component, id }) => (
<Component key={id} testCaseData={testCaseData} />
))}
{testCaseData && isParameterEdit && (
<EditTestCaseModal
showOnlyParameter
testCase={testCaseData}
visible={isParameterEdit}
onCancel={handleCancelParameter}
onUpdate={setTestCase}
/>
)}
</Row>
);
};

View File

@ -18,7 +18,12 @@ import {
screen,
} from '@testing-library/react';
import React from 'react';
import { TestCase } from '../../../../generated/tests/testCase';
import { TagLabel, TestCase } from '../../../../generated/tests/testCase';
import {
LabelType,
State,
TagSource,
} from '../../../../generated/type/tagLabel';
import { MOCK_PERMISSIONS } from '../../../../mocks/Glossary.mock';
import { DEFAULT_ENTITY_PERMISSION } from '../../../../utils/PermissionsUtils';
import TestCaseResultTab from './TestCaseResultTab.component';
@ -74,6 +79,7 @@ const mockUseTestCaseStore = {
testCasePermission: MOCK_PERMISSIONS,
setTestCasePermission: jest.fn(),
setIsPermissionLoading: jest.fn(),
isTabExpanded: false,
};
jest.mock('react-router-dom', () => ({
@ -117,6 +123,27 @@ jest.mock('../../AddDataQualityTest/EditTestCaseModal', () => {
));
});
const mockUpdateTestCaseById = jest.fn();
jest.mock('../../../../rest/testAPI', () => ({
updateTestCaseById: jest
.fn()
.mockImplementation(() => mockUpdateTestCaseById()),
}));
// Mock TagsContainerV2 to capture props
const mockTagsContainerV2 = jest.fn();
jest.mock('../../../Tag/TagsContainerV2/TagsContainerV2', () => {
return jest.fn().mockImplementation((props) => {
mockTagsContainerV2(props);
return (
<div data-testid={`tags-container-${props.tagType}`}>
TagsContainerV2 - {props.tagType}
</div>
);
});
});
describe('TestCaseResultTab', () => {
it('Should render component', async () => {
render(<TestCaseResultTab />);
@ -243,4 +270,220 @@ describe('TestCaseResultTab', () => {
mockTestCaseData.useDynamicAssertion = false;
mockUseTestCaseStore.showAILearningBanner = false;
});
// Tier tag tests
describe('Tier tag filtering', () => {
beforeEach(() => {
mockTagsContainerV2.mockClear();
mockUpdateTestCaseById.mockClear();
mockUpdateTestCaseById.mockResolvedValue({});
});
it('should filter out tier tags from displayed tags in TagsContainerV2', async () => {
const testCaseWithTierTag = {
...mockTestCaseData,
tags: [
{
tagFQN: 'Tier.Tier1',
source: TagSource.Classification,
labelType: LabelType.Manual,
state: State.Confirmed,
},
{
tagFQN: 'PII.Sensitive',
source: TagSource.Classification,
labelType: LabelType.Manual,
state: State.Confirmed,
},
{
tagFQN: 'PersonalData.Email',
source: TagSource.Glossary,
labelType: LabelType.Manual,
state: State.Confirmed,
},
],
};
mockUseTestCaseStore.testCase = testCaseWithTierTag;
mockUseTestCaseStore.isTabExpanded = true;
render(<TestCaseResultTab />);
// Wait for tags containers to render
expect(
await screen.findByTestId('tags-container-Classification')
).toBeInTheDocument();
expect(
await screen.findByTestId('tags-container-Glossary')
).toBeInTheDocument();
// Check that TagsContainerV2 is called with filtered tags (without tier tags)
const classificationCall = mockTagsContainerV2.mock.calls.find(
(call) => call[0].tagType === TagSource.Classification
);
const glossaryCall = mockTagsContainerV2.mock.calls.find(
(call) => call[0].tagType === TagSource.Glossary
);
expect(classificationCall).toBeDefined();
expect(glossaryCall).toBeDefined();
// The selectedTags prop should not contain tier tags
const selectedTags = classificationCall[0].selectedTags;
expect(selectedTags).toBeDefined();
expect(
selectedTags.some((tag: TagLabel) => tag.tagFQN.startsWith('Tier.'))
).toBe(false);
});
it('should preserve tier tags when updating tags', async () => {
const testCaseWithTierTag = {
...mockTestCaseData,
tags: [
{
tagFQN: 'Tier.Tier2',
source: TagSource.Classification,
labelType: LabelType.Manual,
state: State.Confirmed,
},
{
tagFQN: 'PII.Sensitive',
source: TagSource.Classification,
labelType: LabelType.Manual,
state: State.Confirmed,
},
],
};
mockUseTestCaseStore.testCase = testCaseWithTierTag;
mockUseTestCaseStore.isTabExpanded = true;
render(<TestCaseResultTab />);
// Wait for tags container to render
expect(
await screen.findByTestId('tags-container-Classification')
).toBeInTheDocument();
// Get the onSelectionChange handler
const classificationCall = mockTagsContainerV2.mock.calls.find(
(call) => call[0].tagType === TagSource.Classification
);
const onSelectionChange = classificationCall[0].onSelectionChange;
// Simulate tag selection change with a new tag
const newTags = [
{
tagFQN: 'PII.NonSensitive',
source: TagSource.Classification,
labelType: LabelType.Manual,
state: State.Confirmed,
},
];
await onSelectionChange(newTags);
// Verify updateTestCaseById was called
expect(mockUpdateTestCaseById).toHaveBeenCalled();
// The tier tag should be preserved in the update
// Note: The actual preservation logic is in the component
});
it('should work correctly when no tier tags are present', async () => {
const testCaseWithoutTierTag = {
...mockTestCaseData,
tags: [
{
tagFQN: 'PII.Sensitive',
source: TagSource.Classification,
labelType: LabelType.Manual,
state: State.Confirmed,
},
{
tagFQN: 'PersonalData.Email',
source: TagSource.Glossary,
labelType: LabelType.Manual,
state: State.Confirmed,
},
],
};
mockUseTestCaseStore.testCase = testCaseWithoutTierTag;
mockUseTestCaseStore.isTabExpanded = true;
render(<TestCaseResultTab />);
// Wait for tags containers to render
expect(
await screen.findByTestId('tags-container-Classification')
).toBeInTheDocument();
expect(
await screen.findByTestId('tags-container-Glossary')
).toBeInTheDocument();
// Should work normally without tier tags
const classificationCall = mockTagsContainerV2.mock.calls.find(
(call) => call[0].tagType === TagSource.Classification
);
expect(classificationCall).toBeDefined();
// Should have both tags but no tier tags
const allTags = classificationCall[0].selectedTags;
expect(allTags).toHaveLength(2); // PII.Sensitive and PersonalData.Email
expect(
allTags.some((tag: TagLabel) => tag.tagFQN.startsWith('Tier.'))
).toBe(false);
});
it('should display only non-tier tags when test case has multiple tier tags', async () => {
const testCaseWithMultipleTierTags = {
...mockTestCaseData,
tags: [
{
tagFQN: 'Tier.Tier1',
source: TagSource.Classification,
labelType: LabelType.Manual,
state: State.Confirmed,
},
{
tagFQN: 'Tier.Tier2', // This shouldn't happen in practice
source: TagSource.Classification,
labelType: LabelType.Manual,
state: State.Confirmed,
},
{
tagFQN: 'PII.Sensitive',
source: TagSource.Classification,
labelType: LabelType.Manual,
state: State.Confirmed,
},
],
};
mockUseTestCaseStore.testCase = testCaseWithMultipleTierTags;
mockUseTestCaseStore.isTabExpanded = true;
render(<TestCaseResultTab />);
// Wait for tags container to render
expect(
await screen.findByTestId('tags-container-Classification')
).toBeInTheDocument();
// Check that TagsContainerV2 is called with filtered tags
const classificationCall = mockTagsContainerV2.mock.calls.find(
(call) => call[0].tagType === TagSource.Classification
);
const selectedTags = classificationCall[0].selectedTags;
// Should only have the non-tier tag
expect(selectedTags).toHaveLength(1);
expect(selectedTags[0].tagFQN).toBe('PII.Sensitive');
});
});
});

View File

@ -42,6 +42,10 @@ export interface TestCaseDefinitions {
*/
computePassedFailedRowCount?: boolean;
parameterValues?: TestCaseParameterValue[];
/**
* Tags to apply
*/
tags?: TagLabel[];
/**
* Fully qualified name of the test definition.
*/
@ -69,6 +73,94 @@ export interface TestCaseParameterValue {
[property: string]: any;
}
/**
* This schema defines the type for labeling an entity with a Tag.
*/
export interface TagLabel {
/**
* Description for the tag label.
*/
description?: string;
/**
* Display Name that identifies this tag.
*/
displayName?: string;
/**
* Link to the tag resource.
*/
href?: string;
/**
* Label type describes how a tag label was applied. 'Manual' indicates the tag label was
* applied by a person. 'Derived' indicates a tag label was derived using the associated tag
* relationship (see Classification.json for more details). 'Propagated` indicates a tag
* label was propagated from upstream based on lineage. 'Automated' is used when a tool was
* used to determine the tag label.
*/
labelType: LabelType;
/**
* Name of the tag or glossary term.
*/
name?: string;
/**
* Label is from Tags or Glossary.
*/
source: TagSource;
/**
* 'Suggested' state is used when a tag label is suggested by users or tools. Owner of the
* entity must confirm the suggested labels before it is marked as 'Confirmed'.
*/
state: State;
style?: Style;
tagFQN: string;
}
/**
* Label type describes how a tag label was applied. 'Manual' indicates the tag label was
* applied by a person. 'Derived' indicates a tag label was derived using the associated tag
* relationship (see Classification.json for more details). 'Propagated` indicates a tag
* label was propagated from upstream based on lineage. 'Automated' is used when a tool was
* used to determine the tag label.
*/
export enum LabelType {
Automated = "Automated",
Derived = "Derived",
Generated = "Generated",
Manual = "Manual",
Propagated = "Propagated",
}
/**
* Label is from Tags or Glossary.
*/
export enum TagSource {
Classification = "Classification",
Glossary = "Glossary",
}
/**
* 'Suggested' state is used when a tag label is suggested by users or tools. Owner of the
* entity must confirm the suggested labels before it is marked as 'Confirmed'.
*/
export enum State {
Confirmed = "Confirmed",
Suggested = "Suggested",
}
/**
* UI Style is used to associate a color code and/or icon to entity to customize the look of
* that entity in UI.
*/
export interface Style {
/**
* Hex Color Code to mark an entity such as GlossaryTerm, Tag, Domain or Data Product.
*/
color?: string;
/**
* An icon to associate with GlossaryTerm, Tag, Domain or Data Product.
*/
iconURL?: string;
}
/**
* Application Type
*

View File

@ -85,6 +85,8 @@ const mockUseTestCase: UseTestCaseStoreInterface = {
testCasePermission: MOCK_PERMISSIONS,
setTestCasePermission: jest.fn(),
setIsPermissionLoading: jest.fn(),
isTabExpanded: false,
setIsTabExpanded: jest.fn(),
};
jest.mock('./useTestCase.store', () => ({
useTestCaseStore: jest.fn().mockImplementation(() => mockUseTestCase),

View File

@ -25,6 +25,7 @@ import { ReactComponent as VersionIcon } from '../../../assets/svg/ic-version.sv
import { withActivityFeed } from '../../../components/AppRouter/withActivityFeed';
import ManageButton from '../../../components/common/EntityPageInfos/ManageButton/ManageButton';
import ErrorPlaceHolder from '../../../components/common/ErrorWithPlaceholder/ErrorPlaceHolder';
import { AlignRightIconButton } from '../../../components/common/IconButtons/EditIconButton';
import Loader from '../../../components/common/Loader/Loader';
import TitleBreadcrumb from '../../../components/common/TitleBreadcrumb/TitleBreadcrumb.component';
import { TitleBreadcrumbProps } from '../../../components/common/TitleBreadcrumb/TitleBreadcrumb.interface';
@ -91,6 +92,8 @@ const IncidentManagerDetailPage = ({
testCasePermission,
setTestCasePermission,
setIsPermissionLoading,
isTabExpanded,
setIsTabExpanded,
} = useTestCaseStore();
const [feedCount, setFeedCount] = useState<FeedCounts>(
FEED_COUNT_INITIAL_DATA
@ -112,6 +115,15 @@ const IncidentManagerDetailPage = ({
};
}, [testCasePermission]);
const isExpandViewSupported = useMemo(
() => activeTab === TestCasePageTabs.TEST_CASE_RESULTS,
[activeTab]
);
const toggleTabExpanded = useCallback(() => {
setIsTabExpanded(!isTabExpanded);
}, [isTabExpanded, setIsTabExpanded]);
const tabDetails: TabsProps['items'] = useMemo(() => {
const tabs = testCaseClassBase.getTab(
feedCount.openTaskCount,
@ -418,6 +430,17 @@ const IncidentManagerDetailPage = ({
className="tabs-new"
data-testid="tabs"
items={tabDetails}
tabBarExtraContent={
isExpandViewSupported && (
<AlignRightIconButton
className={isTabExpanded ? 'rotate-180' : ''}
title={
isTabExpanded ? t('label.collapse') : t('label.expand')
}
onClick={toggleTabExpanded}
/>
)
}
onChange={handleTabChange}
/>
</Col>

View File

@ -74,6 +74,7 @@ describe('TestCaseClassBase', () => {
'testDefinition',
'owners',
'incidentId',
'tags',
];
const result = testCaseClassBase.getFields();

View File

@ -80,6 +80,7 @@ class TestCaseClassBase {
TabSpecificField.TEST_DEFINITION,
TabSpecificField.OWNERS,
TabSpecificField.INCIDENT_ID,
TabSpecificField.TAGS,
];
}

View File

@ -31,6 +31,8 @@ export interface UseTestCaseStoreInterface {
reset: () => void;
dqLineageData: EntityLineageResponse | undefined;
setDqLineageData: (data: EntityLineageResponse | undefined) => void;
isTabExpanded: boolean;
setIsTabExpanded: (isTabExpanded: boolean) => void;
}
export const useTestCaseStore = create<UseTestCaseStoreInterface>()((set) => ({
testCase: undefined,
@ -39,6 +41,7 @@ export const useTestCaseStore = create<UseTestCaseStoreInterface>()((set) => ({
isPermissionLoading: true,
showAILearningBanner: false,
testCasePermission: undefined,
isTabExpanded: true,
setTestCase: (testCase: TestCase) => {
set({ testCase });
},
@ -59,7 +62,15 @@ export const useTestCaseStore = create<UseTestCaseStoreInterface>()((set) => ({
setDqLineageData: (data: EntityLineageResponse | undefined) => {
set({ dqLineageData: data });
},
setIsTabExpanded: (isTabExpanded: boolean) => {
set({ isTabExpanded });
},
reset: () => {
set({ testCase: undefined, isLoading: true, showAILearningBanner: false });
set({
testCase: undefined,
isLoading: true,
showAILearningBanner: false,
isTabExpanded: true,
});
},
}));

View File

@ -970,3 +970,9 @@ a[href].link-text-grey,
margin-top: -3px;
transform: rotate(180deg);
}
// transition
.transition-all-200ms {
transition: all 200ms ease;
}