mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-10-02 12:26:42 +00:00
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:
parent
e66824cd45
commit
5c1b76a2c8
@ -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 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -32,6 +32,7 @@ import {
|
|||||||
} from '../../../../utils/TableProfilerUtils';
|
} from '../../../../utils/TableProfilerUtils';
|
||||||
import { ColumnMetricsInterface } from '../../../../utils/TableProfilerUtils.interface';
|
import { ColumnMetricsInterface } from '../../../../utils/TableProfilerUtils.interface';
|
||||||
import { showErrorToast } from '../../../../utils/ToastUtils';
|
import { showErrorToast } from '../../../../utils/ToastUtils';
|
||||||
|
import CardinalityDistributionChart from '../../../Visualisations/Chart/CardinalityDistributionChart.component';
|
||||||
import DataDistributionHistogram from '../../../Visualisations/Chart/DataDistributionHistogram.component';
|
import DataDistributionHistogram from '../../../Visualisations/Chart/DataDistributionHistogram.component';
|
||||||
import ProfilerDetailsCard from '../ProfilerDetailsCard/ProfilerDetailsCard';
|
import ProfilerDetailsCard from '../ProfilerDetailsCard/ProfilerDetailsCard';
|
||||||
import CustomMetricGraphs from './CustomMetricGraphs/CustomMetricGraphs.component';
|
import CustomMetricGraphs from './CustomMetricGraphs/CustomMetricGraphs.component';
|
||||||
@ -202,26 +203,55 @@ const SingleColumnProfile: FC<SingleColumnProfileProps> = ({
|
|||||||
title={t('label.data-quartile-plural')}
|
title={t('label.data-quartile-plural')}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={24}>
|
{firstDay?.histogram || currentDay?.histogram ? (
|
||||||
<Card
|
<Col span={24}>
|
||||||
className="shadow-none global-border-radius"
|
<Card
|
||||||
data-testid="histogram-metrics"
|
className="shadow-none global-border-radius"
|
||||||
loading={isLoading}>
|
data-testid="histogram-metrics"
|
||||||
<Row gutter={[16, 16]}>
|
loading={isLoading}>
|
||||||
<Col span={24}>
|
<Row gutter={[16, 16]}>
|
||||||
<Typography.Title data-testid="data-distribution-title" level={5}>
|
<Col span={24}>
|
||||||
{t('label.data-distribution')}
|
<Typography.Title
|
||||||
</Typography.Title>
|
data-testid="data-distribution-title"
|
||||||
</Col>
|
level={5}>
|
||||||
<Col span={24}>
|
{t('label.data-distribution')}
|
||||||
<DataDistributionHistogram
|
</Typography.Title>
|
||||||
data={{ firstDayData: firstDay, currentDayData: currentDay }}
|
</Col>
|
||||||
noDataPlaceholderText={noProfilerMessage}
|
<Col span={24}>
|
||||||
/>
|
<DataDistributionHistogram
|
||||||
</Col>
|
data={{ firstDayData: firstDay, currentDayData: currentDay }}
|
||||||
</Row>
|
noDataPlaceholderText={noProfilerMessage}
|
||||||
</Card>
|
/>
|
||||||
</Col>
|
</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}>
|
<Col span={24}>
|
||||||
<CustomMetricGraphs
|
<CustomMetricGraphs
|
||||||
customMetrics={customMetrics}
|
customMetrics={customMetrics}
|
||||||
|
@ -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;
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -24,6 +24,7 @@ import {
|
|||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
|
import { CHART_BLUE_1 } from '../../../constants/Color.constants';
|
||||||
import { GRAPH_BACKGROUND_COLOR } from '../../../constants/constants';
|
import { GRAPH_BACKGROUND_COLOR } from '../../../constants/constants';
|
||||||
import { DEFAULT_HISTOGRAM_DATA } from '../../../constants/profiler.constant';
|
import { DEFAULT_HISTOGRAM_DATA } from '../../../constants/profiler.constant';
|
||||||
import { HistogramClass } from '../../../generated/entity/data/table';
|
import { HistogramClass } from '../../../generated/entity/data/table';
|
||||||
@ -116,7 +117,7 @@ const DataDistributionHistogram = ({
|
|||||||
tooltipFormatter(value)
|
tooltipFormatter(value)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="frequency" fill="#1890FF" />
|
<Bar dataKey="frequency" fill={CHART_BLUE_1} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</Col>
|
</Col>
|
||||||
|
@ -29,6 +29,7 @@ export const LIGHT_GRAY = '#F1F4F9';
|
|||||||
export const INDIGO_1 = '#3538CD';
|
export const INDIGO_1 = '#3538CD';
|
||||||
export const PRIMARY_COLOR = DEFAULT_THEME.primaryColor;
|
export const PRIMARY_COLOR = DEFAULT_THEME.primaryColor;
|
||||||
export const BLUE_2 = '#3ca2f4';
|
export const BLUE_2 = '#3ca2f4';
|
||||||
|
export const CHART_BLUE_1 = '#1890FF';
|
||||||
export const RIPTIDE = '#76E9C6';
|
export const RIPTIDE = '#76E9C6';
|
||||||
export const MY_SIN = '#FEB019';
|
export const MY_SIN = '#FEB019';
|
||||||
export const SAN_MARINO = '#416BB3';
|
export const SAN_MARINO = '#416BB3';
|
||||||
|
@ -194,6 +194,7 @@
|
|||||||
"by-lowercase": "von",
|
"by-lowercase": "von",
|
||||||
"ca-certs": "CA-Zertifikate",
|
"ca-certs": "CA-Zertifikate",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
|
"cardinality": "Cardinality",
|
||||||
"category": "Kategorie",
|
"category": "Kategorie",
|
||||||
"category-plural": "Kategorien",
|
"category-plural": "Kategorien",
|
||||||
"certification": "Zertifizierung",
|
"certification": "Zertifizierung",
|
||||||
|
@ -194,6 +194,7 @@
|
|||||||
"by-lowercase": "by",
|
"by-lowercase": "by",
|
||||||
"ca-certs": "CA Certs",
|
"ca-certs": "CA Certs",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
"cardinality": "Cardinality",
|
||||||
"category": "Category",
|
"category": "Category",
|
||||||
"category-plural": "Categories",
|
"category-plural": "Categories",
|
||||||
"certification": "Certification",
|
"certification": "Certification",
|
||||||
|
@ -194,6 +194,7 @@
|
|||||||
"by-lowercase": "por",
|
"by-lowercase": "por",
|
||||||
"ca-certs": "Certificados CA",
|
"ca-certs": "Certificados CA",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
|
"cardinality": "Cardinalidad",
|
||||||
"category": "Categoría",
|
"category": "Categoría",
|
||||||
"category-plural": "Categorías",
|
"category-plural": "Categorías",
|
||||||
"certification": "Certificación",
|
"certification": "Certificación",
|
||||||
|
@ -194,6 +194,7 @@
|
|||||||
"by-lowercase": "par",
|
"by-lowercase": "par",
|
||||||
"ca-certs": "Certificats CA",
|
"ca-certs": "Certificats CA",
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
|
"cardinality": "Cardinality",
|
||||||
"category": "Category",
|
"category": "Category",
|
||||||
"category-plural": "Categories",
|
"category-plural": "Categories",
|
||||||
"certification": "Certification",
|
"certification": "Certification",
|
||||||
|
@ -194,6 +194,7 @@
|
|||||||
"by-lowercase": "por",
|
"by-lowercase": "por",
|
||||||
"ca-certs": "Certificados CA",
|
"ca-certs": "Certificados CA",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
|
"cardinality": "Cardinality",
|
||||||
"category": "Categoría",
|
"category": "Categoría",
|
||||||
"category-plural": "Categorías",
|
"category-plural": "Categorías",
|
||||||
"certification": "Certificación",
|
"certification": "Certificación",
|
||||||
|
@ -194,6 +194,7 @@
|
|||||||
"by-lowercase": "לפי",
|
"by-lowercase": "לפי",
|
||||||
"ca-certs": "תעודות CA",
|
"ca-certs": "תעודות CA",
|
||||||
"cancel": "ביטול",
|
"cancel": "ביטול",
|
||||||
|
"cardinality": "Cardinality",
|
||||||
"category": "Category",
|
"category": "Category",
|
||||||
"category-plural": "קטגוריות",
|
"category-plural": "קטגוריות",
|
||||||
"certification": "Certification",
|
"certification": "Certification",
|
||||||
|
@ -194,6 +194,7 @@
|
|||||||
"by-lowercase": "による",
|
"by-lowercase": "による",
|
||||||
"ca-certs": "CA証明書",
|
"ca-certs": "CA証明書",
|
||||||
"cancel": "キャンセル",
|
"cancel": "キャンセル",
|
||||||
|
"cardinality": "Cardinality",
|
||||||
"category": "カテゴリ",
|
"category": "カテゴリ",
|
||||||
"category-plural": "カテゴリ",
|
"category-plural": "カテゴリ",
|
||||||
"certification": "認証",
|
"certification": "認証",
|
||||||
|
@ -194,6 +194,7 @@
|
|||||||
"by-lowercase": "작성자",
|
"by-lowercase": "작성자",
|
||||||
"ca-certs": "CA 인증서",
|
"ca-certs": "CA 인증서",
|
||||||
"cancel": "취소",
|
"cancel": "취소",
|
||||||
|
"cardinality": "Cardinality",
|
||||||
"category": "카테고리",
|
"category": "카테고리",
|
||||||
"category-plural": "카테고리들",
|
"category-plural": "카테고리들",
|
||||||
"certification": "인증",
|
"certification": "인증",
|
||||||
|
@ -194,6 +194,7 @@
|
|||||||
"by-lowercase": "द्वारे",
|
"by-lowercase": "द्वारे",
|
||||||
"ca-certs": "CA प्रमाणपत्रे",
|
"ca-certs": "CA प्रमाणपत्रे",
|
||||||
"cancel": "रद्द करा",
|
"cancel": "रद्द करा",
|
||||||
|
"cardinality": "Cardinality",
|
||||||
"category": "श्रेणी",
|
"category": "श्रेणी",
|
||||||
"category-plural": "श्रेण्या",
|
"category-plural": "श्रेण्या",
|
||||||
"certification": "प्रमाणपत्र",
|
"certification": "प्रमाणपत्र",
|
||||||
|
@ -194,6 +194,7 @@
|
|||||||
"by-lowercase": "door",
|
"by-lowercase": "door",
|
||||||
"ca-certs": "CA-certificaten",
|
"ca-certs": "CA-certificaten",
|
||||||
"cancel": "Annuleren",
|
"cancel": "Annuleren",
|
||||||
|
"cardinality": "Cardinality",
|
||||||
"category": "Categorie",
|
"category": "Categorie",
|
||||||
"category-plural": "Categorieën",
|
"category-plural": "Categorieën",
|
||||||
"certification": "Certification",
|
"certification": "Certification",
|
||||||
|
@ -194,6 +194,7 @@
|
|||||||
"by-lowercase": "توسط",
|
"by-lowercase": "توسط",
|
||||||
"ca-certs": "گواهیهای CA",
|
"ca-certs": "گواهیهای CA",
|
||||||
"cancel": "لغو",
|
"cancel": "لغو",
|
||||||
|
"cardinality": "Cardinality",
|
||||||
"category": "دستهبندی",
|
"category": "دستهبندی",
|
||||||
"category-plural": "دستهبندیها",
|
"category-plural": "دستهبندیها",
|
||||||
"certification": "Certification",
|
"certification": "Certification",
|
||||||
|
@ -194,6 +194,7 @@
|
|||||||
"by-lowercase": "por",
|
"by-lowercase": "por",
|
||||||
"ca-certs": "Certificados CA",
|
"ca-certs": "Certificados CA",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
|
"cardinality": "Cardinality",
|
||||||
"category": "Categoria",
|
"category": "Categoria",
|
||||||
"category-plural": "Categorias",
|
"category-plural": "Categorias",
|
||||||
"certification": "Certificação",
|
"certification": "Certificação",
|
||||||
|
@ -194,6 +194,7 @@
|
|||||||
"by-lowercase": "por",
|
"by-lowercase": "por",
|
||||||
"ca-certs": "Certificados CA",
|
"ca-certs": "Certificados CA",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
|
"cardinality": "Cardinality",
|
||||||
"category": "Category",
|
"category": "Category",
|
||||||
"category-plural": "Categorias",
|
"category-plural": "Categorias",
|
||||||
"certification": "Certification",
|
"certification": "Certification",
|
||||||
|
@ -194,6 +194,7 @@
|
|||||||
"by-lowercase": "к",
|
"by-lowercase": "к",
|
||||||
"ca-certs": "Сертификаты CA",
|
"ca-certs": "Сертификаты CA",
|
||||||
"cancel": "Отмена",
|
"cancel": "Отмена",
|
||||||
|
"cardinality": "Cardinality",
|
||||||
"category": "Категория",
|
"category": "Категория",
|
||||||
"category-plural": "Категории",
|
"category-plural": "Категории",
|
||||||
"certification": "Сертификация",
|
"certification": "Сертификация",
|
||||||
|
@ -194,6 +194,7 @@
|
|||||||
"by-lowercase": "โดย",
|
"by-lowercase": "โดย",
|
||||||
"ca-certs": "ใบรับรอง CA",
|
"ca-certs": "ใบรับรอง CA",
|
||||||
"cancel": "ยกเลิก",
|
"cancel": "ยกเลิก",
|
||||||
|
"cardinality": "Cardinality",
|
||||||
"category": "หมวดหมู่",
|
"category": "หมวดหมู่",
|
||||||
"category-plural": "หมวดหมู่หลายรายการ",
|
"category-plural": "หมวดหมู่หลายรายการ",
|
||||||
"certification": "Certification",
|
"certification": "Certification",
|
||||||
|
@ -194,6 +194,7 @@
|
|||||||
"by-lowercase": "tarafından",
|
"by-lowercase": "tarafından",
|
||||||
"ca-certs": "CA Sertifikaları",
|
"ca-certs": "CA Sertifikaları",
|
||||||
"cancel": "İptal",
|
"cancel": "İptal",
|
||||||
|
"cardinality": "Cardinality",
|
||||||
"category": "Kategori",
|
"category": "Kategori",
|
||||||
"category-plural": "Kategoriler",
|
"category-plural": "Kategoriler",
|
||||||
"certification": "Sertifikasyon",
|
"certification": "Sertifikasyon",
|
||||||
|
@ -194,6 +194,7 @@
|
|||||||
"by-lowercase": "by",
|
"by-lowercase": "by",
|
||||||
"ca-certs": "CA 证书",
|
"ca-certs": "CA 证书",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
|
"cardinality": "Cardinality",
|
||||||
"category": "分类",
|
"category": "分类",
|
||||||
"category-plural": "分类",
|
"category-plural": "分类",
|
||||||
"certification": "Certification",
|
"certification": "Certification",
|
||||||
|
@ -194,6 +194,7 @@
|
|||||||
"by-lowercase": "由",
|
"by-lowercase": "由",
|
||||||
"ca-certs": "CA 憑證",
|
"ca-certs": "CA 憑證",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
|
"cardinality": "Cardinality",
|
||||||
"category": "類別",
|
"category": "類別",
|
||||||
"category-plural": "類別",
|
"category-plural": "類別",
|
||||||
"certification": "認證",
|
"certification": "認證",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user