From 999af800b36cda008d64d54717f6100e38202bc3 Mon Sep 17 00:00:00 2001 From: Ashish Gupta Date: Fri, 5 Sep 2025 10:21:13 +0530 Subject: [PATCH] fix contract status button not visible in persona having contract tab (#23237) * fix contract status button not visible in persona having contract tab * supported playwright test for the same * fix the unit test failing --- .../e2e/Pages/DataContracts.spec.ts | 36 ++- .../DataAssetsHeader.component.tsx | 6 +- .../DataAssetsHeader.test.tsx | 239 +++++++++++++++++- .../ui/src/mocks/DataContract.mock.ts | 125 +++++++++ 4 files changed, 396 insertions(+), 10 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/mocks/DataContract.mock.ts diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataContracts.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataContracts.spec.ts index 5ce33233bc0..c192566fad6 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataContracts.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataContracts.spec.ts @@ -54,7 +54,6 @@ const test = base.extend<{ page: Page }>({ test.describe('Data Contracts', () => { const table = new TableClass(); - const table2 = new TableClass(); const testClassification = new ClassificationClass(); const testTag = new TagClass({ classification: testClassification.data.name, @@ -68,7 +67,6 @@ test.describe('Data Contracts', () => { const { apiContext, afterAction } = await performAdminLogin(browser); await table.create(apiContext); - await table2.create(apiContext); await testClassification.create(apiContext); await testTag.create(apiContext); await testGlossary.create(apiContext); @@ -111,7 +109,6 @@ test.describe('Data Contracts', () => { const { apiContext, afterAction } = await performAdminLogin(browser); await table.delete(apiContext); - await table2.delete(apiContext); await testClassification.delete(apiContext); await testTag.delete(apiContext); await testGlossary.delete(apiContext); @@ -541,7 +538,7 @@ test.describe('Data Contracts', () => { }); }); - test('Contract Status badge should not be visible if Contract Tab is hidden by Person', async ({ + test('Contract Status badge should be visible on condition if Contract Tab is present/hidden by Persona', async ({ page, }) => { test.slow(true); @@ -549,7 +546,7 @@ test.describe('Data Contracts', () => { await test.step( 'Create Data Contract in Table and validate it fails', async () => { - await table2.visitEntityPage(page); + await table.visitEntityPage(page); // Open contract section and start adding contract await page.click('[data-testid="contract"]'); @@ -679,6 +676,35 @@ test.describe('Data Contracts', () => { await personaResponse; }); + await test.step( + 'Verify Contract tab and status badge are visible if persona is set', + async () => { + await redirectToHomePage(page); + await table.visitEntityPage(page); + await page.waitForLoadState('networkidle'); + await page.waitForSelector('[data-testid="loader"]', { + state: 'detached', + }); + + // Verify Contract tab is not visible (should be hidden by persona customization) + await expect(page.getByTestId('contract')).toBeVisible(); + + // Verify Contract status badge is not visible in header + await expect( + page.getByTestId('data-contract-latest-result-btn') + ).toBeVisible(); + + // Additional verification: Check that other tabs are still visible + await expect(page.getByTestId('schema')).toBeVisible(); + await expect(page.getByTestId('activity_feed')).toBeVisible(); + await expect(page.getByTestId('sample_data')).toBeVisible(); + await expect(page.getByTestId('table_queries')).toBeVisible(); + await expect(page.getByTestId('profiler')).toBeVisible(); + await expect(page.getByTestId('lineage')).toBeVisible(); + await expect(page.getByTestId('custom_properties')).toBeVisible(); + } + ); + await test.step('Customize Table page to hide Contract tab', async () => { await settingClick(page, GlobalSettingOptions.PERSONA); await page.waitForLoadState('networkidle'); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.component.tsx index 455f76c3b8d..91c9a918166 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.component.tsx @@ -429,14 +429,14 @@ export const DataAssetsHeader = ({ ]); const dataContractLatestResultButton = useMemo(() => { - const entityContainContractTab = - isUndefined(customizedPage?.tabs) ?? + const entityContainContractTabVisible = + isUndefined(customizedPage?.tabs) || Boolean( customizedPage?.tabs?.find((item) => item.id === EntityTabs.CONTRACT) ); if ( - entityContainContractTab && + entityContainContractTabVisible && dataContract?.latestResult?.status && [ ContractExecutionStatus.Aborted, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.test.tsx index 3c6eb242fa8..1eefa0fb096 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.test.tsx @@ -12,21 +12,26 @@ */ import { act, fireEvent, render, screen } from '@testing-library/react'; import { AUTO_PILOT_APP_NAME } from '../../../constants/Applications.constant'; -import { EntityType } from '../../../enums/entity.enum'; +import { EntityTabs, EntityType } from '../../../enums/entity.enum'; import { ServiceCategory } from '../../../enums/service.enum'; import { Container, StorageServiceType, } from '../../../generated/entity/data/container'; +import { ContractExecutionStatus } from '../../../generated/entity/data/dataContract'; import { DatabaseServiceType } from '../../../generated/entity/services/databaseService'; import { LabelType, State, TagSource } from '../../../generated/tests/testCase'; import { AssetCertification } from '../../../generated/type/assetCertification'; +import { useCustomPages } from '../../../hooks/useCustomPages'; +import { MOCK_DATA_CONTRACT } from '../../../mocks/DataContract.mock'; import { MOCK_TIER_DATA } from '../../../mocks/TableData.mock'; import { triggerOnDemandApp } from '../../../rest/applicationAPI'; import { getDataQualityLineage } from '../../../rest/lineageAPI'; import { getContainerByName } from '../../../rest/storageAPI'; import { ExtraInfoLink } from '../../../utils/DataAssetsHeader.utils'; +import { getDataContractStatusIcon } from '../../../utils/DataContract/DataContractUtils'; import { DEFAULT_ENTITY_PERMISSION } from '../../../utils/PermissionsUtils'; +import { getEntityDetailsPath } from '../../../utils/RouterUtils'; import { useRequiredParams } from '../../../utils/useRequiredParams'; import { DataAssetsHeader } from './DataAssetsHeader.component'; import { DataAssetsHeaderProps } from './DataAssetsHeader.interface'; @@ -58,9 +63,11 @@ const mockProps: DataAssetsHeaderProps = { onOwnerUpdate: jest.fn(), }; +const mockNavigate = jest.fn(); + jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), - useNavigate: jest.fn().mockReturnValue(jest.fn()), + useNavigate: jest.fn().mockImplementation(() => mockNavigate), })); jest.mock('../../../utils/useRequiredParams', () => ({ @@ -179,6 +186,18 @@ jest.mock('../../../rest/lineageAPI', () => ({ getDataQualityLineage: jest.fn(), })); +jest.mock('../../../utils/DataContract/DataContractUtils', () => ({ + getDataContractStatusIcon: jest.fn(), +})); + +jest.mock('../../../hooks/useCustomPages', () => ({ + useCustomPages: jest.fn().mockReturnValue({ customizedPage: null }), +})); + +jest.mock('../../../utils/RouterUtils', () => ({ + getEntityDetailsPath: jest.fn(), +})); + describe('ExtraInfoLink component', () => { const mockProps = { label: 'myLabel', @@ -475,4 +494,220 @@ describe('DataAssetsHeader component', () => { expect(button).toBeEnabled(); }); + + describe('dataContractLatestResultButton', () => { + const mockGetDataContractStatusIcon = + getDataContractStatusIcon as jest.Mock; + const mockUseCustomPages = useCustomPages as jest.Mock; + const mockGetEntityDetailsPath = getEntityDetailsPath as jest.Mock; + + it('should render data contract button when contract tab is visible and status is in allowed list', () => { + mockUseCustomPages.mockReturnValue({ + customizedPage: { + tabs: [{ id: EntityTabs.CONTRACT }], + }, + }); + + render( + + ); + + const button = screen.getByTestId('data-contract-latest-result-btn'); + + expect(button).toBeInTheDocument(); + expect(button).toHaveClass('data-contract-latest-result-button'); + expect(button).toHaveClass('failed'); + }); + + it('should render data contract button when customizedPage tabs is undefined', () => { + mockUseCustomPages.mockReturnValue({ + customizedPage: { + tabs: undefined, + }, + }); + + const mockDataContract = MOCK_DATA_CONTRACT; + + render( + + ); + + expect( + screen.getByTestId('data-contract-latest-result-btn') + ).toBeInTheDocument(); + }); + + it('should navigate to contract tab when button is clicked', () => { + mockUseCustomPages.mockReturnValue({ + customizedPage: { + tabs: [{ id: EntityTabs.CONTRACT }], + }, + }); + + const mockDataContract = { + ...MOCK_DATA_CONTRACT, + latestResult: { + status: ContractExecutionStatus.Running, + }, + }; + + render( + + ); + + const button = screen.getByTestId('data-contract-latest-result-btn'); + fireEvent.click(button); + + expect(mockGetEntityDetailsPath).toHaveBeenCalledWith( + EntityType.CONTAINER, + 'fullyQualifiedName', + EntityTabs.CONTRACT + ); + expect(mockNavigate).toHaveBeenCalled(); + }); + + it('should not render data contract button when contract tab is not visible', () => { + mockUseCustomPages.mockReturnValue({ + customizedPage: { + tabs: [{ id: EntityTabs.ACTIVITY_FEED }], + }, + }); + + render( + + ); + + expect( + screen.queryByTestId('data-contract-latest-result-btn') + ).not.toBeInTheDocument(); + }); + + it('should not render data contract button when status is not in allowed list', () => { + mockUseCustomPages.mockReturnValue({ + customizedPage: { + tabs: [{ id: EntityTabs.CONTRACT }], + }, + }); + + const mockDataContract = { + ...MOCK_DATA_CONTRACT, + latestResult: { + status: ContractExecutionStatus.Success, + }, + }; + + render( + + ); + + expect( + screen.queryByTestId('data-contract-latest-result-btn') + ).not.toBeInTheDocument(); + }); + + it('should not render data contract button when dataContract is undefined', () => { + mockUseCustomPages.mockReturnValue({ + customizedPage: { + tabs: [{ id: EntityTabs.CONTRACT }], + }, + }); + + render(); + + expect( + screen.queryByTestId('data-contract-latest-result-btn') + ).not.toBeInTheDocument(); + }); + + it('should not render data contract button when latestResult is undefined', () => { + mockUseCustomPages.mockReturnValue({ + customizedPage: { + tabs: [{ id: EntityTabs.CONTRACT }], + }, + }); + + const mockDataContract = { + ...MOCK_DATA_CONTRACT, + latestResult: undefined, + }; + + render( + + ); + + expect( + screen.queryByTestId('data-contract-latest-result-btn') + ).not.toBeInTheDocument(); + }); + + it('should render button with correct class names for each status', () => { + mockUseCustomPages.mockReturnValue({ + customizedPage: { tabs: [{ id: EntityTabs.CONTRACT }] }, + }); + + const statuses = [ + ContractExecutionStatus.Failed, + ContractExecutionStatus.Aborted, + ContractExecutionStatus.Running, + ]; + + statuses.forEach((status) => { + const { unmount } = render( + + ); + + const button = screen.getByTestId('data-contract-latest-result-btn'); + + expect(button).toHaveClass(`data-contract-latest-result-button`); + expect(button).toHaveClass(status.toLowerCase()); + + unmount(); + }); + }); + + it('should render button with icon when getDataContractStatusIcon returns an icon', () => { + mockUseCustomPages.mockReturnValue({ + customizedPage: { tabs: [{ id: EntityTabs.CONTRACT }] }, + }); + mockGetDataContractStatusIcon.mockReturnValue('TestIcon'); + + render( + + ); + + const button = screen.getByTestId('data-contract-latest-result-btn'); + + expect(button.querySelector('.anticon')).toBeInTheDocument(); + }); + + it('should render button without icon when getDataContractStatusIcon returns null', () => { + mockUseCustomPages.mockReturnValue({ + customizedPage: { tabs: [{ id: EntityTabs.CONTRACT }] }, + }); + mockGetDataContractStatusIcon.mockReturnValue(null); + + render( + + ); + + const button = screen.getByTestId('data-contract-latest-result-btn'); + + expect(button.querySelector('.anticon')).not.toBeInTheDocument(); + }); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/mocks/DataContract.mock.ts b/openmetadata-ui/src/main/resources/ui/src/mocks/DataContract.mock.ts new file mode 100644 index 00000000000..342b6454f0b --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/mocks/DataContract.mock.ts @@ -0,0 +1,125 @@ +/* + * 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 { + Constraint, + DataType, + LabelType, + State, +} from '../generated/entity/data/table'; +import { ContractExecutionStatus } from '../generated/type/contractExecutionStatus'; +import { TagSource } from '../generated/type/tagLabel'; + +export const MOCK_DATA_CONTRACT = { + id: 'bf7b3a0d-6a85-4dd6-95e3-243a1769d1a9', + name: 'Customer 360', + fullyQualifiedName: + 'redshift prod.dev.dbt_jaffle.customers.dataContract_Customer 360', + description: 'Customer 360 Data Contract ', + version: 0.2, + updatedAt: 1755860378527, + updatedBy: 'joseph', + status: 'Active', + entity: { + id: 'ee9d44a0-815d-4ac9-8422-4f9d02ddf04d', + type: 'table', + href: 'https://demo.getcollate.io/v1/tables/ee9d44a0-815d-4ac9-8422-4f9d02ddf04d', + }, + testSuite: { + id: '24859b7c-a2ef-4e0e-b3b7-67a61ed14bc9', + type: 'testSuite', + fullyQualifiedName: 'bf7b3a0d-6a85-4dd6-95e3-243a1769d1a9', + }, + schema: [ + { + name: 'customer_id', + dataType: DataType.Array, + dataLength: 1, + dataTypeDisplay: 'integer', + description: 'Unique identifier for each customer', + fullyQualifiedName: 'redshift prod.dev.dbt_jaffle.customers.customer_id', + tags: [ + { + tagFQN: 'PII.NonSensitive', + name: 'NonSensitive', + displayName: 'Non Sensitive ', + description: + 'PII which is easily accessible from public sources and can include zip code, race, gender, and date of birth.', + style: {}, + source: TagSource.Classification, + labelType: LabelType.Automated, + state: State.Suggested, + }, + ], + constraint: Constraint.Null, + children: [], + }, + ], + semantics: [ + { + name: 'Tiering Elements', + description: '', + // eslint-disable-next-line max-len + rule: '{"and":[{"some":[{"var":"owners"},{"==":[{"var":"fullyQualifiedName"},"customer team"]}]},{"some":[{"var":"dataProduct"},{"==":[{"var":"fullyQualifiedName"},"C360"]}]},{"==":[{"var":"tier.tagFQN"},"Tier.Tier1"]}]}', + enabled: true, + ignoredEntities: [], + }, + ], + qualityExpectations: [ + { + id: 'f496f0d9-58c3-4a0e-a836-2479b457c68e', + type: 'testCase', + name: 'CLV Must be Positive', + description: + '

The customer lifetime value must always be greater or equal to zero

', + }, + { + id: 'e57ffd73-b8f1-4f5f-91b1-7ebe614dc26e', + type: 'testCase', + name: 'Customer ID To Be Unique', + }, + { + id: '484e016e-ed87-4f6c-ae77-7d5b16f07ad0', + type: 'testCase', + name: 'Table Row Count To Equal', + }, + ], + reviewers: [], + changeDescription: { + fieldsAdded: [ + { + name: 'latestResult', + newValue: { + status: ContractExecutionStatus.Failed, + resultId: '04551202-c2b9-4d7b-aecf-6c9c8fe22c1c', + timestamp: 1756944000095, + }, + }, + ], + fieldsUpdated: [], + fieldsDeleted: [], + previousVersion: 0.1, + changeSummary: {}, + }, + incrementalChangeDescription: { + fieldsAdded: [], + fieldsUpdated: [], + fieldsDeleted: [], + previousVersion: 0.2, + }, + deleted: false, + latestResult: { + timestamp: 1756944000095, + status: ContractExecutionStatus.Failed, + resultId: '04551202-c2b9-4d7b-aecf-6c9c8fe22c1c', + }, +};