Fix(ui) : Updated tag and selection group style (#22708)

* Updated tag and slection group style

* added add testCase drawer in the contract form

* added pagination

* fixed unit tests

* fix the pagination in table schema form

---------

Co-authored-by: Ashish Gupta <ashish@getcollate.io>
This commit is contained in:
Dhruv Parmar 2025-08-02 23:25:26 +05:30 committed by GitHub
parent 34cd7178e2
commit bcc8cc1e39
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 409 additions and 88 deletions

View File

@ -126,6 +126,9 @@
border-radius: 8px;
border: 1px solid @border-color-7;
box-shadow: @button-box-shadow-default;
display: flex;
flex-direction: column;
gap: @size-lg;
}
// Table styling

View File

@ -142,7 +142,11 @@ const ContractDetail: React.FC<{
title: t('label.type'),
dataIndex: 'dataType',
key: 'dataType',
render: (type: string) => <Tag color="purple">{type}</Tag>,
render: (type: string) => (
<Tag className="custom-tag" color="purple">
{type}
</Tag>
),
},
{
title: t('label.constraint-plural'),
@ -151,7 +155,9 @@ const ContractDetail: React.FC<{
render: (constraint: string) => (
<div>
{constraint ? (
<Tag color="blue">{constraint}</Tag>
<Tag className="custom-tag" color="blue">
{constraint}
</Tag>
) : (
<Typography.Text data-testid="no-constraints">
{NO_DATA_PLACEHOLDER}

View File

@ -243,3 +243,12 @@
}
}
}
.ant-tag.custom-tag {
border-radius: 6px;
padding: 2px @size-xs 2px 6px;
font-size: @size-sm;
font-weight: @font-medium;
max-width: 100%;
text-wrap: auto;
border: none;
}

View File

@ -11,26 +11,39 @@
* limitations under the License.
*/
import { ArrowLeftOutlined } from '@ant-design/icons';
import { Button, Card, Radio, Typography } from 'antd';
import { AxiosError } from 'axios';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { EntityType } from '../../../enums/entity.enum';
import { Table as TableType } from '../../../generated/entity/data/table';
import { TestCase, TestCaseStatus } from '../../../generated/tests/testCase';
import { EntityReference } from '../../../generated/type/entityReference';
import { usePaging } from '../../../hooks/paging/usePaging';
import { listTestCases, TestCaseType } from '../../../rest/testAPI';
import { showErrorToast } from '../../../utils/ToastUtils';
import Table from '../../common/Table/Table';
import Icon, { ArrowLeftOutlined } from '@ant-design/icons';
import { Button, Card, Typography } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import { AxiosError } from 'axios';
import { toLower } from 'lodash';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ReactComponent as PlusIcon } from '../../../assets/svg/x-colored.svg';
import { DEFAULT_SORT_ORDER } from '../../../constants/profiler.constant';
import { EntityType, TabSpecificField } from '../../../enums/entity.enum';
import { DataContract } from '../../../generated/entity/data/dataContract';
import { Table as TableType } from '../../../generated/entity/data/table';
import { TestCase, TestCaseResult } from '../../../generated/tests/testCase';
import { EntityReference } from '../../../generated/type/entityReference';
import { Include } from '../../../generated/type/include';
import { usePaging } from '../../../hooks/paging/usePaging';
import {
getListTestCaseBySearch,
ListTestCaseParamsBySearch,
TestCaseType,
} from '../../../rest/testAPI';
import { TEST_LEVEL_OPTIONS } from '../../../utils/DataQuality/DataQualityUtils';
import { generateEntityLink } from '../../../utils/TableUtils';
import { showErrorToast } from '../../../utils/ToastUtils';
import { PagingHandlerParams } from '../../common/NextPrevious/NextPrevious.interface';
import { SelectionCard } from '../../common/SelectionCardGroup/SelectionCardGroup';
import StatusBadge from '../../common/StatusBadge/StatusBadge.component';
import { StatusType } from '../../common/StatusBadge/StatusBadge.interface';
import Table from '../../common/Table/Table';
import { useGenericContext } from '../../Customization/GenericProvider/GenericProvider';
import TestCaseFormV1 from '../../DataQuality/AddDataQualityTest/components/TestCaseFormV1';
import { TestLevel } from '../../DataQuality/AddDataQualityTest/components/TestCaseFormV1.interface';
import './contract-quality-form-tab.less';
export const ContractQualityFormTab: React.FC<{
selectedQuality: string[];
@ -41,47 +54,38 @@ export const ContractQualityFormTab: React.FC<{
const [testType, setTestType] = useState<TestCaseType>(TestCaseType.table);
const [allTestCases, setAllTestCases] = useState<TestCase[]>([]);
const { data: table } = useGenericContext<TableType>();
const { pageSize, handlePagingChange } = usePaging();
const [isTestsLoading, setIsTestsLoading] = useState<boolean>(false);
const [selectedKeys, setSelectedKeys] = useState<string[]>(
selectedQuality ?? []
);
const [isTestCaseDrawerOpen, setIsTestCaseDrawerOpen] =
useState<boolean>(false);
const {
currentPage,
pageSize,
handlePageChange,
handlePageSizeChange,
showPagination,
paging,
handlePagingChange,
} = usePaging();
const { t } = useTranslation();
const columns: ColumnsType<TestCase> = useMemo(
() => [
{
title: t('label.name'),
dataIndex: 'name',
},
{
title: t('label.status'),
dataIndex: 'testCaseStatus',
key: 'testCaseStatus',
render: (testCaseStatus: TestCaseStatus) => {
return (
<StatusBadge
dataTestId={`status-badge-${testCaseStatus}`}
label={testCaseStatus}
status={toLower(testCaseStatus) as StatusType}
/>
);
},
},
],
[]
);
const fetchAllTests = async () => {
const fetchAllTests = async (params?: ListTestCaseParamsBySearch) => {
if (!table?.fullyQualifiedName) {
return;
}
setIsTestsLoading(true);
try {
const { data, paging } = await listTestCases({
entityFQN: table.fullyQualifiedName,
const { data, paging } = await getListTestCaseBySearch({
...DEFAULT_SORT_ORDER,
...params,
testCaseType: testType,
fields: [TabSpecificField.TEST_CASE_RESULT],
entityLink: generateEntityLink(table.fullyQualifiedName ?? ''),
includeAllTests: true,
limit: pageSize,
include: Include.NonDeleted,
});
setAllTestCases(data);
@ -93,9 +97,77 @@ export const ContractQualityFormTab: React.FC<{
}
};
useEffect(() => {
const handleTestPageChange = useCallback(
({ currentPage }: PagingHandlerParams) => {
fetchAllTests({
offset: (currentPage - 1) * pageSize,
});
handlePageChange(currentPage);
},
[pageSize, fetchAllTests, handlePageChange]
);
const handleOpenTestCaseDrawer = useCallback(() => {
setIsTestCaseDrawerOpen(true);
}, []);
const handleCloseTestCaseDrawer = useCallback(() => {
setIsTestCaseDrawerOpen(false);
}, []);
const handleTestCaseSubmit = useCallback(() => {
handleCloseTestCaseDrawer();
fetchAllTests();
}, [testType]);
}, [handleCloseTestCaseDrawer, fetchAllTests]);
const columns: ColumnsType<TestCase> = useMemo(
() => [
{
title: t('label.name'),
dataIndex: 'name',
},
{
title: t('label.status'),
dataIndex: 'testCaseResult',
key: 'testCaseResult',
render: (result: TestCaseResult, record) => {
return result?.testCaseStatus ? (
<StatusBadge
dataTestId={`status-badge-${record.name}`}
label={result.testCaseStatus}
status={toLower(result.testCaseStatus) as StatusType}
/>
) : (
'--'
);
},
},
],
[]
);
const paginationProps = useMemo(
() => ({
currentPage,
showPagination,
isLoading: isTestsLoading,
isNumberBased: false,
pageSize,
paging,
pagingHandler: handleTestPageChange,
onShowSizeChange: handlePageSizeChange,
}),
[
currentPage,
showPagination,
isTestsLoading,
pageSize,
paging,
handleTestPageChange,
handlePageSizeChange,
]
);
const handleSelection = (selectedRowKeys: string[]) => {
const qualityExpectations = selectedRowKeys.map((id) => {
@ -114,31 +186,47 @@ export const ContractQualityFormTab: React.FC<{
});
};
useEffect(() => {
fetchAllTests();
}, [testType]);
return (
<Card className="container bg-grey p-box">
<div>
<Typography.Text className="contract-detail-form-tab-title">
{t('label.quality')}
</Typography.Text>
<Typography.Text className="contract-detail-form-tab-description">
{t('message.quality-contract-description')}
</Typography.Text>
<Card className="contract-quality-form-tab-container container bg-grey p-box">
<div className="d-flex justify-between">
<div>
<Typography.Text className="contract-detail-form-tab-title">
{t('label.quality')}
</Typography.Text>
<Typography.Text className="contract-detail-form-tab-description">
{t('message.quality-contract-description')}
</Typography.Text>
</div>
<Button
className="add-test-case-button"
data-testid="add-test-button"
icon={<Icon className="anticon" component={PlusIcon} />}
onClick={handleOpenTestCaseDrawer}>
{t('label.add-entity', {
entity: t('label.test'),
})}
</Button>
</div>
<div className="contract-form-content-container">
<Radio.Group
className="m-b-sm"
value={testType}
onChange={(e) => setTestType(e.target.value)}>
<Radio.Button value={TestCaseType.table}>
{t('label.table')}
</Radio.Button>
<Radio.Button value={TestCaseType.column}>
{t('label.column')}
</Radio.Button>
</Radio.Group>
<div className="contract-form-content-container ">
<div className="w-full selection-card-group">
{TEST_LEVEL_OPTIONS.map((option) => (
<SelectionCard
isSelected={testType === option.value}
key={option.value}
option={option}
onClick={() => setTestType(option.value as TestCaseType)}
/>
))}
</div>
<Table
columns={columns}
customPaginationProps={paginationProps}
dataSource={allTestCases}
loading={isTestsLoading}
pagination={false}
@ -158,6 +246,18 @@ export const ContractQualityFormTab: React.FC<{
{prevLabel ?? t('label.previous')}
</Button>
</div>
{isTestCaseDrawerOpen && (
<TestCaseFormV1
drawerProps={{
open: isTestCaseDrawerOpen,
}}
table={table}
testLevel={TestLevel.TABLE}
onCancel={handleCloseTestCaseDrawer}
onFormSubmit={handleTestCaseSubmit}
/>
)}
</Card>
);
};

View File

@ -0,0 +1,28 @@
/*
* Copyright 2025 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@import (reference) url('../../../styles/variables.less');
.contract-quality-form-tab-container {
.add-test-case-button {
font-weight: 600;
color: @grey-700;
border: 1px solid @grey-300 !important;
box-shadow: 0px 1px 2px rgba(10, 13, 18, 0.05),
inset 0px -2px 0px rgba(10, 13, 18, 0.05);
.anticon {
transform: rotate(45deg);
}
}
}

View File

@ -11,25 +11,32 @@
* limitations under the License.
*/
import { ArrowLeftOutlined, ArrowRightOutlined } from '@ant-design/icons';
import { Button, Card, Typography } from 'antd';
import { Button, Card, Tag, Typography } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import { isEmpty } from 'lodash';
import { Key, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { NO_DATA_PLACEHOLDER } from '../../../constants/constants';
import { FQN_SEPARATOR_CHAR } from '../../../constants/char.constants';
import {
NO_DATA_PLACEHOLDER,
PAGE_SIZE_MEDIUM,
} from '../../../constants/constants';
import { TABLE_COLUMNS_KEYS } from '../../../constants/TableKeys.constants';
import { EntityType } from '../../../enums/entity.enum';
import { EntityType, FqnPart } from '../../../enums/entity.enum';
import { DataContract } from '../../../generated/entity/data/dataContract';
import { Column } from '../../../generated/entity/data/table';
import { TagSource } from '../../../generated/tests/testCase';
import { TagLabel } from '../../../generated/type/tagLabel';
import { usePaging } from '../../../hooks/paging/usePaging';
import { useFqn } from '../../../hooks/useFqn';
import { getTableColumnsByFQN } from '../../../rest/tableAPI';
import { getPartialNameFromTableFQN } from '../../../utils/CommonUtils';
import {
getEntityName,
highlightSearchArrayElement,
} from '../../../utils/EntityUtils';
import { pruneEmptyChildren } from '../../../utils/TableUtils';
import { PagingHandlerParams } from '../../common/NextPrevious/NextPrevious.interface';
import Table from '../../common/Table/Table';
import { TableCellRendered } from '../../Database/SchemaTable/SchemaTable.interface';
import TableTags from '../../Database/TableTags/TableTags.component';
@ -44,29 +51,103 @@ export const ContractSchemaFormTab: React.FC<{
}> = ({ selectedSchema, onNext, onChange, onPrev, nextLabel, prevLabel }) => {
const { t } = useTranslation();
const { fqn } = useFqn();
const [schema, setSchema] = useState<Column[]>([]);
const [allColumns, setAllColumns] = useState<Column[]>([]);
const [selectedKeys, setSelectedKeys] = useState<string[]>(selectedSchema);
const [isLoading, setIsLoading] = useState(false);
const tableFqn = useMemo(
() =>
getPartialNameFromTableFQN(
fqn,
[FqnPart.Service, FqnPart.Database, FqnPart.Schema, FqnPart.Table],
FQN_SEPARATOR_CHAR
),
[fqn]
);
const {
currentPage,
pageSize,
paging,
handlePageChange,
handlePageSizeChange,
handlePagingChange,
showPagination,
} = usePaging(PAGE_SIZE_MEDIUM);
const handleChangeTable = useCallback(
(selectedRowKeys: Key[]) => {
setSelectedKeys(selectedRowKeys as string[]);
onChange({
schema: schema.filter((column) =>
schema: allColumns.filter((column) =>
selectedRowKeys.includes(column.name)
),
});
},
[schema, onChange]
[allColumns, onChange]
);
const fetchTableColumns = useCallback(async () => {
const response = await getTableColumnsByFQN(fqn);
setSchema(pruneEmptyChildren(response.data));
}, [fqn]);
const fetchTableColumns = useCallback(
async (page = 1) => {
if (!tableFqn) {
return;
}
useEffect(() => {
fetchTableColumns();
}, [fqn]);
setIsLoading(true);
try {
const offset = (page - 1) * pageSize;
const response = await getTableColumnsByFQN(tableFqn, {
limit: pageSize,
offset: offset,
fields: 'tags',
});
const prunedColumns = pruneEmptyChildren(response.data);
setAllColumns(prunedColumns);
handlePagingChange(response.paging);
} catch {
// Set empty state if API fails
setAllColumns([]);
handlePagingChange({
offset: 1,
limit: pageSize,
total: 0,
});
}
setIsLoading(false);
},
[tableFqn, pageSize]
);
const handleColumnsPageChange = useCallback(
({ currentPage }: PagingHandlerParams) => {
fetchTableColumns(currentPage);
handlePageChange(currentPage);
},
[fetchTableColumns]
);
const paginationProps = useMemo(
() => ({
currentPage,
showPagination,
isLoading: isLoading,
isNumberBased: false,
pageSize,
paging,
pagingHandler: handleColumnsPageChange,
onShowSizeChange: handlePageSizeChange,
}),
[
currentPage,
showPagination,
isLoading,
pageSize,
paging,
handlePageSizeChange,
handleColumnsPageChange,
]
);
const renderDataTypeDisplay: TableCellRendered<Column, 'dataTypeDisplay'> = (
dataTypeDisplay,
@ -81,11 +162,29 @@ export const ContractSchemaFormTab: React.FC<{
}
return (
<Typography.Paragraph
className="cursor-pointer"
ellipsis={{ tooltip: displayValue, rows: 3 }}>
<Tag
className="cursor-pointer custom-tag"
color="purple"
title={displayValue}>
{highlightSearchArrayElement(dataTypeDisplay, '')}
</Typography.Paragraph>
</Tag>
);
};
const renderConstraint: TableCellRendered<Column, 'constraint'> = (
constraint
) => {
if (isEmpty(constraint)) {
return NO_DATA_PLACEHOLDER;
}
return (
<Tag
className="cursor-pointer custom-tag"
color="blue"
title={constraint}>
{constraint}
</Tag>
);
};
@ -114,7 +213,7 @@ export const ContractSchemaFormTab: React.FC<{
render: (tags: TagLabel[], record: Column, index: number) => (
<TableTags<Column>
isReadOnly
entityFqn={fqn}
entityFqn={tableFqn}
entityType={EntityType.TABLE}
handleTagSelection={() => Promise.resolve()}
hasTagEditAccess={false}
@ -132,7 +231,7 @@ export const ContractSchemaFormTab: React.FC<{
render: (tags: TagLabel[], record: Column, index: number) => (
<TableTags<Column>
isReadOnly
entityFqn={fqn}
entityFqn={tableFqn}
entityType={EntityType.TABLE}
handleTagSelection={() => Promise.resolve()}
hasTagEditAccess={false}
@ -146,11 +245,17 @@ export const ContractSchemaFormTab: React.FC<{
{
title: t('label.constraint-plural'),
dataIndex: 'constraint',
key: 'constraint',
render: renderConstraint,
},
],
[t]
[]
);
useEffect(() => {
fetchTableColumns();
}, [fetchTableColumns]);
return (
<>
<Card className="container bg-grey p-box">
@ -164,7 +269,9 @@ export const ContractSchemaFormTab: React.FC<{
</div>
<Table
columns={columns}
dataSource={schema}
customPaginationProps={paginationProps}
dataSource={allColumns}
loading={isLoading}
pagination={false}
rowKey="name"
rowSelection={{

View File

@ -165,6 +165,11 @@ jest.mock('../../../../rest/testAPI', () => ({
getListTestCase: jest.fn().mockResolvedValue({ data: [] }),
createTestCase: jest.fn().mockResolvedValue(MOCK_TEST_CASE[0]),
getTestCaseByFqn: jest.fn().mockResolvedValue(MOCK_TEST_CASE[0]),
TestCaseType: {
all: 'all',
table: 'table',
column: 'column',
},
}));
jest.mock('../../../../rest/ingestionPipelineAPI', () => ({

View File

@ -121,6 +121,11 @@ jest.mock('../../../hooks/useApplicationStore', () => ({
jest.mock('../../../rest/testAPI', () => ({
createTestSuites: jest.fn().mockResolvedValue(mockTestSuite),
addTestCaseToLogicalTestSuite: jest.fn().mockResolvedValue({}),
TestCaseType: {
all: 'all',
table: 'table',
column: 'column',
},
}));
jest.mock('../../../rest/ingestionPipelineAPI', () => ({

View File

@ -127,6 +127,11 @@ jest.mock('../../../../rest/testAPI', () => ({
updateTestCaseById: jest
.fn()
.mockImplementation(() => mockUpdateTestCaseById()),
TestCaseType: {
all: 'all',
table: 'table',
column: 'column',
},
}));
// Mock TagsContainerV2 to capture props

View File

@ -40,6 +40,11 @@ jest.mock('../../../../rest/testAPI', () => ({
getListTestCaseBySearch: jest
.fn()
.mockResolvedValue({ data: [], paging: {} }),
TestCaseType: {
all: 'all',
table: 'table',
column: 'column',
},
}));
jest.mock('../../../../utils/ToastUtils', () => ({
showErrorToast: jest.fn(),

View File

@ -50,6 +50,11 @@ jest.mock('../../../../context/LineageProvider/LineageProvider', () => ({
jest.mock('../../../../rest/testAPI', () => ({
getTestCaseExecutionSummary: jest.fn(),
TestCaseType: {
all: 'all',
table: 'table',
column: 'column',
},
}));
jest.mock('../../../../utils/EntityLink', () => ({

View File

@ -41,6 +41,11 @@ jest.mock('../../../../rest/tableAPI', () => ({
}));
jest.mock('../../../../rest/testAPI', () => ({
getTestCaseExecutionSummary: jest.fn(),
TestCaseType: {
all: 'all',
table: 'table',
column: 'column',
},
}));
jest.mock('../SummaryList/SummaryList.component', () =>

View File

@ -21,7 +21,7 @@ import {
SelectionCardProps,
} from './SelectionCardGroup.interface';
const SelectionCard: FC<SelectionCardProps> = ({
export const SelectionCard: FC<SelectionCardProps> = ({
option,
isSelected,
onClick,

View File

@ -96,6 +96,11 @@ jest.mock('../../../rest/testAPI', () => ({
.fn()
.mockImplementation(() => Promise.resolve({ data: mockTestCaseData })),
updateTestCaseById: jest.fn(),
TestCaseType: {
all: 'all',
table: 'table',
column: 'column',
},
}));
jest.mock('../../../hooks/useCustomLocation/useCustomLocation', () => {

View File

@ -112,6 +112,11 @@ jest.mock('../../rest/testAPI', () => {
ListTestCaseParamsBySearch: jest
.fn()
.mockImplementation(() => Promise.resolve({ data: [] })),
TestCaseType: {
all: 'all',
table: 'table',
column: 'column',
},
};
});
jest.mock('../../context/PermissionProvider/PermissionProvider', () => ({

View File

@ -51,6 +51,11 @@ jest.mock('../../rest/testAPI', () => {
getTestSuiteByName: jest
.fn()
.mockImplementation(() => Promise.resolve(mockTestSuite)),
TestCaseType: {
all: 'all',
table: 'table',
column: 'column',
},
};
});
jest.mock('../../rest/ingestionPipelineAPI', () => {

View File

@ -10,15 +10,19 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { t } from 'i18next';
import { isArray, isNil, isUndefined, omit, omitBy } from 'lodash';
import { ReactComponent as AccuracyIcon } from '../../assets/svg/ic-accuracy.svg';
import { ReactComponent as ColumnIcon } from '../../assets/svg/ic-column.svg';
import { ReactComponent as CompletenessIcon } from '../../assets/svg/ic-completeness.svg';
import { ReactComponent as ConsistencyIcon } from '../../assets/svg/ic-consistency.svg';
import { ReactComponent as IntegrityIcon } from '../../assets/svg/ic-integrity.svg';
import { ReactComponent as SqlIcon } from '../../assets/svg/ic-sql.svg';
import { ReactComponent as TableIcon } from '../../assets/svg/ic-table-test.svg';
import { ReactComponent as UniquenessIcon } from '../../assets/svg/ic-uniqueness.svg';
import { ReactComponent as ValidityIcon } from '../../assets/svg/ic-validity.svg';
import { ReactComponent as NoDimensionIcon } from '../../assets/svg/no-dimension-icon.svg';
import { SelectionOption } from '../../components/common/SelectionCardGroup/SelectionCardGroup.interface';
import { TestCaseSearchParams } from '../../components/DataQuality/DataQuality.interface';
import { TEST_CASE_FILTERS } from '../../constants/profiler.constant';
import { Table } from '../../generated/entity/data/table';
@ -322,3 +326,22 @@ export const convertSearchSourceToTable = (
...searchSource,
columns: searchSource.columns || [],
} as Table);
export const TEST_LEVEL_OPTIONS: SelectionOption[] = [
{
value: TestCaseType.table,
label: t('label.table-level'),
description: t('label.test-applied-on-entity', {
entity: t('label.table-lowercase'),
}),
icon: <TableIcon />,
},
{
value: TestCaseType.column,
label: t('label.column-level'),
description: t('label.test-applied-on-entity', {
entity: t('label.column-lowercase'),
}),
icon: <ColumnIcon />,
},
];