mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-10-01 20:04:52 +00:00
Modify ContractExecutionChart to occupy full space and chart bar's start from left (#23602)
* modify contract execution chart to occupy full space * remove unwanted css * move the chart bar's to be aligned in left hand side
This commit is contained in:
parent
ed71f3629a
commit
77cfe7849b
@ -11,7 +11,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { AxiosError } from 'axios';
|
||||
import classNames from 'classnames';
|
||||
import { isEqual, pick, sortBy } from 'lodash';
|
||||
import { DateRangeObject } from 'Models';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
@ -36,11 +35,14 @@ import { DATA_CONTRACT_EXECUTION_CHART_COMMON_PROPS } from '../../../constants/D
|
||||
import { PROFILER_FILTER_RANGE } from '../../../constants/profiler.constant';
|
||||
import { DataContract } from '../../../generated/entity/data/dataContract';
|
||||
import { DataContractResult } from '../../../generated/entity/datacontract/dataContractResult';
|
||||
import { ContractExecutionStatus } from '../../../generated/type/contractExecutionStatus';
|
||||
import { getAllContractResults } from '../../../rest/contractAPI';
|
||||
import { getContractExecutionMonthTicks } from '../../../utils/DataContract/DataContractUtils';
|
||||
import {
|
||||
formatMonth,
|
||||
createContractExecutionCustomScale,
|
||||
formatContractExecutionTick,
|
||||
generateMonthTickPositions,
|
||||
processContractExecutionData,
|
||||
} from '../../../utils/DataContract/DataContractUtils';
|
||||
import {
|
||||
getCurrentMillis,
|
||||
getEpochMillisForPastDays,
|
||||
} from '../../../utils/date-time/DateTimeUtils';
|
||||
@ -89,31 +91,24 @@ const ContractExecutionChart = ({ contract }: { contract: DataContract }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const { processedChartData, executionMonthThicks } = useMemo(() => {
|
||||
const processed = contractExecutionResultList.map((item) => {
|
||||
return {
|
||||
name: item.timestamp,
|
||||
failed:
|
||||
item.contractExecutionStatus === ContractExecutionStatus.Failed
|
||||
? 1
|
||||
: 0,
|
||||
success:
|
||||
item.contractExecutionStatus === ContractExecutionStatus.Success
|
||||
? 1
|
||||
: 0,
|
||||
aborted:
|
||||
item.contractExecutionStatus === ContractExecutionStatus.Aborted
|
||||
? 1
|
||||
: 0,
|
||||
data: item,
|
||||
};
|
||||
});
|
||||
const { processedChartData, executionMonthThicks, customScale } =
|
||||
useMemo(() => {
|
||||
const processed = processContractExecutionData(
|
||||
contractExecutionResultList
|
||||
);
|
||||
|
||||
return {
|
||||
processedChartData: processed,
|
||||
executionMonthThicks: getContractExecutionMonthTicks(processed),
|
||||
};
|
||||
}, [contractExecutionResultList]);
|
||||
// Create custom scale for positioning bars from the left
|
||||
const customScaleFunction = createContractExecutionCustomScale(processed);
|
||||
|
||||
// Generate tick positions for month labels
|
||||
const tickPositions = generateMonthTickPositions(processed);
|
||||
|
||||
return {
|
||||
processedChartData: processed,
|
||||
executionMonthThicks: tickPositions,
|
||||
customScale: customScaleFunction,
|
||||
};
|
||||
}, [contractExecutionResultList]);
|
||||
|
||||
const handleDateRangeChange = (value: DateRangeObject) => {
|
||||
if (!isEqual(value, dateRangeObject)) {
|
||||
@ -137,11 +132,7 @@ const ContractExecutionChart = ({ contract }: { contract: DataContract }) => {
|
||||
{isLoading ? (
|
||||
<Loader />
|
||||
) : (
|
||||
<ResponsiveContainer
|
||||
className={classNames('contract-execution-chart', {
|
||||
'contract-execution-chart-less-width':
|
||||
contractExecutionResultList.length <= 16,
|
||||
})}>
|
||||
<ResponsiveContainer className="contract-execution-chart">
|
||||
<BarChart data={processedChartData}>
|
||||
<CartesianGrid
|
||||
stroke={GREY_100}
|
||||
@ -156,8 +147,9 @@ const ContractExecutionChart = ({ contract }: { contract: DataContract }) => {
|
||||
<XAxis
|
||||
axisLine={false}
|
||||
dataKey="name"
|
||||
domain={['min', 'max']}
|
||||
tickFormatter={formatMonth}
|
||||
domain={[dateRangeObject.startTs, dateRangeObject.endTs]}
|
||||
scale={customScale}
|
||||
tickFormatter={formatContractExecutionTick}
|
||||
tickMargin={10}
|
||||
ticks={executionMonthThicks}
|
||||
/>
|
||||
|
@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2025 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import {
|
||||
ContractExecutionStatus,
|
||||
DataContractResult,
|
||||
} from '../../../generated/entity/datacontract/dataContractResult';
|
||||
|
||||
export interface DataContractProcessedResultCharts {
|
||||
name: string;
|
||||
displayTimestamp: number;
|
||||
value: number;
|
||||
status: ContractExecutionStatus;
|
||||
failed: number;
|
||||
success: number;
|
||||
aborted: number;
|
||||
data: DataContractResult;
|
||||
}
|
@ -24,6 +24,11 @@ import { DataContract } from '../../../generated/entity/data/dataContract';
|
||||
import { DataContractResult } from '../../../generated/entity/datacontract/dataContractResult';
|
||||
import { ContractExecutionStatus } from '../../../generated/type/contractExecutionStatus';
|
||||
import { getAllContractResults } from '../../../rest/contractAPI';
|
||||
import {
|
||||
createContractExecutionCustomScale,
|
||||
generateMonthTickPositions,
|
||||
processContractExecutionData,
|
||||
} from '../../../utils/DataContract/DataContractUtils';
|
||||
import { showErrorToast } from '../../../utils/ToastUtils';
|
||||
import ContractExecutionChart from './ContractExecutionChart.component';
|
||||
|
||||
@ -35,6 +40,56 @@ jest.mock('../../../utils/ToastUtils', () => ({
|
||||
showErrorToast: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../utils/DataContract/DataContractUtils', () => ({
|
||||
processContractExecutionData: jest.fn((data) =>
|
||||
data.map((item: any, index: number) => ({
|
||||
name: `${item.timestamp}_${index}`,
|
||||
displayTimestamp: item.timestamp,
|
||||
value: 1,
|
||||
status: item.contractExecutionStatus,
|
||||
failed: item.contractExecutionStatus === 'Failed' ? 1 : 0,
|
||||
success: item.contractExecutionStatus === 'Success' ? 1 : 0,
|
||||
aborted: item.contractExecutionStatus === 'Aborted' ? 1 : 0,
|
||||
data: item,
|
||||
}))
|
||||
),
|
||||
createContractExecutionCustomScale: jest.fn(() => {
|
||||
const scale: any = (value: any) => value;
|
||||
scale.domain = jest.fn(() => scale);
|
||||
scale.range = jest.fn(() => scale);
|
||||
scale.ticks = jest.fn(() => []);
|
||||
scale.tickFormat = jest.fn();
|
||||
scale.bandwidth = jest.fn(() => 20);
|
||||
scale.copy = jest.fn(() => scale);
|
||||
scale.nice = jest.fn(() => scale);
|
||||
scale.type = 'band';
|
||||
|
||||
return scale;
|
||||
}),
|
||||
generateMonthTickPositions: jest.fn((data) =>
|
||||
data.length > 0 ? [data[0].name] : []
|
||||
),
|
||||
formatContractExecutionTick: jest.fn((value) => {
|
||||
const timestamp = value.split('_')[0];
|
||||
const monthNames = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
];
|
||||
|
||||
return monthNames[new Date(Number(timestamp)).getMonth()];
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../../utils/date-time/DateTimeUtils', () => ({
|
||||
formatMonth: jest.fn((timestamp) => {
|
||||
const monthNames = [
|
||||
@ -249,8 +304,12 @@ describe('ContractExecutionChart', () => {
|
||||
);
|
||||
|
||||
expect(chartData).toHaveLength(3);
|
||||
// Data should now have unique names with timestamp_index format
|
||||
expect(chartData[0]).toEqual({
|
||||
name: 1640995200000,
|
||||
name: '1640995200000_0',
|
||||
displayTimestamp: 1640995200000,
|
||||
value: 1,
|
||||
status: ContractExecutionStatus.Success,
|
||||
failed: 0,
|
||||
success: 1,
|
||||
aborted: 0,
|
||||
@ -261,7 +320,10 @@ describe('ContractExecutionChart', () => {
|
||||
},
|
||||
});
|
||||
expect(chartData[1]).toEqual({
|
||||
name: 1640995260000,
|
||||
name: '1640995260000_1',
|
||||
displayTimestamp: 1640995260000,
|
||||
value: 1,
|
||||
status: ContractExecutionStatus.Failed,
|
||||
failed: 1,
|
||||
success: 0,
|
||||
aborted: 0,
|
||||
@ -272,7 +334,10 @@ describe('ContractExecutionChart', () => {
|
||||
},
|
||||
});
|
||||
expect(chartData[2]).toEqual({
|
||||
name: 1640995320000,
|
||||
name: '1640995320000_2',
|
||||
displayTimestamp: 1640995320000,
|
||||
value: 1,
|
||||
status: ContractExecutionStatus.Aborted,
|
||||
failed: 0,
|
||||
success: 0,
|
||||
aborted: 1,
|
||||
@ -313,9 +378,10 @@ describe('ContractExecutionChart', () => {
|
||||
expect(await screen.findByTestId('x-axis')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render bars for each status type', async () => {
|
||||
it('should render bars for each status type without stacking', async () => {
|
||||
render(<ContractExecutionChart contract={mockContract} />);
|
||||
|
||||
// Bars should not have stackId anymore - they render individually
|
||||
expect(await screen.findByTestId('bar-success')).toHaveTextContent(
|
||||
'Success'
|
||||
);
|
||||
@ -347,6 +413,45 @@ describe('ContractExecutionChart', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Utility Functions Integration', () => {
|
||||
it('should call processContractExecutionData with correct data', async () => {
|
||||
render(<ContractExecutionChart contract={mockContract} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(processContractExecutionData).toHaveBeenCalledWith(
|
||||
mockContractResults
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should call createContractExecutionCustomScale with processed data', async () => {
|
||||
render(<ContractExecutionChart contract={mockContract} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createContractExecutionCustomScale).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call generateMonthTickPositions with processed data', async () => {
|
||||
render(<ContractExecutionChart contract={mockContract} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(generateMonthTickPositions).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should use formatContractExecutionTick for tick formatting', async () => {
|
||||
render(<ContractExecutionChart contract={mockContract} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const xAxis = screen.getByTestId('x-axis');
|
||||
|
||||
expect(xAxis).toBeInTheDocument();
|
||||
// The formatter function is passed to XAxis
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Date Range Handling', () => {
|
||||
it('should initialize with default date range', () => {
|
||||
render(<ContractExecutionChart contract={mockContract} />);
|
||||
|
@ -23,15 +23,19 @@ const ContractExecutionChartTooltip = (
|
||||
|
||||
const data = payload.length ? payload[0].payload.data : {};
|
||||
|
||||
if (!active || payload.length === 0) {
|
||||
if (!active || payload.length === 0 || !data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp =
|
||||
payload[0].payload.displayTimestamp ||
|
||||
payload[0].payload.name.split('_')[0];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<Typography.Title level={5}>
|
||||
{formatDateTimeLong(payload[0].payload.name)}
|
||||
{formatDateTimeLong(timestamp)}
|
||||
</Typography.Title>
|
||||
}>
|
||||
<ul
|
||||
@ -43,7 +47,7 @@ const ContractExecutionChartTooltip = (
|
||||
<span className="flex items-center text-grey-muted">
|
||||
{t('label.contract-execution-status')}
|
||||
</span>
|
||||
<span className="font-medium">{data.contractExecutionStatus}</span>
|
||||
<span className="font-medium">{data?.contractExecutionStatus}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</Card>
|
||||
|
@ -0,0 +1,226 @@
|
||||
/*
|
||||
* Copyright 2025 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { TooltipProps } from 'recharts';
|
||||
import { ContractExecutionStatus } from '../../../generated/type/contractExecutionStatus';
|
||||
import ContractExecutionChartTooltip from './ContractExecutionChartTooltip.component';
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../../utils/date-time/DateTimeUtils', () => ({
|
||||
formatDateTimeLong: jest.fn((timestamp) => `Formatted: ${timestamp}`),
|
||||
}));
|
||||
|
||||
describe('ContractExecutionChartTooltip', () => {
|
||||
const mockData = {
|
||||
id: 'test-id-123',
|
||||
dataContractFQN: 'test.contract.fqn',
|
||||
timestamp: 1234567890000,
|
||||
contractExecutionStatus: ContractExecutionStatus.Success,
|
||||
};
|
||||
|
||||
const baseProps: TooltipProps<string | number, string> = {
|
||||
active: true,
|
||||
payload: [
|
||||
{
|
||||
color: '#00FF00',
|
||||
dataKey: 'success',
|
||||
value: 1,
|
||||
payload: {
|
||||
name: '1234567890000_0',
|
||||
displayTimestamp: 1234567890000,
|
||||
value: 1,
|
||||
status: ContractExecutionStatus.Success,
|
||||
failed: 0,
|
||||
success: 1,
|
||||
aborted: 0,
|
||||
data: mockData,
|
||||
},
|
||||
},
|
||||
],
|
||||
label: '1234567890000_0',
|
||||
};
|
||||
|
||||
it('should render tooltip when active and has data', () => {
|
||||
render(<ContractExecutionChartTooltip {...baseProps} />);
|
||||
|
||||
expect(screen.getByText('Formatted: 1234567890000')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('label.contract-execution-status')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Success')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render when not active', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
active: false,
|
||||
};
|
||||
|
||||
const { container } = render(<ContractExecutionChartTooltip {...props} />);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('should not render when payload is empty', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
payload: [],
|
||||
};
|
||||
|
||||
const { container } = render(<ContractExecutionChartTooltip {...props} />);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('should not render when data is null', () => {
|
||||
const props: TooltipProps<string | number, string> = {
|
||||
...baseProps,
|
||||
payload: [
|
||||
{
|
||||
color: '#00FF00',
|
||||
dataKey: 'success',
|
||||
value: 1,
|
||||
payload: {
|
||||
name: '1234567890000_0',
|
||||
displayTimestamp: 1234567890000,
|
||||
value: 1,
|
||||
status: ContractExecutionStatus.Success,
|
||||
failed: 0,
|
||||
success: 1,
|
||||
aborted: 0,
|
||||
data: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { container } = render(<ContractExecutionChartTooltip {...props} />);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle Failed status', () => {
|
||||
const failedData = {
|
||||
...mockData,
|
||||
contractExecutionStatus: ContractExecutionStatus.Failed,
|
||||
};
|
||||
|
||||
const props: TooltipProps<string | number, string> = {
|
||||
...baseProps,
|
||||
payload: [
|
||||
{
|
||||
color: '#FF0000',
|
||||
dataKey: 'failed',
|
||||
value: 1,
|
||||
payload: {
|
||||
name: '1234567890000_0',
|
||||
displayTimestamp: 1234567890000,
|
||||
value: 1,
|
||||
status: ContractExecutionStatus.Failed,
|
||||
failed: 1,
|
||||
success: 0,
|
||||
aborted: 0,
|
||||
data: failedData,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(<ContractExecutionChartTooltip {...props} />);
|
||||
|
||||
expect(screen.getByText('Failed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle Aborted status', () => {
|
||||
const abortedData = {
|
||||
...mockData,
|
||||
contractExecutionStatus: ContractExecutionStatus.Aborted,
|
||||
};
|
||||
|
||||
const props: TooltipProps<string | number, string> = {
|
||||
...baseProps,
|
||||
payload: [
|
||||
{
|
||||
color: '#FFFF00',
|
||||
dataKey: 'aborted',
|
||||
value: 1,
|
||||
payload: {
|
||||
name: '1234567890000_0',
|
||||
displayTimestamp: 1234567890000,
|
||||
value: 1,
|
||||
status: ContractExecutionStatus.Aborted,
|
||||
failed: 0,
|
||||
success: 0,
|
||||
aborted: 1,
|
||||
data: abortedData,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(<ContractExecutionChartTooltip {...props} />);
|
||||
|
||||
expect(screen.getByText('Aborted')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should fallback to extracting timestamp from name if displayTimestamp is not available', () => {
|
||||
const props: TooltipProps<string | number, string> = {
|
||||
...baseProps,
|
||||
payload: [
|
||||
{
|
||||
color: '#00FF00',
|
||||
dataKey: 'success',
|
||||
value: 1,
|
||||
payload: {
|
||||
name: '9876543210000_5',
|
||||
displayTimestamp: undefined,
|
||||
value: 1,
|
||||
status: ContractExecutionStatus.Success,
|
||||
failed: 0,
|
||||
success: 1,
|
||||
aborted: 0,
|
||||
data: mockData,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(<ContractExecutionChartTooltip {...props} />);
|
||||
|
||||
expect(screen.getByText('Formatted: 9876543210000')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle undefined payload gracefully', () => {
|
||||
const props = {
|
||||
active: true,
|
||||
payload: undefined,
|
||||
} as unknown as TooltipProps<string | number, string>;
|
||||
|
||||
const { container } = render(<ContractExecutionChartTooltip {...props} />);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('should display the test-summary-tooltip-container', () => {
|
||||
render(<ContractExecutionChartTooltip {...baseProps} />);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('test-summary-tooltip-container')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
@ -26,10 +26,6 @@
|
||||
.contract-execution-chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
&.contract-execution-chart-less-width {
|
||||
width: 600px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.recharts-cartesian-axis-tick-line {
|
||||
|
@ -0,0 +1,463 @@
|
||||
/*
|
||||
* Copyright 2025 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Mock DateTimeUtils before any imports that might use it
|
||||
jest.mock('../date-time/DateTimeUtils', () => ({
|
||||
formatMonth: jest.fn((timestamp) => {
|
||||
const monthNames = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
];
|
||||
|
||||
return monthNames[new Date(timestamp).getMonth()];
|
||||
}),
|
||||
getCurrentMillis: jest.fn(() => 1640995200000),
|
||||
getEpochMillisForPastDays: jest.fn(
|
||||
(days) => 1640995200000 - days * 24 * 60 * 60 * 1000
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('js-yaml', () => ({
|
||||
dump: jest.fn((data) => JSON.stringify(data)),
|
||||
}));
|
||||
|
||||
jest.mock('../i18next/LocalUtil', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
t: jest.fn((key: string) => key),
|
||||
},
|
||||
t: jest.fn((key: string) => key),
|
||||
detectBrowserLanguage: jest.fn(() => 'en-US'),
|
||||
}));
|
||||
|
||||
// Import after mocks are set up
|
||||
import { ContractExecutionStatus } from '../../generated/type/contractExecutionStatus';
|
||||
import {
|
||||
createContractExecutionCustomScale,
|
||||
downloadContractYamlFile,
|
||||
formatContractExecutionTick,
|
||||
generateMonthTickPositions,
|
||||
generateSelectOptionsFromString,
|
||||
getConstraintStatus,
|
||||
getContractStatusLabelBasedOnFailedResult,
|
||||
getContractStatusType,
|
||||
getDataContractStatusIcon,
|
||||
getUpdatedContractDetails,
|
||||
processContractExecutionData,
|
||||
} from './DataContractUtils';
|
||||
|
||||
describe('DataContractUtils', () => {
|
||||
describe('processContractExecutionData', () => {
|
||||
it('should process execution data with unique identifiers', () => {
|
||||
const executionData = [
|
||||
{
|
||||
id: '1',
|
||||
timestamp: 1234567890000,
|
||||
contractExecutionStatus: ContractExecutionStatus.Success,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
timestamp: 1234567890000,
|
||||
contractExecutionStatus: ContractExecutionStatus.Failed,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
timestamp: 1234567891000,
|
||||
contractExecutionStatus: ContractExecutionStatus.Aborted,
|
||||
},
|
||||
];
|
||||
|
||||
const result = processContractExecutionData(executionData as any);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]).toEqual({
|
||||
name: '1234567890000_0',
|
||||
displayTimestamp: 1234567890000,
|
||||
value: 1,
|
||||
status: ContractExecutionStatus.Success,
|
||||
failed: 0,
|
||||
success: 1,
|
||||
aborted: 0,
|
||||
data: executionData[0],
|
||||
});
|
||||
expect(result[1]).toEqual({
|
||||
name: '1234567890000_1',
|
||||
displayTimestamp: 1234567890000,
|
||||
value: 1,
|
||||
status: ContractExecutionStatus.Failed,
|
||||
failed: 1,
|
||||
success: 0,
|
||||
aborted: 0,
|
||||
data: executionData[1],
|
||||
});
|
||||
expect(result[2]).toEqual({
|
||||
name: '1234567891000_2',
|
||||
displayTimestamp: 1234567891000,
|
||||
value: 1,
|
||||
status: ContractExecutionStatus.Aborted,
|
||||
failed: 0,
|
||||
success: 0,
|
||||
aborted: 1,
|
||||
data: executionData[2],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty execution data', () => {
|
||||
const result = processContractExecutionData([]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createContractExecutionCustomScale', () => {
|
||||
const mockData = [
|
||||
{ name: '1234567890000_0', displayTimestamp: 1234567890000 },
|
||||
{ name: '1234567890001_1', displayTimestamp: 1234567890001 },
|
||||
{ name: '1234567890002_2', displayTimestamp: 1234567890002 },
|
||||
];
|
||||
|
||||
it('should create a scale function that maps values to positions', () => {
|
||||
const scale = createContractExecutionCustomScale(mockData as any);
|
||||
|
||||
expect(scale('1234567890000_0')).toBe(0);
|
||||
expect(scale('1234567890001_1')).toBe(28); // 0 + 1 * (20 + 8)
|
||||
expect(scale('1234567890002_2')).toBe(56); // 0 + 2 * (20 + 8)
|
||||
});
|
||||
|
||||
it('should return 0 for unknown values', () => {
|
||||
const scale = createContractExecutionCustomScale(mockData as any);
|
||||
|
||||
expect(scale('unknown_value')).toBe(0);
|
||||
});
|
||||
|
||||
it('should have chainable domain method', () => {
|
||||
const scale = createContractExecutionCustomScale(mockData as any);
|
||||
const result = scale.domain();
|
||||
|
||||
expect(result).toEqual([
|
||||
'1234567890000_0',
|
||||
'1234567890001_1',
|
||||
'1234567890002_2',
|
||||
]);
|
||||
|
||||
const chained = scale.domain(['new_domain']);
|
||||
|
||||
expect(chained).toBe(scale);
|
||||
});
|
||||
|
||||
it('should have chainable range method', () => {
|
||||
const scale = createContractExecutionCustomScale(mockData as any);
|
||||
const result = scale.range();
|
||||
|
||||
expect(result).toEqual([0, 800]);
|
||||
|
||||
const chained = scale.range([0, 1000]);
|
||||
|
||||
expect(chained).toBe(scale);
|
||||
expect(scale.range()).toEqual([0, 1000]);
|
||||
});
|
||||
|
||||
it('should have other required scale methods', () => {
|
||||
const scale = createContractExecutionCustomScale(mockData as any);
|
||||
|
||||
expect(scale.bandwidth()).toBe(20);
|
||||
expect(scale.ticks()).toEqual([]);
|
||||
expect(scale.tickFormat()).toBeDefined();
|
||||
expect(scale.copy()).toBeDefined();
|
||||
expect(scale.nice()).toBe(scale);
|
||||
expect(scale.type).toBe('band');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateMonthTickPositions', () => {
|
||||
it('should generate unique month tick positions', () => {
|
||||
const processedData = [
|
||||
{ name: '1640995200000_0', displayTimestamp: 1640995200000 }, // Jan 2022
|
||||
{ name: '1640995260000_1', displayTimestamp: 1640995260000 }, // Same month
|
||||
{ name: '1643673600000_2', displayTimestamp: 1643673600000 }, // Feb 2022
|
||||
{ name: '1646092800000_3', displayTimestamp: 1646092800000 }, // Mar 2022
|
||||
];
|
||||
|
||||
const result = generateMonthTickPositions(processedData as any);
|
||||
|
||||
expect(result).toEqual([
|
||||
'1640995200000_0', // First occurrence of Jan
|
||||
'1643673600000_2', // First occurrence of Feb
|
||||
'1646092800000_3', // First occurrence of Mar
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle empty data', () => {
|
||||
const result = generateMonthTickPositions([]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle data with same month', () => {
|
||||
const processedData = [
|
||||
{ name: '1640995200000_0', displayTimestamp: 1640995200000 },
|
||||
{ name: '1640995260000_1', displayTimestamp: 1640995260000 },
|
||||
{ name: '1640995320000_2', displayTimestamp: 1640995320000 },
|
||||
];
|
||||
|
||||
const result = generateMonthTickPositions(processedData as any);
|
||||
|
||||
expect(result).toEqual(['1640995200000_0']); // Only first occurrence
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatContractExecutionTick', () => {
|
||||
it('should extract timestamp and format as month', () => {
|
||||
const result = formatContractExecutionTick('1640995200000_0');
|
||||
|
||||
expect(result).toBe('Jan');
|
||||
});
|
||||
|
||||
it('should handle different months', () => {
|
||||
expect(formatContractExecutionTick('1643673600000_0')).toBe('Feb');
|
||||
expect(formatContractExecutionTick('1646092800000_0')).toBe('Mar');
|
||||
});
|
||||
|
||||
it('should handle invalid timestamp gracefully', () => {
|
||||
const result = formatContractExecutionTick('invalid_0');
|
||||
|
||||
expect(result).toBeUndefined(); // formatMonth returns undefined for NaN
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContractStatusLabelBasedOnFailedResult', () => {
|
||||
it('should return passed when failed count is 0', () => {
|
||||
expect(getContractStatusLabelBasedOnFailedResult(0)).toBe('label.passed');
|
||||
});
|
||||
|
||||
it('should return failed when failed count is greater than 0', () => {
|
||||
expect(getContractStatusLabelBasedOnFailedResult(1)).toBe('label.failed');
|
||||
expect(getContractStatusLabelBasedOnFailedResult(10)).toBe(
|
||||
'label.failed'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle undefined gracefully', () => {
|
||||
expect(getContractStatusLabelBasedOnFailedResult(undefined)).toBe(
|
||||
'label.failed'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConstraintStatus', () => {
|
||||
it('should return status for all validations', () => {
|
||||
const latestContractResults = {
|
||||
schemaValidation: { failed: 0 },
|
||||
semanticsValidation: { failed: 1 },
|
||||
qualityValidation: { failed: 2 },
|
||||
};
|
||||
|
||||
const result = getConstraintStatus(latestContractResults as any);
|
||||
|
||||
expect(result).toEqual({
|
||||
schema: 'label.passed',
|
||||
semantic: 'label.failed',
|
||||
quality: 'label.failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle partial validations', () => {
|
||||
const latestContractResults = {
|
||||
schemaValidation: { failed: 0 },
|
||||
};
|
||||
|
||||
const result = getConstraintStatus(latestContractResults as any);
|
||||
|
||||
expect(result).toEqual({
|
||||
schema: 'label.passed',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty object when no validations', () => {
|
||||
const latestContractResults = {};
|
||||
|
||||
const result = getConstraintStatus(latestContractResults as any);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContractStatusType', () => {
|
||||
it('should return correct status types', () => {
|
||||
expect(getContractStatusType('passed')).toBe('success');
|
||||
expect(getContractStatusType('success')).toBe('success');
|
||||
expect(getContractStatusType('failed')).toBe('failure');
|
||||
expect(getContractStatusType('issue')).toBe('warning');
|
||||
expect(getContractStatusType('warning')).toBe('warning');
|
||||
expect(getContractStatusType('unknown')).toBe('pending');
|
||||
expect(getContractStatusType('')).toBe('pending');
|
||||
});
|
||||
|
||||
it('should be case insensitive', () => {
|
||||
expect(getContractStatusType('PASSED')).toBe('success');
|
||||
expect(getContractStatusType('Failed')).toBe('failure');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUpdatedContractDetails', () => {
|
||||
it('should merge form values with contract and omit specific fields', () => {
|
||||
const contract = {
|
||||
id: '123',
|
||||
name: 'Original Name',
|
||||
description: 'Original Description',
|
||||
fullyQualifiedName: 'fqn',
|
||||
version: '1.0',
|
||||
updatedAt: 1234567890,
|
||||
updatedBy: 'user',
|
||||
testSuite: 'suite',
|
||||
deleted: false,
|
||||
changeDescription: 'change',
|
||||
latestResult: 'result',
|
||||
incrementalChangeDescription: 'incremental',
|
||||
otherField: 'value',
|
||||
};
|
||||
|
||||
const formValues = {
|
||||
name: 'New Name',
|
||||
description: 'New Description',
|
||||
newField: 'newValue',
|
||||
};
|
||||
|
||||
const result = getUpdatedContractDetails(
|
||||
contract as any,
|
||||
formValues as any
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
name: 'Original Name', // Name is preserved
|
||||
description: 'New Description', // Description is updated
|
||||
otherField: 'value',
|
||||
newField: 'newValue',
|
||||
});
|
||||
|
||||
expect(result).not.toHaveProperty('id');
|
||||
expect(result).not.toHaveProperty('fullyQualifiedName');
|
||||
expect(result).not.toHaveProperty('version');
|
||||
expect(result).not.toHaveProperty('updatedAt');
|
||||
expect(result).not.toHaveProperty('updatedBy');
|
||||
expect(result).not.toHaveProperty('testSuite');
|
||||
expect(result).not.toHaveProperty('deleted');
|
||||
expect(result).not.toHaveProperty('changeDescription');
|
||||
expect(result).not.toHaveProperty('latestResult');
|
||||
expect(result).not.toHaveProperty('incrementalChangeDescription');
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadContractYamlFile', () => {
|
||||
let createElementSpy: jest.SpyInstance;
|
||||
let appendChildSpy: jest.SpyInstance;
|
||||
let removeChildSpy: jest.SpyInstance;
|
||||
let clickSpy: jest.Mock;
|
||||
let createObjectURLSpy: jest.SpyInstance;
|
||||
let revokeObjectURLSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
clickSpy = jest.fn();
|
||||
const mockElement = {
|
||||
textContent: '',
|
||||
href: '',
|
||||
download: '',
|
||||
click: clickSpy,
|
||||
};
|
||||
|
||||
createElementSpy = jest
|
||||
.spyOn(document, 'createElement')
|
||||
.mockReturnValue(mockElement as any);
|
||||
appendChildSpy = jest
|
||||
.spyOn(document.body, 'appendChild')
|
||||
.mockImplementation(() => mockElement as any);
|
||||
removeChildSpy = jest
|
||||
.spyOn(document.body, 'removeChild')
|
||||
.mockImplementation(() => mockElement as any);
|
||||
// Mock URL methods on global object
|
||||
global.URL = {
|
||||
createObjectURL: jest.fn(() => 'blob:url'),
|
||||
revokeObjectURL: jest.fn(),
|
||||
} as any;
|
||||
createObjectURLSpy = global.URL.createObjectURL as jest.Mock;
|
||||
revokeObjectURLSpy = global.URL.revokeObjectURL as jest.Mock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should download contract as YAML file', () => {
|
||||
const contract = {
|
||||
id: '123',
|
||||
name: 'test-contract',
|
||||
description: 'Test Contract',
|
||||
};
|
||||
|
||||
downloadContractYamlFile(contract as any);
|
||||
|
||||
expect(createElementSpy).toHaveBeenCalledWith('a');
|
||||
expect(appendChildSpy).toHaveBeenCalled();
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
expect(removeChildSpy).toHaveBeenCalled();
|
||||
expect(createObjectURLSpy).toHaveBeenCalled();
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDataContractStatusIcon', () => {
|
||||
it('should return correct icon for each status', () => {
|
||||
expect(
|
||||
getDataContractStatusIcon(ContractExecutionStatus.Failed)
|
||||
).toBeDefined();
|
||||
expect(
|
||||
getDataContractStatusIcon(ContractExecutionStatus.Aborted)
|
||||
).toBeDefined();
|
||||
expect(
|
||||
getDataContractStatusIcon(ContractExecutionStatus.Running)
|
||||
).toBeDefined();
|
||||
expect(
|
||||
getDataContractStatusIcon(ContractExecutionStatus.Success)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateSelectOptionsFromString', () => {
|
||||
it('should convert string array to select options', () => {
|
||||
const input = ['hour', 'day', 'week'];
|
||||
const result = generateSelectOptionsFromString(input);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ label: 'label.hour', value: 'hour' },
|
||||
{ label: 'label.day', value: 'day' },
|
||||
{ label: 'label.week', value: 'week' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
const result = generateSelectOptionsFromString([]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
@ -11,11 +11,12 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import yaml from 'js-yaml';
|
||||
import { isEmpty, omit } from 'lodash';
|
||||
import { omit } from 'lodash';
|
||||
import { ReactComponent as ContractAbortedIcon } from '../../assets/svg/ic-contract-aborted.svg';
|
||||
import { ReactComponent as ContractFailedIcon } from '../../assets/svg/ic-contract-failed.svg';
|
||||
import { ReactComponent as ContractRunningIcon } from '../../assets/svg/ic-contract-running.svg';
|
||||
import { StatusType } from '../../components/common/StatusBadge/StatusBadge.interface';
|
||||
import { DataContractProcessedResultCharts } from '../../components/DataContract/ContractExecutionChart/ContractExecutionChart.interface';
|
||||
import { SEMANTIC_OPERATORS } from '../../constants/DataContract.constants';
|
||||
import { EntityReferenceFields } from '../../enums/AdvancedSearch.enum';
|
||||
import { SearchIndex } from '../../enums/search.enum';
|
||||
@ -219,31 +220,101 @@ export const getSematicRuleFields = () => {
|
||||
return allFields;
|
||||
};
|
||||
|
||||
// Create month ticks at regular intervals
|
||||
export const getContractExecutionMonthTicks = (
|
||||
data: {
|
||||
name: number;
|
||||
failed: number;
|
||||
success: number;
|
||||
aborted: number;
|
||||
}[]
|
||||
export const processContractExecutionData = (
|
||||
executionData: DataContractResult[]
|
||||
): DataContractProcessedResultCharts[] => {
|
||||
return executionData.map((item, index) => {
|
||||
// Add a unique identifier to distinguish items with same timestamp
|
||||
const uniqueName = `${item.timestamp}_${index}`;
|
||||
const status = item.contractExecutionStatus;
|
||||
|
||||
return {
|
||||
name: uniqueName, // Use unique identifier for positioning
|
||||
displayTimestamp: item.timestamp, // Keep original timestamp for display
|
||||
value: 1, // Always 1 for the bar height
|
||||
status: status, // Store status for color determination
|
||||
failed: status === ContractExecutionStatus.Failed ? 1 : 0,
|
||||
success: status === ContractExecutionStatus.Success ? 1 : 0,
|
||||
aborted: status === ContractExecutionStatus.Aborted ? 1 : 0,
|
||||
data: item,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Create custom scale function for positioning bars from left
|
||||
export const createContractExecutionCustomScale = (
|
||||
data: DataContractProcessedResultCharts[]
|
||||
) => {
|
||||
if (isEmpty(data)) {
|
||||
return [];
|
||||
}
|
||||
const domainValues = data.map((d) => d.name);
|
||||
let rangeValues = [0, 800];
|
||||
|
||||
// Group data by month and find the first occurrence of each month
|
||||
const monthMap = new Map<string, number>();
|
||||
const scale = (value: string) => {
|
||||
const index = data.findIndex((item) => item.name === value);
|
||||
if (index === -1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
data.forEach((item) => {
|
||||
const month = formatMonth(item.name);
|
||||
// Only add if we haven't seen this month before (keeps the earliest timestamp)
|
||||
if (!monthMap.has(month)) {
|
||||
monthMap.set(month, item.name);
|
||||
// Calculate position starting from the left edge
|
||||
const maxBarWidth = 20; // Wider bars for better visibility
|
||||
const spacing = 8; // More spacing between bars
|
||||
const position = rangeValues[0] + index * (maxBarWidth + spacing);
|
||||
|
||||
return position;
|
||||
};
|
||||
|
||||
// Implement chainable methods like d3-scale
|
||||
scale.domain = (domain?: string[]) => {
|
||||
if (domain === undefined) {
|
||||
return domainValues;
|
||||
}
|
||||
|
||||
return scale;
|
||||
};
|
||||
|
||||
scale.range = (range?: number[]) => {
|
||||
if (range === undefined) {
|
||||
return rangeValues;
|
||||
}
|
||||
rangeValues = range;
|
||||
|
||||
return scale;
|
||||
};
|
||||
|
||||
scale.ticks = () => [];
|
||||
scale.tickFormat = () => formatMonth;
|
||||
scale.bandwidth = () => 20; // Match the maxBarWidth
|
||||
scale.copy = () => createContractExecutionCustomScale(data);
|
||||
scale.nice = () => scale;
|
||||
scale.type = 'band';
|
||||
|
||||
return scale;
|
||||
};
|
||||
|
||||
// Generate tick positions for month labels
|
||||
export const generateMonthTickPositions = (
|
||||
processedData: DataContractProcessedResultCharts[]
|
||||
) => {
|
||||
const uniqueMonths = new Set();
|
||||
const tickPositions: string[] = [];
|
||||
|
||||
processedData.forEach((item) => {
|
||||
const monthKey = new Date(item.displayTimestamp).toISOString().slice(0, 7); // YYYY-MM format
|
||||
if (!uniqueMonths.has(monthKey)) {
|
||||
uniqueMonths.add(monthKey);
|
||||
// Use the first occurrence of each month as the tick position
|
||||
tickPositions.push(item.name); // Use the unique name for the tick
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(monthMap.values());
|
||||
return tickPositions;
|
||||
};
|
||||
|
||||
// Format tick value for month display
|
||||
export const formatContractExecutionTick = (value: string) => {
|
||||
// Extract timestamp from the unique name (format: timestamp_index)
|
||||
const timestamp = value.split('_')[0];
|
||||
|
||||
return formatMonth(Number(timestamp));
|
||||
};
|
||||
|
||||
// Utility function to convert string to options array for Ant Design Select
|
||||
|
Loading…
x
Reference in New Issue
Block a user