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:
Ashish Gupta 2025-09-29 11:00:02 +05:30 committed by GitHub
parent ed71f3629a
commit 77cfe7849b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 950 additions and 66 deletions

View File

@ -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}
/>

View File

@ -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;
}

View File

@ -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} />);

View File

@ -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>

View File

@ -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();
});
});

View File

@ -26,10 +26,6 @@
.contract-execution-chart {
width: 100%;
height: 100%;
&.contract-execution-chart-less-width {
width: 600px !important;
}
}
.recharts-cartesian-axis-tick-line {

View File

@ -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([]);
});
});
});

View File

@ -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