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