diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/SingleColumnProfile.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/SingleColumnProfile.test.tsx new file mode 100644 index 00000000000..fa182344c73 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/SingleColumnProfile.test.tsx @@ -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) { + return ( + <> +
+ {props.title as string} +
+ {props.isLoading &&
Loading...
} + {!props.isLoading && + (props.chartCollection as { data?: unknown[] })?.data?.length === + 0 && ( +
+ {props.noDataPlaceholderText as string} +
+ )} + + ); + }; +}); + +jest.mock( + '../../../Visualisations/Chart/DataDistributionHistogram.component', + () => { + return function MockDataDistributionHistogram( + props: Record + ) { + return ( +
+ {(props.data as { firstDayData?: unknown; currentDayData?: unknown }) + ?.firstDayData || + (props.data as { firstDayData?: unknown; currentDayData?: unknown }) + ?.currentDayData ? ( +
Histogram Data
+ ) : ( +
{props.noDataPlaceholderText as string}
+ )} +
+ ); + }; + } +); + +jest.mock( + '../../../Visualisations/Chart/CardinalityDistributionChart.component', + () => { + return function MockCardinalityDistributionChart( + props: Record + ) { + return ( +
+ {(props.data as { firstDayData?: unknown; currentDayData?: unknown }) + ?.firstDayData || + (props.data as { firstDayData?: unknown; currentDayData?: unknown }) + ?.currentDayData ? ( +
Cardinality Data
+ ) : ( +
{props.noDataPlaceholderText as string}
+ )} +
+ ); + }; + } +); + +jest.mock('./CustomMetricGraphs/CustomMetricGraphs.component', () => { + return function MockCustomMetricGraphs(props: Record) { + return ( + <> +
Custom Metrics Component
+ {props.isLoading &&
Loading...
} +
+ {(props.customMetrics as unknown[])?.length || 0} custom metrics +
+ + ); + }; +}); + +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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + expect(screen.getAllByTestId('loading')).toHaveLength(5); + }); + + it('should show loading state from TableProfiler context', async () => { + mockUseTableProfiler.mockReturnValue({ + ...defaultTableProfilerContext, + isProfilerDataLoading: true, + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('custom-loading')).toBeInTheDocument(); + }); + }); + }); + + describe('Data Fetching', () => { + it('should fetch column profiler data with correct parameters', async () => { + render(); + + 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( + + ); + + 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(); + + expect(mockGetColumnProfilerList).not.toHaveBeenCalled(); + }); + + it('should refetch data when activeColumnFqn changes', async () => { + const { rerender } = render(); + + await waitFor(() => { + expect(mockGetColumnProfilerList).toHaveBeenCalledTimes(1); + }); + + rerender( + + ); + + 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(); + + await waitFor(() => { + expect(mockGetColumnProfilerList).toHaveBeenCalledTimes(1); + }); + + const newDateRange = { + startTs: 1703894400000, + endTs: 1703980800000, + key: 'last_1_day', + }; + + rerender( + + ); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + await waitFor(() => { + expect( + screen.getByTestId('profiler-tab-container') + ).toBeInTheDocument(); + }); + }); + }); + + describe('Custom Metrics Integration', () => { + it('should use custom metrics from tableDetails when provided', async () => { + render(); + + 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( + + ); + + await waitFor(() => { + expect(screen.getByTestId('custom-metric-graphs')).toBeInTheDocument(); + }); + }); + + it('should handle empty custom metrics array', async () => { + render( + + ); + + 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(); + + 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(); + + 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(); + + await waitFor(() => { + expect( + screen.getByTestId('profiler-tab-container') + ).toBeInTheDocument(); + }); + + mockGetColumnProfilerList.mockResolvedValue({ + data: [mockStringColumnProfilerData[0]], + paging: { total: 1 }, + }); + + rerender( + + ); + + await waitFor(() => { + expect(mockGetColumnProfilerList).toHaveBeenCalledWith( + 'db.schema.test_table.string_column', + { startTs: 1703980800000, endTs: 1704067200000 } + ); + }); + }); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/SingleColumnProfile.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/SingleColumnProfile.tsx index e8d85f61df2..9b7b26d55f7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/SingleColumnProfile.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/SingleColumnProfile.tsx @@ -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 = ({ title={t('label.data-quartile-plural')} /> - - - - - - {t('label.data-distribution')} - - - - - - - - + {firstDay?.histogram || currentDay?.histogram ? ( + + + + + + {t('label.data-distribution')} + + + + + + + + + ) : null} + {firstDay?.cardinalityDistribution || + currentDay?.cardinalityDistribution ? ( + + + + + + {t('label.cardinality')} + + + + + + + + + ) : null} { + const { t } = useTranslation(); + const showSingleGraph = + isUndefined(data.firstDayData?.cardinalityDistribution) || + isUndefined(data.currentDayData?.cardinalityDistribution); + + if ( + isUndefined(data.firstDayData?.cardinalityDistribution) && + isUndefined(data.currentDayData?.cardinalityDistribution) + ) { + return ( + + + + + + ); + } + + const renderTooltip: TooltipProps['content'] = ( + props + ) => { + const { active, payload } = props; + if (active && payload && payload.length) { + const data = payload[0].payload; + + return ( + +

{`${t('label.category')}: ${ + data.name + }`}

+

{`${t('label.count')}: ${tooltipFormatter( + data.count + )}`}

+

{`${t('label.percentage')}: ${ + data.percentage + }%`}

+
+ ); + } + + return null; + }; + + return ( + + {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 ( + + + + {graphDate} + + + {`${t('label.total-entity', { + entity: t('label.category-plural'), + })}: ${cardinalityData.categories?.length || 0}`} + + + + + + axisTickFormatter(props, '%')} + type="number" + /> + + value?.length > 15 ? `${value.slice(0, 15)}...` : value + } + type="category" + width={120} + /> + + + + + + + + + ); + })} + + ); +}; + +export default CardinalityDistributionChart; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Visualisations/Chart/CardinalityDistributionChart.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Visualisations/Chart/CardinalityDistributionChart.test.tsx new file mode 100644 index 00000000000..66c63e569ed --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Visualisations/Chart/CardinalityDistributionChart.test.tsx @@ -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
{placeholderText}
; + }; +}); + +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(); + + expect(await screen.findByTestId('chart-container')).toBeInTheDocument(); + }); + + it('should render dual charts when both firstDayData and currentDayData have cardinality data', async () => { + const { container } = render( + + ); + + 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(); + + 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(); + + 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(); + + 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(); + + 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( + + ); + + // 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(); + + // 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(); + + // 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(); + + 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(); + + 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(); + + 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(); + + // 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( + + ); + + // 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(); + + 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(); + + 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(); + + const dateElement = await screen.findByTestId('date'); + + expect(dateElement).toHaveTextContent('Unknown Date'); + }); + + it('should handle React node as noDataPlaceholderText', async () => { + const customPlaceholder =
Custom No Data Message
; + + const props: CardinalityDistributionChartProps = { + data: { + firstDayData: mockColumnProfileWithoutCardinality, + currentDayData: mockColumnProfileWithoutCardinality, + }, + noDataPlaceholderText: customPlaceholder, + }; + + render(); + + 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(); + + 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( + + ); + + 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(); + + expect( + await screen.findByTestId('error-placeholder') + ).toBeInTheDocument(); + }); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Visualisations/Chart/DataDistributionHistogram.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Visualisations/Chart/DataDistributionHistogram.component.tsx index 1f602ac012c..c1454ea48ee 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Visualisations/Chart/DataDistributionHistogram.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Visualisations/Chart/DataDistributionHistogram.component.tsx @@ -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) } /> - + diff --git a/openmetadata-ui/src/main/resources/ui/src/constants/Color.constants.ts b/openmetadata-ui/src/main/resources/ui/src/constants/Color.constants.ts index f53a38fc6ac..c7c7cae47e3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/constants/Color.constants.ts +++ b/openmetadata-ui/src/main/resources/ui/src/constants/Color.constants.ts @@ -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'; diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json index 92320a8bcff..eaa6ae19dc3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json @@ -194,6 +194,7 @@ "by-lowercase": "von", "ca-certs": "CA-Zertifikate", "cancel": "Abbrechen", + "cardinality": "Cardinality", "category": "Kategorie", "category-plural": "Kategorien", "certification": "Zertifizierung", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json index 8ac111896bb..cc451978192 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json @@ -194,6 +194,7 @@ "by-lowercase": "by", "ca-certs": "CA Certs", "cancel": "Cancel", + "cardinality": "Cardinality", "category": "Category", "category-plural": "Categories", "certification": "Certification", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json index 3d2ae2becd5..72be39e9d23 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json @@ -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", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json index 99bf933fa9f..1baf22034c4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json @@ -194,6 +194,7 @@ "by-lowercase": "par", "ca-certs": "Certificats CA", "cancel": "Annuler", + "cardinality": "Cardinality", "category": "Category", "category-plural": "Categories", "certification": "Certification", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json index 0b8e03801b7..42789eeece7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json @@ -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", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json index ba94ccea28f..dcbfd43150a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json @@ -194,6 +194,7 @@ "by-lowercase": "לפי", "ca-certs": "תעודות CA", "cancel": "ביטול", + "cardinality": "Cardinality", "category": "Category", "category-plural": "קטגוריות", "certification": "Certification", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json index 118ad360b5d..37079e2ede6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json @@ -194,6 +194,7 @@ "by-lowercase": "による", "ca-certs": "CA証明書", "cancel": "キャンセル", + "cardinality": "Cardinality", "category": "カテゴリ", "category-plural": "カテゴリ", "certification": "認証", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json index f9750284bb2..dbf82bcf2f1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json @@ -194,6 +194,7 @@ "by-lowercase": "작성자", "ca-certs": "CA 인증서", "cancel": "취소", + "cardinality": "Cardinality", "category": "카테고리", "category-plural": "카테고리들", "certification": "인증", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json index 29505d9c80b..d6fd05dc713 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json @@ -194,6 +194,7 @@ "by-lowercase": "द्वारे", "ca-certs": "CA प्रमाणपत्रे", "cancel": "रद्द करा", + "cardinality": "Cardinality", "category": "श्रेणी", "category-plural": "श्रेण्या", "certification": "प्रमाणपत्र", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json index 7388f248a25..8e6374f0670 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json @@ -194,6 +194,7 @@ "by-lowercase": "door", "ca-certs": "CA-certificaten", "cancel": "Annuleren", + "cardinality": "Cardinality", "category": "Categorie", "category-plural": "Categorieën", "certification": "Certification", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json index 63217cf7959..af3406056a3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json @@ -194,6 +194,7 @@ "by-lowercase": "توسط", "ca-certs": "گواهی‌های CA", "cancel": "لغو", + "cardinality": "Cardinality", "category": "دسته‌بندی", "category-plural": "دسته‌بندی‌ها", "certification": "Certification", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json index adf3bf55b51..b6add59f345 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json @@ -194,6 +194,7 @@ "by-lowercase": "por", "ca-certs": "Certificados CA", "cancel": "Cancelar", + "cardinality": "Cardinality", "category": "Categoria", "category-plural": "Categorias", "certification": "Certificação", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json index 6f10a27472e..0cef6c89634 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json @@ -194,6 +194,7 @@ "by-lowercase": "por", "ca-certs": "Certificados CA", "cancel": "Cancelar", + "cardinality": "Cardinality", "category": "Category", "category-plural": "Categorias", "certification": "Certification", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json index 834a3e667e9..ad8667a16e5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json @@ -194,6 +194,7 @@ "by-lowercase": "к", "ca-certs": "Сертификаты CA", "cancel": "Отмена", + "cardinality": "Cardinality", "category": "Категория", "category-plural": "Категории", "certification": "Сертификация", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json index a4566e80bac..0d908dd52e2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json @@ -194,6 +194,7 @@ "by-lowercase": "โดย", "ca-certs": "ใบรับรอง CA", "cancel": "ยกเลิก", + "cardinality": "Cardinality", "category": "หมวดหมู่", "category-plural": "หมวดหมู่หลายรายการ", "certification": "Certification", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json index 9dbd5ab6078..9ec577c0287 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json @@ -194,6 +194,7 @@ "by-lowercase": "tarafından", "ca-certs": "CA Sertifikaları", "cancel": "İptal", + "cardinality": "Cardinality", "category": "Kategori", "category-plural": "Kategoriler", "certification": "Sertifikasyon", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json index 2b7f11bbf25..ca977c90182 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json @@ -194,6 +194,7 @@ "by-lowercase": "by", "ca-certs": "CA 证书", "cancel": "取消", + "cardinality": "Cardinality", "category": "分类", "category-plural": "分类", "certification": "Certification", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json index c265edc4a3e..f4e26aebede 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json @@ -194,6 +194,7 @@ "by-lowercase": "由", "ca-certs": "CA 憑證", "cancel": "取消", + "cardinality": "Cardinality", "category": "類別", "category-plural": "類別", "certification": "認證",