diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractExecutionChart/ContractExecutionChart.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractExecutionChart/ContractExecutionChart.component.tsx
index 84db1ab6758..ff39824ce26 100644
--- a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractExecutionChart/ContractExecutionChart.component.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractExecutionChart/ContractExecutionChart.component.tsx
@@ -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 ? (
) : (
-
+
{
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractExecutionChart/ContractExecutionChart.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractExecutionChart/ContractExecutionChart.interface.ts
new file mode 100644
index 00000000000..521181654ce
--- /dev/null
+++ b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractExecutionChart/ContractExecutionChart.interface.ts
@@ -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;
+}
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractExecutionChart/ContractExecutionChart.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractExecutionChart/ContractExecutionChart.test.tsx
index 509ae49b920..ae8b8630a0f 100644
--- a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractExecutionChart/ContractExecutionChart.test.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractExecutionChart/ContractExecutionChart.test.tsx
@@ -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();
+ // 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();
+
+ await waitFor(() => {
+ expect(processContractExecutionData).toHaveBeenCalledWith(
+ mockContractResults
+ );
+ });
+ });
+
+ it('should call createContractExecutionCustomScale with processed data', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(createContractExecutionCustomScale).toHaveBeenCalled();
+ });
+ });
+
+ it('should call generateMonthTickPositions with processed data', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(generateMonthTickPositions).toHaveBeenCalled();
+ });
+ });
+
+ it('should use formatContractExecutionTick for tick formatting', async () => {
+ render();
+
+ 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();
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractExecutionChart/ContractExecutionChartTooltip.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractExecutionChart/ContractExecutionChartTooltip.component.tsx
index 009236aa2d3..6f0e2e86ec1 100644
--- a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractExecutionChart/ContractExecutionChartTooltip.component.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractExecutionChart/ContractExecutionChartTooltip.component.tsx
@@ -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 (
- {formatDateTimeLong(payload[0].payload.name)}
+ {formatDateTimeLong(timestamp)}
}>
{t('label.contract-execution-status')}
- {data.contractExecutionStatus}
+ {data?.contractExecutionStatus}
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractExecutionChart/ContractExecutionChartTooltip.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractExecutionChart/ContractExecutionChartTooltip.test.tsx
new file mode 100644
index 00000000000..db31b064e42
--- /dev/null
+++ b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractExecutionChart/ContractExecutionChartTooltip.test.tsx
@@ -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 = {
+ 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();
+
+ 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();
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('should not render when payload is empty', () => {
+ const props = {
+ ...baseProps,
+ payload: [],
+ };
+
+ const { container } = render();
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('should not render when data is null', () => {
+ const props: TooltipProps = {
+ ...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();
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('should handle Failed status', () => {
+ const failedData = {
+ ...mockData,
+ contractExecutionStatus: ContractExecutionStatus.Failed,
+ };
+
+ const props: TooltipProps = {
+ ...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();
+
+ expect(screen.getByText('Failed')).toBeInTheDocument();
+ });
+
+ it('should handle Aborted status', () => {
+ const abortedData = {
+ ...mockData,
+ contractExecutionStatus: ContractExecutionStatus.Aborted,
+ };
+
+ const props: TooltipProps = {
+ ...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();
+
+ expect(screen.getByText('Aborted')).toBeInTheDocument();
+ });
+
+ it('should fallback to extracting timestamp from name if displayTimestamp is not available', () => {
+ const props: TooltipProps = {
+ ...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();
+
+ expect(screen.getByText('Formatted: 9876543210000')).toBeInTheDocument();
+ });
+
+ it('should handle undefined payload gracefully', () => {
+ const props = {
+ active: true,
+ payload: undefined,
+ } as unknown as TooltipProps;
+
+ const { container } = render();
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('should display the test-summary-tooltip-container', () => {
+ render();
+
+ expect(
+ screen.getByTestId('test-summary-tooltip-container')
+ ).toBeInTheDocument();
+ });
+});
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractExecutionChart/contract-execution-chart.less b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractExecutionChart/contract-execution-chart.less
index cd61d0ca68d..f18cb47d6ab 100644
--- a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractExecutionChart/contract-execution-chart.less
+++ b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractExecutionChart/contract-execution-chart.less
@@ -26,10 +26,6 @@
.contract-execution-chart {
width: 100%;
height: 100%;
-
- &.contract-execution-chart-less-width {
- width: 600px !important;
- }
}
.recharts-cartesian-axis-tick-line {
diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/DataContract/DataContractUtils.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/DataContract/DataContractUtils.test.ts
new file mode 100644
index 00000000000..14bb5042165
--- /dev/null
+++ b/openmetadata-ui/src/main/resources/ui/src/utils/DataContract/DataContractUtils.test.ts
@@ -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([]);
+ });
+ });
+});
diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/DataContract/DataContractUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/DataContract/DataContractUtils.ts
index 19fedd50d92..66c0d39f139 100644
--- a/openmetadata-ui/src/main/resources/ui/src/utils/DataContract/DataContractUtils.ts
+++ b/openmetadata-ui/src/main/resources/ui/src/utils/DataContract/DataContractUtils.ts
@@ -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();
+ 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