Feat: Add cardinality data distribution chart (#23244)

* Feat: Add cardinality data distribution chart

* refactor: Simplify cardinality label in multiple languages
This commit is contained in:
Shailesh Parmar 2025-09-10 12:49:26 +05:30 committed by GitHub
parent e66824cd45
commit 5c1b76a2c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1365 additions and 21 deletions

View File

@ -0,0 +1,652 @@
/*
* Copyright 2023 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 { render, screen, waitFor } from '@testing-library/react';
import { AxiosError } from 'axios';
import { DateRangeObject } from 'Models';
import { OperationPermission } from '../../../../context/PermissionProvider/PermissionProvider.interface';
import { ColumnProfile } from '../../../../generated/entity/data/container';
import { Table } from '../../../../generated/entity/data/table';
import { Operation } from '../../../../generated/entity/policies/accessControl/resourcePermission';
import { getColumnProfilerList } from '../../../../rest/tableAPI';
import { showErrorToast } from '../../../../utils/ToastUtils';
import SingleColumnProfile from './SingleColumnProfile';
import { useTableProfiler } from './TableProfilerProvider';
jest.mock('../../../../rest/tableAPI', () => ({
getColumnProfilerList: jest.fn(),
}));
jest.mock('../../../../utils/ToastUtils', () => ({
showErrorToast: jest.fn(),
}));
jest.mock('./TableProfilerProvider', () => ({
useTableProfiler: jest.fn(),
}));
jest.mock('../../../../utils/TableProfilerUtils', () => ({
calculateColumnProfilerMetrics: jest.fn().mockReturnValue({
countMetrics: { data: [] },
proportionMetrics: { data: [] },
mathMetrics: { data: [] },
sumMetrics: { data: [] },
quartileMetrics: { data: [] },
}),
calculateCustomMetrics: jest.fn().mockReturnValue({}),
getColumnCustomMetric: jest.fn().mockReturnValue([]),
}));
jest.mock('../../../../utils/DocumentationLinksClassBase', () => ({
getDocsURLS: () => ({
DATA_QUALITY_PROFILER_WORKFLOW_DOCS: 'https://docs.example.com/profiler',
}),
}));
jest.mock('../ProfilerDetailsCard/ProfilerDetailsCard', () => {
return function MockProfilerDetailsCard(props: Record<string, unknown>) {
return (
<>
<div data-testid={`profiler-details-card-${props.name as string}`}>
{props.title as string}
</div>
{props.isLoading && <div data-testid="loading">Loading...</div>}
{!props.isLoading &&
(props.chartCollection as { data?: unknown[] })?.data?.length ===
0 && (
<div data-testid="no-data">
{props.noDataPlaceholderText as string}
</div>
)}
</>
);
};
});
jest.mock(
'../../../Visualisations/Chart/DataDistributionHistogram.component',
() => {
return function MockDataDistributionHistogram(
props: Record<string, unknown>
) {
return (
<div data-testid="data-distribution-histogram">
{(props.data as { firstDayData?: unknown; currentDayData?: unknown })
?.firstDayData ||
(props.data as { firstDayData?: unknown; currentDayData?: unknown })
?.currentDayData ? (
<div>Histogram Data</div>
) : (
<div>{props.noDataPlaceholderText as string}</div>
)}
</div>
);
};
}
);
jest.mock(
'../../../Visualisations/Chart/CardinalityDistributionChart.component',
() => {
return function MockCardinalityDistributionChart(
props: Record<string, unknown>
) {
return (
<div data-testid="cardinality-distribution-chart">
{(props.data as { firstDayData?: unknown; currentDayData?: unknown })
?.firstDayData ||
(props.data as { firstDayData?: unknown; currentDayData?: unknown })
?.currentDayData ? (
<div>Cardinality Data</div>
) : (
<div>{props.noDataPlaceholderText as string}</div>
)}
</div>
);
};
}
);
jest.mock('./CustomMetricGraphs/CustomMetricGraphs.component', () => {
return function MockCustomMetricGraphs(props: Record<string, unknown>) {
return (
<>
<div data-testid="custom-metric-graphs">Custom Metrics Component</div>
{props.isLoading && <div data-testid="custom-loading">Loading...</div>}
<div>
{(props.customMetrics as unknown[])?.length || 0} custom metrics
</div>
</>
);
};
});
const mockColumnProfilerData: ColumnProfile[] = [
{
name: 'test_column',
timestamp: 1704067200000,
valuesCount: 1000,
nullCount: 10,
min: 1,
max: 100,
mean: 50.5,
histogram: {
boundaries: [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100],
frequencies: [100, 90, 80, 70, 60, 50, 40, 30, 20, 10],
},
cardinalityDistribution: {
categories: ['low', 'medium', 'high'],
counts: [300, 400, 300],
},
},
{
name: 'test_column',
timestamp: 1703980800000,
valuesCount: 950,
nullCount: 15,
min: 0,
max: 95,
mean: 47.5,
histogram: {
boundaries: [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100],
frequencies: [95, 85, 75, 65, 55, 45, 35, 25, 15, 5],
},
},
];
const mockStringColumnProfilerData: ColumnProfile[] = [
{
name: 'string_column',
timestamp: 1704067200000,
valuesCount: 500,
nullCount: 5,
min: 'apple',
max: 'zebra',
distinctCount: 450,
},
];
const mockDateRangeObject: DateRangeObject = {
startTs: 1703980800000,
endTs: 1704067200000,
key: 'last_7_days',
};
const mockTableDetails: Table = {
id: 'table-id',
name: 'test_table',
fullyQualifiedName: 'db.schema.test_table',
columns: [],
customMetrics: [
{
id: 'metric-1',
name: 'custom_metric_1',
expression: 'SELECT COUNT(*) FROM test_table',
updatedAt: 1704067200000,
updatedBy: 'admin',
},
],
} as Table;
const mockPermissions: OperationPermission = Object.values(Operation).reduce(
(acc, operation) => ({ ...acc, [operation]: true }),
{} as OperationPermission
);
const defaultTableProfilerContext = {
permissions: mockPermissions,
isTestsLoading: false,
isProfilerDataLoading: false,
customMetric: undefined,
allTestCases: [],
overallSummary: [],
onTestCaseUpdate: jest.fn(),
onSettingButtonClick: jest.fn(),
fetchAllTests: jest.fn(),
onCustomMetricUpdate: jest.fn(),
isProfilingEnabled: true,
dateRangeObject: { startTs: 0, endTs: 0, key: '' },
onDateRangeChange: jest.fn(),
testCasePaging: {
currentPage: 1,
paging: { total: 0 },
pageSize: 10,
showPagination: false,
handlePageChange: jest.fn(),
handlePagingChange: jest.fn(),
handlePageSizeChange: jest.fn(),
pagingCursor: {
cursorType: undefined,
cursorValue: undefined,
currentPage: '1',
pageSize: 10,
},
},
isTestCaseDrawerOpen: false,
onTestCaseDrawerOpen: jest.fn(),
};
const mockGetColumnProfilerList = getColumnProfilerList as jest.MockedFunction<
typeof getColumnProfilerList
>;
const mockUseTableProfiler = useTableProfiler as jest.MockedFunction<
typeof useTableProfiler
>;
const mockShowErrorToast = showErrorToast as jest.MockedFunction<
typeof showErrorToast
>;
describe('SingleColumnProfile', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseTableProfiler.mockReturnValue(defaultTableProfilerContext);
mockGetColumnProfilerList.mockResolvedValue({
data: mockColumnProfilerData,
paging: { total: mockColumnProfilerData.length },
});
});
const defaultProps = {
activeColumnFqn: 'db.schema.test_table.test_column',
dateRangeObject: mockDateRangeObject,
tableDetails: mockTableDetails,
};
describe('Rendering', () => {
it('should render all profiler detail cards', async () => {
render(<SingleColumnProfile {...defaultProps} />);
await waitFor(() => {
expect(
screen.getByTestId('profiler-tab-container')
).toBeInTheDocument();
});
expect(
screen.getByTestId('profiler-details-card-count')
).toBeInTheDocument();
expect(
screen.getByTestId('profiler-details-card-proportion')
).toBeInTheDocument();
expect(
screen.getByTestId('profiler-details-card-math')
).toBeInTheDocument();
expect(
screen.getByTestId('profiler-details-card-sum')
).toBeInTheDocument();
expect(
screen.getByTestId('profiler-details-card-quartile')
).toBeInTheDocument();
expect(screen.getByTestId('custom-metric-graphs')).toBeInTheDocument();
});
it('should render histogram section when histogram data is available', async () => {
render(<SingleColumnProfile {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId('histogram-metrics')).toBeInTheDocument();
});
expect(screen.getByTestId('data-distribution-title')).toBeInTheDocument();
expect(
screen.getByTestId('data-distribution-histogram')
).toBeInTheDocument();
});
it('should render cardinality distribution section when cardinality data is available', async () => {
render(<SingleColumnProfile {...defaultProps} />);
await waitFor(() => {
expect(
screen.getByTestId('cardinality-distribution-metrics')
).toBeInTheDocument();
});
expect(
screen.getByTestId('cardinality-distribution-title')
).toBeInTheDocument();
expect(
screen.getByTestId('cardinality-distribution-chart')
).toBeInTheDocument();
});
it('should not render histogram section when no histogram data', async () => {
mockGetColumnProfilerList.mockResolvedValue({
data: [{ ...mockColumnProfilerData[0], histogram: undefined }],
paging: { total: 1 },
});
render(<SingleColumnProfile {...defaultProps} />);
await waitFor(() => {
expect(
screen.getByTestId('profiler-tab-container')
).toBeInTheDocument();
});
expect(screen.queryByTestId('histogram-metrics')).not.toBeInTheDocument();
});
it('should not render cardinality section when no cardinality data', async () => {
mockGetColumnProfilerList.mockResolvedValue({
data: [
{ ...mockColumnProfilerData[0], cardinalityDistribution: undefined },
],
paging: { total: 1 },
});
render(<SingleColumnProfile {...defaultProps} />);
await waitFor(() => {
expect(
screen.getByTestId('profiler-tab-container')
).toBeInTheDocument();
});
expect(
screen.queryByTestId('cardinality-distribution-metrics')
).not.toBeInTheDocument();
});
});
describe('Loading States', () => {
it('should show loading state initially', () => {
render(<SingleColumnProfile {...defaultProps} />);
expect(screen.getAllByTestId('loading')).toHaveLength(5);
});
it('should show loading state from TableProfiler context', async () => {
mockUseTableProfiler.mockReturnValue({
...defaultTableProfilerContext,
isProfilerDataLoading: true,
});
render(<SingleColumnProfile {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId('custom-loading')).toBeInTheDocument();
});
});
});
describe('Data Fetching', () => {
it('should fetch column profiler data with correct parameters', async () => {
render(<SingleColumnProfile {...defaultProps} />);
await waitFor(() => {
expect(mockGetColumnProfilerList).toHaveBeenCalledWith(
'db.schema.test_table.test_column',
{ startTs: 1703980800000, endTs: 1704067200000 }
);
});
});
it('should fetch data with default range when dateRangeObject is not provided', async () => {
render(
<SingleColumnProfile
{...defaultProps}
dateRangeObject={undefined as unknown as DateRangeObject}
/>
);
await waitFor(() => {
expect(mockGetColumnProfilerList).toHaveBeenCalledWith(
'db.schema.test_table.test_column',
expect.objectContaining({
startTs: expect.any(Number),
endTs: expect.any(Number),
})
);
});
});
it('should not fetch data when activeColumnFqn is empty', async () => {
render(<SingleColumnProfile {...defaultProps} activeColumnFqn="" />);
expect(mockGetColumnProfilerList).not.toHaveBeenCalled();
});
it('should refetch data when activeColumnFqn changes', async () => {
const { rerender } = render(<SingleColumnProfile {...defaultProps} />);
await waitFor(() => {
expect(mockGetColumnProfilerList).toHaveBeenCalledTimes(1);
});
rerender(
<SingleColumnProfile
{...defaultProps}
activeColumnFqn="db.schema.test_table.new_column"
/>
);
await waitFor(() => {
expect(mockGetColumnProfilerList).toHaveBeenCalledTimes(2);
expect(mockGetColumnProfilerList).toHaveBeenLastCalledWith(
'db.schema.test_table.new_column',
{ startTs: 1703980800000, endTs: 1704067200000 }
);
});
});
it('should refetch data when dateRangeObject changes', async () => {
const { rerender } = render(<SingleColumnProfile {...defaultProps} />);
await waitFor(() => {
expect(mockGetColumnProfilerList).toHaveBeenCalledTimes(1);
});
const newDateRange = {
startTs: 1703894400000,
endTs: 1703980800000,
key: 'last_1_day',
};
rerender(
<SingleColumnProfile {...defaultProps} dateRangeObject={newDateRange} />
);
await waitFor(() => {
expect(mockGetColumnProfilerList).toHaveBeenCalledTimes(2);
expect(mockGetColumnProfilerList).toHaveBeenLastCalledWith(
'db.schema.test_table.test_column',
{ startTs: 1703894400000, endTs: 1703980800000 }
);
});
});
});
describe('Error Handling', () => {
it('should show error toast when API call fails', async () => {
const error = new AxiosError('API Error');
mockGetColumnProfilerList.mockRejectedValue(error);
render(<SingleColumnProfile {...defaultProps} />);
await waitFor(() => {
expect(mockShowErrorToast).toHaveBeenCalledWith(error);
});
});
it('should set loading to false after error', async () => {
const error = new AxiosError('API Error');
mockGetColumnProfilerList.mockRejectedValue(error);
render(<SingleColumnProfile {...defaultProps} />);
await waitFor(() => {
expect(mockShowErrorToast).toHaveBeenCalled();
});
await waitFor(() => {
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
});
});
});
describe('String Data Handling', () => {
it('should handle string min/max values correctly', async () => {
mockGetColumnProfilerList.mockResolvedValue({
data: mockStringColumnProfilerData,
paging: { total: mockStringColumnProfilerData.length },
});
render(<SingleColumnProfile {...defaultProps} />);
await waitFor(() => {
expect(
screen.getByTestId('profiler-tab-container')
).toBeInTheDocument();
});
});
});
describe('No Data States', () => {
it('should show appropriate message when profiling is enabled but no data', async () => {
mockGetColumnProfilerList.mockResolvedValue({
data: [],
paging: { total: 0 },
});
render(<SingleColumnProfile {...defaultProps} />);
await waitFor(() => {
expect(
screen.getByTestId('profiler-tab-container')
).toBeInTheDocument();
});
});
it('should show documentation link when profiling is disabled', async () => {
mockUseTableProfiler.mockReturnValue({
...defaultTableProfilerContext,
isProfilingEnabled: false,
});
mockGetColumnProfilerList.mockResolvedValue({
data: [],
paging: { total: 0 },
});
render(<SingleColumnProfile {...defaultProps} />);
await waitFor(() => {
expect(
screen.getByTestId('profiler-tab-container')
).toBeInTheDocument();
});
});
});
describe('Custom Metrics Integration', () => {
it('should use custom metrics from tableDetails when provided', async () => {
render(<SingleColumnProfile {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId('custom-metric-graphs')).toBeInTheDocument();
});
expect(screen.getByTestId('custom-metric-graphs')).toBeInTheDocument();
});
it('should fallback to custom metrics from context when tableDetails not provided', async () => {
mockUseTableProfiler.mockReturnValue({
...defaultTableProfilerContext,
customMetric: mockTableDetails,
});
render(
<SingleColumnProfile {...defaultProps} tableDetails={undefined} />
);
await waitFor(() => {
expect(screen.getByTestId('custom-metric-graphs')).toBeInTheDocument();
});
});
it('should handle empty custom metrics array', async () => {
render(
<SingleColumnProfile
{...defaultProps}
tableDetails={{ ...mockTableDetails, customMetrics: undefined }}
/>
);
await waitFor(() => {
expect(screen.getByTestId('custom-metric-graphs')).toBeInTheDocument();
});
expect(screen.getByTestId('custom-metric-graphs')).toBeInTheDocument();
});
});
describe('Data Processing', () => {
it('should process first and last day data correctly', async () => {
render(<SingleColumnProfile {...defaultProps} />);
await waitFor(() => {
expect(
screen.getByTestId('data-distribution-histogram')
).toBeInTheDocument();
});
expect(screen.getByText('Histogram Data')).toBeInTheDocument();
});
it('should handle single data point correctly', async () => {
mockGetColumnProfilerList.mockResolvedValue({
data: [mockColumnProfilerData[0]],
paging: { total: 1 },
});
render(<SingleColumnProfile {...defaultProps} />);
await waitFor(() => {
expect(
screen.getByTestId('profiler-tab-container')
).toBeInTheDocument();
});
});
});
describe('Component Updates', () => {
it('should update metrics when column profiler data changes', async () => {
const { rerender } = render(<SingleColumnProfile {...defaultProps} />);
await waitFor(() => {
expect(
screen.getByTestId('profiler-tab-container')
).toBeInTheDocument();
});
mockGetColumnProfilerList.mockResolvedValue({
data: [mockStringColumnProfilerData[0]],
paging: { total: 1 },
});
rerender(
<SingleColumnProfile
{...defaultProps}
activeColumnFqn="db.schema.test_table.string_column"
/>
);
await waitFor(() => {
expect(mockGetColumnProfilerList).toHaveBeenCalledWith(
'db.schema.test_table.string_column',
{ startTs: 1703980800000, endTs: 1704067200000 }
);
});
});
});
});

View File

@ -32,6 +32,7 @@ import {
} from '../../../../utils/TableProfilerUtils';
import { ColumnMetricsInterface } from '../../../../utils/TableProfilerUtils.interface';
import { showErrorToast } from '../../../../utils/ToastUtils';
import CardinalityDistributionChart from '../../../Visualisations/Chart/CardinalityDistributionChart.component';
import DataDistributionHistogram from '../../../Visualisations/Chart/DataDistributionHistogram.component';
import ProfilerDetailsCard from '../ProfilerDetailsCard/ProfilerDetailsCard';
import CustomMetricGraphs from './CustomMetricGraphs/CustomMetricGraphs.component';
@ -202,26 +203,55 @@ const SingleColumnProfile: FC<SingleColumnProfileProps> = ({
title={t('label.data-quartile-plural')}
/>
</Col>
<Col span={24}>
<Card
className="shadow-none global-border-radius"
data-testid="histogram-metrics"
loading={isLoading}>
<Row gutter={[16, 16]}>
<Col span={24}>
<Typography.Title data-testid="data-distribution-title" level={5}>
{t('label.data-distribution')}
</Typography.Title>
</Col>
<Col span={24}>
<DataDistributionHistogram
data={{ firstDayData: firstDay, currentDayData: currentDay }}
noDataPlaceholderText={noProfilerMessage}
/>
</Col>
</Row>
</Card>
</Col>
{firstDay?.histogram || currentDay?.histogram ? (
<Col span={24}>
<Card
className="shadow-none global-border-radius"
data-testid="histogram-metrics"
loading={isLoading}>
<Row gutter={[16, 16]}>
<Col span={24}>
<Typography.Title
data-testid="data-distribution-title"
level={5}>
{t('label.data-distribution')}
</Typography.Title>
</Col>
<Col span={24}>
<DataDistributionHistogram
data={{ firstDayData: firstDay, currentDayData: currentDay }}
noDataPlaceholderText={noProfilerMessage}
/>
</Col>
</Row>
</Card>
</Col>
) : null}
{firstDay?.cardinalityDistribution ||
currentDay?.cardinalityDistribution ? (
<Col span={24}>
<Card
className="shadow-none global-border-radius"
data-testid="cardinality-distribution-metrics"
loading={isLoading}>
<Row gutter={[16, 16]}>
<Col span={24}>
<Typography.Title
data-testid="cardinality-distribution-title"
level={5}>
{t('label.cardinality')}
</Typography.Title>
</Col>
<Col span={24}>
<CardinalityDistributionChart
data={{ firstDayData: firstDay, currentDayData: currentDay }}
noDataPlaceholderText={noProfilerMessage}
/>
</Col>
</Row>
</Card>
</Col>
) : null}
<Col span={24}>
<CustomMetricGraphs
customMetrics={customMetrics}

View File

@ -0,0 +1,170 @@
/*
* Copyright 2023 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 { Card, Col, Row, Tag } from 'antd';
import { isUndefined, map } from 'lodash';
import { useTranslation } from 'react-i18next';
import {
Bar,
BarChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
TooltipProps,
XAxis,
YAxis,
} from 'recharts';
import { CHART_BLUE_1 } from '../../../constants/Color.constants';
import { GRAPH_BACKGROUND_COLOR } from '../../../constants/constants';
import { ColumnProfile } from '../../../generated/entity/data/table';
import { axisTickFormatter, tooltipFormatter } from '../../../utils/ChartUtils';
import { customFormatDateTime } from '../../../utils/date-time/DateTimeUtils';
import ErrorPlaceHolder from '../../common/ErrorWithPlaceholder/ErrorPlaceHolder';
export interface CardinalityDistributionChartProps {
data: {
firstDayData?: ColumnProfile;
currentDayData?: ColumnProfile;
};
noDataPlaceholderText?: string | React.ReactNode;
}
const CardinalityDistributionChart = ({
data,
noDataPlaceholderText,
}: CardinalityDistributionChartProps) => {
const { t } = useTranslation();
const showSingleGraph =
isUndefined(data.firstDayData?.cardinalityDistribution) ||
isUndefined(data.currentDayData?.cardinalityDistribution);
if (
isUndefined(data.firstDayData?.cardinalityDistribution) &&
isUndefined(data.currentDayData?.cardinalityDistribution)
) {
return (
<Row align="middle" className="h-full w-full" justify="center">
<Col>
<ErrorPlaceHolder placeholderText={noDataPlaceholderText} />
</Col>
</Row>
);
}
const renderTooltip: TooltipProps<string | number, string>['content'] = (
props
) => {
const { active, payload } = props;
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<Card>
<p className="font-semibold text-sm mb-1">{`${t('label.category')}: ${
data.name
}`}</p>
<p className="text-sm mb-1">{`${t('label.count')}: ${tooltipFormatter(
data.count
)}`}</p>
<p className="text-sm">{`${t('label.percentage')}: ${
data.percentage
}%`}</p>
</Card>
);
}
return null;
};
return (
<Row className="w-full" data-testid="chart-container">
{map(data, (columnProfile, key) => {
if (
isUndefined(columnProfile) ||
isUndefined(columnProfile?.cardinalityDistribution)
) {
return;
}
const cardinalityData = columnProfile.cardinalityDistribution;
const graphData =
cardinalityData.categories?.map((category, i) => ({
name: category,
count: cardinalityData.counts?.[i] || 0,
percentage: cardinalityData.percentages?.[i] || 0,
})) || [];
const graphDate = customFormatDateTime(
columnProfile?.timestamp || 0,
'MMM dd'
);
return (
<Col key={key} span={showSingleGraph ? 24 : 12}>
<Row gutter={[8, 8]}>
<Col
data-testid="date"
offset={showSingleGraph ? 1 : 2}
span={24}>
{graphDate}
</Col>
<Col offset={showSingleGraph ? 1 : 2} span={24}>
<Tag data-testid="cardinality-tag">{`${t('label.total-entity', {
entity: t('label.category-plural'),
})}: ${cardinalityData.categories?.length || 0}`}</Tag>
</Col>
<Col span={24}>
<ResponsiveContainer
debounce={200}
id={`${key}-cardinality`}
minHeight={300}>
<BarChart
className="w-full"
data={graphData}
layout="vertical"
margin={{ left: 16 }}>
<CartesianGrid stroke={GRAPH_BACKGROUND_COLOR} />
<XAxis
padding={{ left: 16, right: 16 }}
tick={{ fontSize: 12 }}
tickFormatter={(props) => axisTickFormatter(props, '%')}
type="number"
/>
<YAxis
allowDataOverflow
dataKey="name"
padding={{ top: 16, bottom: 16 }}
tick={{ fontSize: 12 }}
tickFormatter={(value: string) =>
value?.length > 15 ? `${value.slice(0, 15)}...` : value
}
type="category"
width={120}
/>
<Legend />
<Tooltip content={renderTooltip} />
<Bar dataKey="percentage" fill={CHART_BLUE_1} />
</BarChart>
</ResponsiveContainer>
</Col>
</Row>
</Col>
);
})}
</Row>
);
};
export default CardinalityDistributionChart;

View File

@ -0,0 +1,472 @@
/*
* Copyright 2023 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 { queryByAttribute, render, screen } from '@testing-library/react';
import { ColumnProfile } from '../../../generated/entity/data/table';
import CardinalityDistributionChart, {
CardinalityDistributionChartProps,
} from './CardinalityDistributionChart.component';
// Use existing recharts mock
import '../../../test/unit/mocks/recharts.mock';
// Mock utility functions
jest.mock('../../../utils/ChartUtils', () => ({
axisTickFormatter: jest.fn(
(value: string, suffix: string) => `${value}${suffix}`
),
tooltipFormatter: jest.fn((value: number) => value.toLocaleString()),
}));
jest.mock('../../../utils/date-time/DateTimeUtils', () => ({
customFormatDateTime: jest.fn((timestamp: number) => {
if (timestamp === 1704067200000) {
return 'Jan 01';
}
if (timestamp === 1703980800000) {
return 'Dec 30';
}
return 'Unknown Date';
}),
}));
jest.mock('../../common/ErrorWithPlaceholder/ErrorPlaceHolder', () => {
return function MockErrorPlaceHolder({
placeholderText,
}: {
placeholderText: string | React.ReactNode;
}) {
return <div data-testid="error-placeholder">{placeholderText}</div>;
};
});
const mockColumnProfileWithCardinality: ColumnProfile = {
name: 'test_column',
timestamp: 1704067200000,
valuesCount: 1000,
nullCount: 10,
cardinalityDistribution: {
categories: ['low', 'medium', 'high', 'very_high'],
counts: [100, 300, 400, 200],
percentages: [10, 30, 40, 20],
},
};
const mockColumnProfileWithoutCardinality: ColumnProfile = {
name: 'test_column',
timestamp: 1704067200000,
valuesCount: 1000,
nullCount: 10,
};
const mockSecondColumnProfile: ColumnProfile = {
name: 'test_column',
timestamp: 1703980800000,
valuesCount: 950,
nullCount: 15,
cardinalityDistribution: {
categories: ['low', 'medium', 'high'],
counts: [150, 400, 400],
percentages: [15.8, 42.1, 42.1],
},
};
describe('CardinalityDistributionChart', () => {
const defaultProps: CardinalityDistributionChartProps = {
data: {
firstDayData: mockColumnProfileWithCardinality,
currentDayData: mockSecondColumnProfile,
},
};
describe('Rendering', () => {
it('should render chart container when data is provided', async () => {
render(<CardinalityDistributionChart {...defaultProps} />);
expect(await screen.findByTestId('chart-container')).toBeInTheDocument();
});
it('should render dual charts when both firstDayData and currentDayData have cardinality data', async () => {
const { container } = render(
<CardinalityDistributionChart {...defaultProps} />
);
expect(await screen.findAllByTestId('date')).toHaveLength(2);
expect(await screen.findAllByTestId('cardinality-tag')).toHaveLength(2);
expect(
queryByAttribute('id', container, 'firstDayData-cardinality')
).toBeInTheDocument();
expect(
queryByAttribute('id', container, 'currentDayData-cardinality')
).toBeInTheDocument();
});
it('should render single chart when only currentDayData has cardinality data', async () => {
const props: CardinalityDistributionChartProps = {
data: {
firstDayData: mockColumnProfileWithoutCardinality,
currentDayData: mockSecondColumnProfile,
},
};
const { container } = render(<CardinalityDistributionChart {...props} />);
expect(await screen.findAllByTestId('date')).toHaveLength(1);
expect(await screen.findAllByTestId('cardinality-tag')).toHaveLength(1);
expect(
queryByAttribute('id', container, 'firstDayData-cardinality')
).not.toBeInTheDocument();
expect(
queryByAttribute('id', container, 'currentDayData-cardinality')
).toBeInTheDocument();
});
it('should render single chart when only firstDayData has cardinality data', async () => {
const props: CardinalityDistributionChartProps = {
data: {
firstDayData: mockColumnProfileWithCardinality,
currentDayData: mockColumnProfileWithoutCardinality,
},
};
const { container } = render(<CardinalityDistributionChart {...props} />);
expect(await screen.findAllByTestId('date')).toHaveLength(1);
expect(await screen.findAllByTestId('cardinality-tag')).toHaveLength(1);
expect(
queryByAttribute('id', container, 'firstDayData-cardinality')
).toBeInTheDocument();
expect(
queryByAttribute('id', container, 'currentDayData-cardinality')
).not.toBeInTheDocument();
});
it('should render error placeholder when no cardinality data is available', async () => {
const props: CardinalityDistributionChartProps = {
data: {
firstDayData: mockColumnProfileWithoutCardinality,
currentDayData: mockColumnProfileWithoutCardinality,
},
noDataPlaceholderText: 'No cardinality data available',
};
render(<CardinalityDistributionChart {...props} />);
expect(
await screen.findByTestId('error-placeholder')
).toBeInTheDocument();
expect(
await screen.findByText('No cardinality data available')
).toBeInTheDocument();
expect(screen.queryByTestId('chart-container')).not.toBeInTheDocument();
});
it('should render error placeholder with default message when no cardinality data and no placeholder text', async () => {
const props: CardinalityDistributionChartProps = {
data: {
firstDayData: mockColumnProfileWithoutCardinality,
currentDayData: mockColumnProfileWithoutCardinality,
},
};
render(<CardinalityDistributionChart {...props} />);
expect(
await screen.findByTestId('error-placeholder')
).toBeInTheDocument();
expect(screen.queryByTestId('chart-container')).not.toBeInTheDocument();
});
});
describe('Data Processing', () => {
it('should render charts when cardinality data is provided', async () => {
const { container } = render(
<CardinalityDistributionChart {...defaultProps} />
);
// Should render chart containers with unique IDs
expect(
queryByAttribute('id', container, 'firstDayData-cardinality')
).toBeInTheDocument();
expect(
queryByAttribute('id', container, 'currentDayData-cardinality')
).toBeInTheDocument();
});
it('should handle missing counts and percentages gracefully', async () => {
const incompleteProfile: ColumnProfile = {
name: 'test_column',
timestamp: 1704067200000,
valuesCount: 1000,
nullCount: 10,
cardinalityDistribution: {
categories: ['low', 'medium', 'high'],
counts: [100], // Missing counts for medium and high
percentages: [10, 30], // Missing percentage for high
},
};
const props: CardinalityDistributionChartProps = {
data: {
currentDayData: incompleteProfile,
},
};
const { container } = render(<CardinalityDistributionChart {...props} />);
// Should still render the chart container
expect(await screen.findByTestId('chart-container')).toBeInTheDocument();
expect(
queryByAttribute('id', container, 'currentDayData-cardinality')
).toBeInTheDocument();
});
it('should handle empty categories array', async () => {
const emptyProfile: ColumnProfile = {
name: 'test_column',
timestamp: 1704067200000,
valuesCount: 1000,
nullCount: 10,
cardinalityDistribution: {
categories: [],
counts: [],
percentages: [],
},
};
const props: CardinalityDistributionChartProps = {
data: {
currentDayData: emptyProfile,
},
};
const { container } = render(<CardinalityDistributionChart {...props} />);
// Should still render the chart container even with empty data
expect(await screen.findByTestId('chart-container')).toBeInTheDocument();
expect(
queryByAttribute('id', container, 'currentDayData-cardinality')
).toBeInTheDocument();
});
});
describe('Date and Tag Display', () => {
it('should display formatted dates correctly', async () => {
render(<CardinalityDistributionChart {...defaultProps} />);
const dateElements = await screen.findAllByTestId('date');
expect(dateElements[0]).toHaveTextContent('Jan 01');
expect(dateElements[1]).toHaveTextContent('Dec 30');
});
it('should display total categories count in tags', async () => {
render(<CardinalityDistributionChart {...defaultProps} />);
const cardinalityTags = await screen.findAllByTestId('cardinality-tag');
expect(cardinalityTags[0]).toHaveTextContent('label.total-entity: 4');
expect(cardinalityTags[1]).toHaveTextContent('label.total-entity: 3');
});
it('should handle zero categories in tag', async () => {
const emptyProfile: ColumnProfile = {
name: 'test_column',
timestamp: 1704067200000,
valuesCount: 1000,
nullCount: 10,
cardinalityDistribution: {
categories: [],
counts: [],
percentages: [],
},
};
const props: CardinalityDistributionChartProps = {
data: {
currentDayData: emptyProfile,
},
};
render(<CardinalityDistributionChart {...props} />);
const cardinalityTag = await screen.findByTestId('cardinality-tag');
expect(cardinalityTag).toHaveTextContent('label.total-entity: 0');
});
});
describe('Layout and Styling', () => {
it('should use correct layout for single graph', async () => {
const props: CardinalityDistributionChartProps = {
data: {
currentDayData: mockColumnProfileWithCardinality,
},
};
const { container } = render(<CardinalityDistributionChart {...props} />);
// Single graph should render one chart
expect(await screen.findAllByTestId('date')).toHaveLength(1);
expect(
queryByAttribute('id', container, 'currentDayData-cardinality')
).toBeInTheDocument();
});
it('should use correct layout for dual graphs', async () => {
const { container } = render(
<CardinalityDistributionChart {...defaultProps} />
);
// Dual graphs should render two charts
expect(await screen.findAllByTestId('date')).toHaveLength(2);
expect(
queryByAttribute('id', container, 'firstDayData-cardinality')
).toBeInTheDocument();
expect(
queryByAttribute('id', container, 'currentDayData-cardinality')
).toBeInTheDocument();
});
});
describe('Edge Cases', () => {
it('should handle undefined data gracefully', async () => {
const props: CardinalityDistributionChartProps = {
data: {},
};
render(<CardinalityDistributionChart {...props} />);
expect(
await screen.findByTestId('error-placeholder')
).toBeInTheDocument();
expect(screen.queryByTestId('chart-container')).not.toBeInTheDocument();
});
it('should handle data with undefined cardinality distribution', async () => {
const props: CardinalityDistributionChartProps = {
data: {
firstDayData: {
...mockColumnProfileWithCardinality,
cardinalityDistribution: undefined,
},
currentDayData: {
...mockSecondColumnProfile,
cardinalityDistribution: undefined,
},
},
};
render(<CardinalityDistributionChart {...props} />);
expect(
await screen.findByTestId('error-placeholder')
).toBeInTheDocument();
expect(screen.queryByTestId('chart-container')).not.toBeInTheDocument();
});
it('should handle missing timestamp gracefully', async () => {
const profileWithoutTimestamp: ColumnProfile = {
...mockColumnProfileWithCardinality,
timestamp: undefined as unknown as number,
};
const props: CardinalityDistributionChartProps = {
data: {
currentDayData: profileWithoutTimestamp,
},
};
render(<CardinalityDistributionChart {...props} />);
const dateElement = await screen.findByTestId('date');
expect(dateElement).toHaveTextContent('Unknown Date');
});
it('should handle React node as noDataPlaceholderText', async () => {
const customPlaceholder = <div>Custom No Data Message</div>;
const props: CardinalityDistributionChartProps = {
data: {
firstDayData: mockColumnProfileWithoutCardinality,
currentDayData: mockColumnProfileWithoutCardinality,
},
noDataPlaceholderText: customPlaceholder,
};
render(<CardinalityDistributionChart {...props} />);
expect(
await screen.findByTestId('error-placeholder')
).toBeInTheDocument();
expect(
await screen.findByText('Custom No Data Message')
).toBeInTheDocument();
});
it('should handle undefined columnProfile data', async () => {
const props: CardinalityDistributionChartProps = {
data: {
firstDayData: undefined,
currentDayData: undefined,
},
};
render(<CardinalityDistributionChart {...props} />);
expect(
await screen.findByTestId('error-placeholder')
).toBeInTheDocument();
expect(screen.queryByTestId('chart-container')).not.toBeInTheDocument();
});
});
describe('Component Integration', () => {
it('should render with both data sources having different category counts', async () => {
const { container } = render(
<CardinalityDistributionChart {...defaultProps} />
);
const cardinalityTags = await screen.findAllByTestId('cardinality-tag');
// First profile has 4 categories, second has 3
expect(cardinalityTags[0]).toHaveTextContent('4');
expect(cardinalityTags[1]).toHaveTextContent('3');
// Both charts should render
expect(
queryByAttribute('id', container, 'firstDayData-cardinality')
).toBeInTheDocument();
expect(
queryByAttribute('id', container, 'currentDayData-cardinality')
).toBeInTheDocument();
});
it('should render without custom placeholder text', async () => {
const props: CardinalityDistributionChartProps = {
data: {
firstDayData: mockColumnProfileWithoutCardinality,
currentDayData: mockColumnProfileWithoutCardinality,
},
};
render(<CardinalityDistributionChart {...props} />);
expect(
await screen.findByTestId('error-placeholder')
).toBeInTheDocument();
});
});
});

View File

@ -24,6 +24,7 @@ import {
XAxis,
YAxis,
} from 'recharts';
import { CHART_BLUE_1 } from '../../../constants/Color.constants';
import { GRAPH_BACKGROUND_COLOR } from '../../../constants/constants';
import { DEFAULT_HISTOGRAM_DATA } from '../../../constants/profiler.constant';
import { HistogramClass } from '../../../generated/entity/data/table';
@ -116,7 +117,7 @@ const DataDistributionHistogram = ({
tooltipFormatter(value)
}
/>
<Bar dataKey="frequency" fill="#1890FF" />
<Bar dataKey="frequency" fill={CHART_BLUE_1} />
</BarChart>
</ResponsiveContainer>
</Col>

View File

@ -29,6 +29,7 @@ export const LIGHT_GRAY = '#F1F4F9';
export const INDIGO_1 = '#3538CD';
export const PRIMARY_COLOR = DEFAULT_THEME.primaryColor;
export const BLUE_2 = '#3ca2f4';
export const CHART_BLUE_1 = '#1890FF';
export const RIPTIDE = '#76E9C6';
export const MY_SIN = '#FEB019';
export const SAN_MARINO = '#416BB3';

View File

@ -194,6 +194,7 @@
"by-lowercase": "von",
"ca-certs": "CA-Zertifikate",
"cancel": "Abbrechen",
"cardinality": "Cardinality",
"category": "Kategorie",
"category-plural": "Kategorien",
"certification": "Zertifizierung",

View File

@ -194,6 +194,7 @@
"by-lowercase": "by",
"ca-certs": "CA Certs",
"cancel": "Cancel",
"cardinality": "Cardinality",
"category": "Category",
"category-plural": "Categories",
"certification": "Certification",

View File

@ -194,6 +194,7 @@
"by-lowercase": "por",
"ca-certs": "Certificados CA",
"cancel": "Cancelar",
"cardinality": "Cardinalidad",
"category": "Categoría",
"category-plural": "Categorías",
"certification": "Certificación",

View File

@ -194,6 +194,7 @@
"by-lowercase": "par",
"ca-certs": "Certificats CA",
"cancel": "Annuler",
"cardinality": "Cardinality",
"category": "Category",
"category-plural": "Categories",
"certification": "Certification",

View File

@ -194,6 +194,7 @@
"by-lowercase": "por",
"ca-certs": "Certificados CA",
"cancel": "Cancelar",
"cardinality": "Cardinality",
"category": "Categoría",
"category-plural": "Categorías",
"certification": "Certificación",

View File

@ -194,6 +194,7 @@
"by-lowercase": "לפי",
"ca-certs": "תעודות CA",
"cancel": "ביטול",
"cardinality": "Cardinality",
"category": "Category",
"category-plural": "קטגוריות",
"certification": "Certification",

View File

@ -194,6 +194,7 @@
"by-lowercase": "による",
"ca-certs": "CA証明書",
"cancel": "キャンセル",
"cardinality": "Cardinality",
"category": "カテゴリ",
"category-plural": "カテゴリ",
"certification": "認証",

View File

@ -194,6 +194,7 @@
"by-lowercase": "작성자",
"ca-certs": "CA 인증서",
"cancel": "취소",
"cardinality": "Cardinality",
"category": "카테고리",
"category-plural": "카테고리들",
"certification": "인증",

View File

@ -194,6 +194,7 @@
"by-lowercase": "द्वारे",
"ca-certs": "CA प्रमाणपत्रे",
"cancel": "रद्द करा",
"cardinality": "Cardinality",
"category": "श्रेणी",
"category-plural": "श्रेण्या",
"certification": "प्रमाणपत्र",

View File

@ -194,6 +194,7 @@
"by-lowercase": "door",
"ca-certs": "CA-certificaten",
"cancel": "Annuleren",
"cardinality": "Cardinality",
"category": "Categorie",
"category-plural": "Categorieën",
"certification": "Certification",

View File

@ -194,6 +194,7 @@
"by-lowercase": "توسط",
"ca-certs": "گواهی‌های CA",
"cancel": "لغو",
"cardinality": "Cardinality",
"category": "دسته‌بندی",
"category-plural": "دسته‌بندی‌ها",
"certification": "Certification",

View File

@ -194,6 +194,7 @@
"by-lowercase": "por",
"ca-certs": "Certificados CA",
"cancel": "Cancelar",
"cardinality": "Cardinality",
"category": "Categoria",
"category-plural": "Categorias",
"certification": "Certificação",

View File

@ -194,6 +194,7 @@
"by-lowercase": "por",
"ca-certs": "Certificados CA",
"cancel": "Cancelar",
"cardinality": "Cardinality",
"category": "Category",
"category-plural": "Categorias",
"certification": "Certification",

View File

@ -194,6 +194,7 @@
"by-lowercase": "к",
"ca-certs": "Сертификаты CA",
"cancel": "Отмена",
"cardinality": "Cardinality",
"category": "Категория",
"category-plural": "Категории",
"certification": "Сертификация",

View File

@ -194,6 +194,7 @@
"by-lowercase": "โดย",
"ca-certs": "ใบรับรอง CA",
"cancel": "ยกเลิก",
"cardinality": "Cardinality",
"category": "หมวดหมู่",
"category-plural": "หมวดหมู่หลายรายการ",
"certification": "Certification",

View File

@ -194,6 +194,7 @@
"by-lowercase": "tarafından",
"ca-certs": "CA Sertifikaları",
"cancel": "İptal",
"cardinality": "Cardinality",
"category": "Kategori",
"category-plural": "Kategoriler",
"certification": "Sertifikasyon",

View File

@ -194,6 +194,7 @@
"by-lowercase": "by",
"ca-certs": "CA 证书",
"cancel": "取消",
"cardinality": "Cardinality",
"category": "分类",
"category-plural": "分类",
"certification": "Certification",

View File

@ -194,6 +194,7 @@
"by-lowercase": "由",
"ca-certs": "CA 憑證",
"cancel": "取消",
"cardinality": "Cardinality",
"category": "類別",
"category-plural": "類別",
"certification": "認證",