diff --git a/docker/run_local_docker.sh b/docker/run_local_docker.sh index 183b2f491e3..b220df798ca 100755 --- a/docker/run_local_docker.sh +++ b/docker/run_local_docker.sh @@ -10,6 +10,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +RED='\033[0;31m' + cd "$(dirname "${BASH_SOURCE[0]}")" || exit helpFunction() @@ -116,6 +118,13 @@ until curl -s -f "http://localhost:9200/_cat/indices/openmetadata_team_search_in done until curl -s -f --header 'Authorization: Basic YWRtaW46YWRtaW4=' "http://localhost:8080/api/v1/dags/sample_data"; do + IMPORT_ERRORS="$(curl -s --header 'Authorization: Basic YWRtaW46YWRtaW4=' "http://localhost:8080/api/v1/importErrors")" + echo $IMPORT_ERRORS | grep "/airflow_sample_data.py" > /dev/null; + if [[ "$?" == "0" ]]; then + echo -e "${RED}Airflow found an error importing \`sample_data\` DAG" + printf '%s' "$IMPORT_ERRORS" | jq '.["import_errors"] | .[] | select(.filename | endswith("airflow_sample_data.py"))' + exit 1 + fi echo 'Checking if Sample Data DAG is reachable...\n' sleep 5 done diff --git a/ingestion/src/metadata/ingestion/source/database/databricks/client.py b/ingestion/src/metadata/ingestion/source/database/databricks/client.py index 2462de40ddd..f0fc17f092c 100644 --- a/ingestion/src/metadata/ingestion/source/database/databricks/client.py +++ b/ingestion/src/metadata/ingestion/source/database/databricks/client.py @@ -72,7 +72,9 @@ class DatabricksClient: "Content-Type": "application/json", } self.api_timeout = self.config.connectionTimeout or 120 + self._job_table_lineage_executed: bool = False self.job_table_lineage: dict[str, list[dict[str, str]]] = {} + self._job_column_lineage_executed: bool = False self.job_column_lineage: dict[ str, dict[Tuple[str, str], list[Tuple[str, str]]] ] = {} @@ -193,7 +195,6 @@ class DatabricksClient: """ Method returns List all the created jobs in a Databricks Workspace """ - self.cache_lineage() try: iteration_count = 1 data = {"limit": PAGE_SIZE, "expand_tasks": True, "offset": 0} @@ -266,6 +267,10 @@ class DatabricksClient: Method returns table lineage for a job by the specified job_id """ try: + if not self._job_table_lineage_executed: + logger.debug("Executing cache_lineage...") + self.cache_lineage() + return self.job_table_lineage.get(str(job_id)) except Exception as exc: logger.debug( @@ -281,6 +286,10 @@ class DatabricksClient: Method returns column lineage for a job by the specified job_id and table key """ try: + if not self._job_column_lineage_executed: + logger.debug("Job column lineage not found. Executing cache_lineage...") + self.cache_lineage() + return self.job_column_lineage.get(str(job_id), {}).get(TableKey) except Exception as exc: logger.debug( @@ -325,6 +334,7 @@ class DatabricksClient: f"Error parsing row: {row} due to {traceback.format_exc()}" ) continue + self._job_table_lineage_executed = True # Not every job has column lineage, so we need to check if the job exists in the column_lineage table # we will cache the column lineage for jobs that have column lineage @@ -355,3 +365,5 @@ class DatabricksClient: f"Error parsing row: {row} due to {traceback.format_exc()}" ) continue + self._job_column_lineage_executed = True + logger.debug("Table and column lineage caching completed.") diff --git a/ingestion/src/metadata/ingestion/source/database/databricks/queries.py b/ingestion/src/metadata/ingestion/source/database/databricks/queries.py index 80b86819f70..ad29712d1b7 100644 --- a/ingestion/src/metadata/ingestion/source/database/databricks/queries.py +++ b/ingestion/src/metadata/ingestion/source/database/databricks/queries.py @@ -96,6 +96,7 @@ DATABRICKS_GET_TABLE_LINEAGE_FOR_JOB = textwrap.dedent( target_table_full_name as target_table_full_name FROM system.access.table_lineage WHERE entity_type ILIKE 'job' + AND event_time >= current_date() - INTERVAL 90 DAYS """ ) @@ -110,5 +111,6 @@ DATABRICKS_GET_COLUMN_LINEAGE_FOR_JOB = textwrap.dedent( target_column_name as target_column_name FROM system.access.column_lineage WHERE entity_type ILIKE 'job' + AND event_time >= current_date() - INTERVAL 90 DAYS """ ) diff --git a/ingestion/src/metadata/ingestion/source/pipeline/databrickspipeline/metadata.py b/ingestion/src/metadata/ingestion/source/pipeline/databrickspipeline/metadata.py index c32c89a5c69..0ff1c8da83b 100644 --- a/ingestion/src/metadata/ingestion/source/pipeline/databrickspipeline/metadata.py +++ b/ingestion/src/metadata/ingestion/source/pipeline/databrickspipeline/metadata.py @@ -130,9 +130,11 @@ class DatabrickspipelineSource(PipelineServiceSource): displayName=pipeline_details.settings.name, description=Markdown(description) if description else None, tasks=self.get_tasks(pipeline_details), - scheduleInterval=str(pipeline_details.settings.schedule.cron) - if pipeline_details.settings.schedule - else None, + scheduleInterval=( + str(pipeline_details.settings.schedule.cron) + if pipeline_details.settings.schedule + else None + ), service=FullyQualifiedEntityName(self.context.get().pipeline_service), ) yield Either(right=pipeline_request) @@ -176,12 +178,14 @@ class DatabrickspipelineSource(PipelineServiceSource): Task( name=str(task.name), taskType=pipeline_details.settings.task_type, - sourceUrl=SourceUrl(run.run_page_url) - if run.run_page_url - else None, - description=Markdown(task.description) - if task.description - else None, + sourceUrl=( + SourceUrl(run.run_page_url) + if run.run_page_url + else None + ), + description=( + Markdown(task.description) if task.description else None + ), downstreamTasks=[ depend_task.name for depend_task in task.depends_on or [] @@ -314,6 +318,9 @@ class DatabrickspipelineSource(PipelineServiceSource): table_lineage_list = self.client.get_table_lineage( job_id=pipeline_details.job_id ) + logger.debug( + f"Processing pipeline lineage for job {pipeline_details.job_id}" + ) if table_lineage_list: for table_lineage in table_lineage_list: source_table_full_name = table_lineage.get("source_table_full_name") diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/BulkEditEntity.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/BulkEditEntity.spec.ts index b2177e96028..c73a8c59497 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/BulkEditEntity.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/BulkEditEntity.spec.ts @@ -15,10 +15,11 @@ import { expect, test } from '@playwright/test'; import { RDG_ACTIVE_CELL_SELECTOR } from '../../constant/bulkImportExport'; import { SERVICE_TYPE } from '../../constant/service'; import { GlobalSettingOptions } from '../../constant/settings'; -import { EntityDataClass } from '../../support/entity/EntityDataClass'; +import { Domain } from '../../support/domain/Domain'; import { TableClass } from '../../support/entity/TableClass'; import { Glossary } from '../../support/glossary/Glossary'; import { GlossaryTerm } from '../../support/glossary/GlossaryTerm'; +import { UserClass } from '../../support/user/UserClass'; import { createNewPage, descriptionBoxReadOnly, @@ -49,9 +50,16 @@ test.use({ storageState: 'playwright/.auth/admin.json', }); +const user1 = new UserClass(); +const user2 = new UserClass(); +const glossary = new Glossary(); +const glossaryTerm = new GlossaryTerm(glossary); +const domain1 = new Domain(); +const domain2 = new Domain(); + const glossaryDetails = { - name: EntityDataClass.glossaryTerm1.data.name, - parent: EntityDataClass.glossary1.data.name, + name: glossaryTerm.data.name, + parent: glossary.data.name, }; const databaseSchemaDetails1 = { @@ -70,19 +78,16 @@ const columnDetails1 = { }; test.describe('Bulk Edit Entity', () => { - test.beforeAll('setup pre-test', async ({ browser }, testInfo) => { + test.beforeAll('setup pre-test', async ({ browser }) => { const { apiContext, afterAction } = await createNewPage(browser); - testInfo.setTimeout(90000); - await EntityDataClass.preRequisitesForTests(apiContext); - await afterAction(); - }); + await user1.create(apiContext); + await user2.create(apiContext); + await glossary.create(apiContext); + await glossaryTerm.create(apiContext); + await domain1.create(apiContext); + await domain2.create(apiContext); - test.afterAll('Cleanup', async ({ browser }, testInfo) => { - const { apiContext, afterAction } = await createNewPage(browser); - - testInfo.setTimeout(90000); - await EntityDataClass.postRequisitesForTests(apiContext); await afterAction(); }); @@ -109,7 +114,7 @@ test.describe('Bulk Edit Entity', () => { await test.step('Perform bulk edit action', async () => { const databaseDetails = { ...createDatabaseRowDetails(), - domains: EntityDataClass.domain1.responseData, + domains: domain1.responseData, glossary: glossaryDetails, }; @@ -145,8 +150,8 @@ test.describe('Bulk Edit Entity', () => { ...databaseDetails, name: table.database.name, owners: [ - EntityDataClass.user1.responseData?.['displayName'], - EntityDataClass.user2.responseData?.['displayName'], + user1.responseData?.['displayName'], + user2.responseData?.['displayName'], ], retentionPeriod: undefined, sourceUrl: undefined, @@ -191,10 +196,10 @@ test.describe('Bulk Edit Entity', () => { // Verify Owners await expect( - page.getByTestId(EntityDataClass.user1.responseData?.['displayName']) + page.getByTestId(user1.responseData?.['displayName']) ).toBeVisible(); await expect( - page.getByTestId(EntityDataClass.user2.responseData?.['displayName']) + page.getByTestId(user2.responseData?.['displayName']) ).toBeVisible(); // Verify Tags @@ -212,7 +217,7 @@ test.describe('Bulk Edit Entity', () => { await expect( page.getByRole('link', { - name: EntityDataClass.glossaryTerm1.data.displayName, + name: glossaryTerm.data.displayName, }) ).toBeVisible(); }); @@ -280,10 +285,10 @@ test.describe('Bulk Edit Entity', () => { ...databaseSchemaDetails1, name: table.schema.name, owners: [ - EntityDataClass.user1.responseData?.['displayName'], - EntityDataClass.user2.responseData?.['displayName'], + user1.responseData?.['displayName'], + user2.responseData?.['displayName'], ], - domains: EntityDataClass.domain1.responseData, + domains: domain1.responseData, }, page, customPropertyRecord @@ -327,11 +332,11 @@ test.describe('Bulk Edit Entity', () => { // Verify Owners await expect( - page.getByTestId(EntityDataClass.user1.responseData?.['displayName']) + page.getByTestId(user1.responseData?.['displayName']) ).toBeVisible(); await expect( - page.getByTestId(EntityDataClass.user2.responseData?.['displayName']) + page.getByTestId(user2.responseData?.['displayName']) ).toBeVisible(); await page.getByTestId('column-display-name').click(); @@ -354,7 +359,7 @@ test.describe('Bulk Edit Entity', () => { await expect( page.getByRole('link', { - name: EntityDataClass.glossaryTerm1.data.displayName, + name: glossaryTerm.data.displayName, }) ).toBeVisible(); }); @@ -420,10 +425,10 @@ test.describe('Bulk Edit Entity', () => { ...tableDetails1, name: table.entity.name, owners: [ - EntityDataClass.user1.responseData?.['displayName'], - EntityDataClass.user2.responseData?.['displayName'], + user1.responseData?.['displayName'], + user2.responseData?.['displayName'], ], - domains: EntityDataClass.domain1.responseData, + domains: domain1.responseData, }, page, customPropertyRecord @@ -464,16 +469,16 @@ test.describe('Bulk Edit Entity', () => { // Verify Domain await expect(page.getByTestId('domain-link')).toContainText( - EntityDataClass.domain1.responseData.displayName + domain1.responseData.displayName ); // Verify Owners await expect( - page.getByTestId(EntityDataClass.user1.responseData?.['displayName']) + page.getByTestId(user1.responseData?.['displayName']) ).toBeVisible(); await expect( - page.getByTestId(EntityDataClass.user2.responseData?.['displayName']) + page.getByTestId(user2.responseData?.['displayName']) ).toBeVisible(); // Verify Tags @@ -491,7 +496,7 @@ test.describe('Bulk Edit Entity', () => { await expect( page.getByRole('link', { - name: EntityDataClass.glossaryTerm1.data.displayName, + name: glossaryTerm.data.displayName, }) ).toBeVisible(); }); @@ -583,7 +588,7 @@ test.describe('Bulk Edit Entity', () => { await expect( page.getByRole('link', { - name: EntityDataClass.glossaryTerm1.data.displayName, + name: glossaryTerm.data.displayName, }) ).toBeVisible(); }); @@ -626,8 +631,8 @@ test.describe('Bulk Edit Entity', () => { { ...additionalGlossaryTerm, name: glossaryTerm.data.name, - owners: [EntityDataClass.user1.responseData?.['displayName']], - reviewers: [EntityDataClass.user2.responseData?.['displayName']], + owners: [user1.responseData?.['displayName']], + reviewers: [user2.responseData?.['displayName']], relatedTerm: { parent: glossary.data.name, name: glossaryTerm.data.name, @@ -689,12 +694,12 @@ test.describe('Bulk Edit Entity', () => { // Verify Owners await expect( - page.getByTestId(EntityDataClass.user1.responseData?.['displayName']) + page.getByTestId(user1.responseData?.['displayName']) ).toBeVisible(); // Verify Reviewers await expect( - page.getByTestId(EntityDataClass.user2.responseData?.['displayName']) + page.getByTestId(user2.responseData?.['displayName']) ).toBeVisible(); }); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/BulkImport.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/BulkImport.spec.ts index e24f240346d..c4b97bd3511 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/BulkImport.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/BulkImport.spec.ts @@ -14,11 +14,14 @@ import { expect, test } from '@playwright/test'; import { RDG_ACTIVE_CELL_SELECTOR } from '../../constant/bulkImportExport'; import { GlobalSettingOptions } from '../../constant/settings'; +import { Domain } from '../../support/domain/Domain'; import { DatabaseClass } from '../../support/entity/DatabaseClass'; import { DatabaseSchemaClass } from '../../support/entity/DatabaseSchemaClass'; -import { EntityDataClass } from '../../support/entity/EntityDataClass'; import { DatabaseServiceClass } from '../../support/entity/service/DatabaseServiceClass'; import { TableClass } from '../../support/entity/TableClass'; +import { Glossary } from '../../support/glossary/Glossary'; +import { GlossaryTerm } from '../../support/glossary/GlossaryTerm'; +import { UserClass } from '../../support/user/UserClass'; import { createNewPage, getApiContext, @@ -53,9 +56,16 @@ test.use({ }, }); +const user1 = new UserClass(); +const user2 = new UserClass(); +const glossary = new Glossary(); +const glossaryTerm = new GlossaryTerm(glossary); +const domain1 = new Domain(); +const domain2 = new Domain(); + const glossaryDetails = { - name: EntityDataClass.glossaryTerm1.data.name, - parent: EntityDataClass.glossary1.data.name, + name: glossaryTerm.data.name, + parent: glossary.data.name, }; const databaseDetails1 = { @@ -104,19 +114,15 @@ const storedProcedureDetails = { }; test.describe('Bulk Import Export', () => { - test.beforeAll('setup pre-test', async ({ browser }, testInfo) => { + test.beforeAll('setup pre-test', async ({ browser }) => { const { apiContext, afterAction } = await createNewPage(browser); - testInfo.setTimeout(90000); - await EntityDataClass.preRequisitesForTests(apiContext); - await afterAction(); - }); - - test.afterAll('Cleanup', async ({ browser }, testInfo) => { - const { apiContext, afterAction } = await createNewPage(browser); - - testInfo.setTimeout(90000); - await EntityDataClass.postRequisitesForTests(apiContext); + await user1.create(apiContext); + await user2.create(apiContext); + await glossary.create(apiContext); + await glossaryTerm.create(apiContext); + await domain1.create(apiContext); + await domain2.create(apiContext); await afterAction(); }); @@ -176,10 +182,10 @@ test.describe('Bulk Import Export', () => { { ...databaseDetails1, owners: [ - EntityDataClass.user1.responseData?.['displayName'], - EntityDataClass.user2.responseData?.['displayName'], + user1.responseData?.['displayName'], + user2.responseData?.['displayName'], ], - domains: EntityDataClass.domain1.responseData, + domains: domain1.responseData, }, page, customPropertyRecord @@ -206,10 +212,10 @@ test.describe('Bulk Import Export', () => { { ...databaseSchemaDetails1, owners: [ - EntityDataClass.user1.responseData?.['displayName'], - EntityDataClass.user2.responseData?.['displayName'], + user1.responseData?.['displayName'], + user2.responseData?.['displayName'], ], - domains: EntityDataClass.domain1.responseData, + domains: domain1.responseData, }, page ); @@ -236,10 +242,10 @@ test.describe('Bulk Import Export', () => { { ...tableDetails1, owners: [ - EntityDataClass.user1.responseData?.['displayName'], - EntityDataClass.user2.responseData?.['displayName'], + user1.responseData?.['displayName'], + user2.responseData?.['displayName'], ], - domains: EntityDataClass.domain1.responseData, + domains: domain1.responseData, }, page ); @@ -284,10 +290,10 @@ test.describe('Bulk Import Export', () => { { ...storedProcedureDetails, owners: [ - EntityDataClass.user1.responseData?.['displayName'], - EntityDataClass.user2.responseData?.['displayName'], + user1.responseData?.['displayName'], + user2.responseData?.['displayName'], ], - domains: EntityDataClass.domain2.responseData, + domains: domain2.responseData, }, page ); @@ -317,10 +323,10 @@ test.describe('Bulk Import Export', () => { { ...databaseDetails2, owners: [ - EntityDataClass.user1.responseData?.['displayName'], - EntityDataClass.user2.responseData?.['displayName'], + user1.responseData?.['displayName'], + user2.responseData?.['displayName'], ], - domains: EntityDataClass.domain2.responseData, + domains: domain2.responseData, }, page ); @@ -426,10 +432,10 @@ test.describe('Bulk Import Export', () => { { ...databaseSchemaDetails1, owners: [ - EntityDataClass.user1.responseData?.['displayName'], - EntityDataClass.user2.responseData?.['displayName'], + user1.responseData?.['displayName'], + user2.responseData?.['displayName'], ], - domains: EntityDataClass.domain1.responseData, + domains: domain1.responseData, }, page, customPropertyRecord @@ -457,10 +463,10 @@ test.describe('Bulk Import Export', () => { { ...tableDetails1, owners: [ - EntityDataClass.user1.responseData?.['displayName'], - EntityDataClass.user2.responseData?.['displayName'], + user1.responseData?.['displayName'], + user2.responseData?.['displayName'], ], - domains: EntityDataClass.domain1.responseData, + domains: domain1.responseData, }, page ); @@ -505,10 +511,10 @@ test.describe('Bulk Import Export', () => { { ...databaseSchemaDetails2, owners: [ - EntityDataClass.user1.responseData?.['displayName'], - EntityDataClass.user2.responseData?.['displayName'], + user1.responseData?.['displayName'], + user2.responseData?.['displayName'], ], - domains: EntityDataClass.domain1.responseData, + domains: domain1.responseData, }, page ); @@ -626,10 +632,10 @@ test.describe('Bulk Import Export', () => { { ...tableDetails1, owners: [ - EntityDataClass.user1.responseData?.['displayName'], - EntityDataClass.user2.responseData?.['displayName'], + user1.responseData?.['displayName'], + user2.responseData?.['displayName'], ], - domains: EntityDataClass.domain1.responseData, + domains: domain1.responseData, }, page, customPropertyRecord @@ -676,10 +682,10 @@ test.describe('Bulk Import Export', () => { { ...tableDetails2, owners: [ - EntityDataClass.user1.responseData?.['displayName'], - EntityDataClass.user2.responseData?.['displayName'], + user1.responseData?.['displayName'], + user2.responseData?.['displayName'], ], - domains: EntityDataClass.domain1.responseData, + domains: domain1.responseData, }, page, customPropertyRecord @@ -881,10 +887,10 @@ test.describe('Bulk Import Export', () => { { ...databaseDetails1, owners: [ - EntityDataClass.user1.responseData?.['displayName'], - EntityDataClass.user2.responseData?.['displayName'], + user1.responseData?.['displayName'], + user2.responseData?.['displayName'], ], - domains: EntityDataClass.domain1.responseData, + domains: domain1.responseData, }, page, undefined, diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ApiServiceRest.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ApiServiceRest.spec.ts index cd2409e6de9..1eaab361c99 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ApiServiceRest.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ApiServiceRest.spec.ts @@ -19,7 +19,6 @@ import { toastNotification, uuid, } from '../../utils/common'; -import { testConnection } from '../../utils/serviceIngestion'; import { settingClick } from '../../utils/sidebar'; const apiServiceConfig = { @@ -56,7 +55,6 @@ test.describe('API service', () => { .fill(apiServiceConfig.openAPISchemaURL); await page.locator('#root\\/token').fill(apiServiceConfig.token); - await testConnection(page); await page.getByTestId('submit-btn').click(); const autoPilotApplicationRequest = page.waitForRequest( diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ExploreDiscovery.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ExploreDiscovery.spec.ts index f4829e13a08..98dccfb8004 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ExploreDiscovery.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ExploreDiscovery.spec.ts @@ -11,33 +11,58 @@ * limitations under the License. */ import test, { expect } from '@playwright/test'; +import { SidebarItem } from '../../constant/sidebar'; +import { Domain } from '../../support/domain/Domain'; import { TableClass } from '../../support/entity/TableClass'; +import { UserClass } from '../../support/user/UserClass'; import { createNewPage, redirectToHomePage } from '../../utils/common'; +import { + getEncodedFqn, + waitForAllLoadersToDisappear, +} from '../../utils/entity'; import { getJsonTreeObject } from '../../utils/exploreDiscovery'; +import { sidebarClick } from '../../utils/sidebar'; // use the admin user to login test.use({ storageState: 'playwright/.auth/admin.json' }); const table = new TableClass(); const table1 = new TableClass(); +const user = new UserClass(); +const domain = new Domain(); test.describe('Explore Assets Discovery', () => { test.beforeAll(async ({ browser }) => { const { apiContext, afterAction } = await createNewPage(browser); + await user.create(apiContext); + await domain.create(apiContext); await table.create(apiContext); await table1.create(apiContext); + await table.patch({ + apiContext, + patchData: [ + { + op: 'add', + value: { + type: 'user', + id: user.responseData.id, + }, + path: '/owners/0', + }, + { + op: 'add', + path: '/domains/0', + value: { + id: domain.responseData.id, + type: 'domain', + name: domain.responseData.name, + displayName: domain.responseData.displayName, + }, + }, + ], + }); await table.delete(apiContext, false); - // await table1.delete(apiContext, false); - - await afterAction(); - }); - - test.afterAll(async ({ browser }) => { - const { apiContext, afterAction } = await createNewPage(browser); - - await table.delete(apiContext); - await table1.delete(apiContext); await afterAction(); }); @@ -229,4 +254,144 @@ test.describe('Explore Assets Discovery', () => { page.locator('.ant-popover-inner-content').textContent() ).not.toContain(table1.entityResponseData.name); }); + + test('Should not display domain and owner of deleted asset in suggestions when showDeleted is off', async ({ + page, + }) => { + await sidebarClick(page, SidebarItem.EXPLORE); + await page.waitForLoadState('networkidle'); + await waitForAllLoadersToDisappear(page); + + // The user should not be visible in the owners filter when the deleted switch is off + await page.click('[data-testid="search-dropdown-Owners"]'); + const searchResOwner = page.waitForResponse( + `/api/v1/search/aggregate?index=dataAsset&field=owners.displayName.keyword*deleted=false*` + ); + + await page.fill( + '[data-testid="search-input"]', + user.responseData.displayName + ); + await searchResOwner; + + await waitForAllLoadersToDisappear(page); + + await expect( + page + .getByTestId('drop-down-menu') + .getByTestId(user.responseData.displayName) + ).not.toBeAttached(); + + await page.getByTestId('close-btn').click(); + + // The domain should not be visible in the domains filter when the deleted switch is off + await page.click('[data-testid="search-dropdown-Domains"]'); + + const searchResDomain = page.waitForResponse( + `/api/v1/search/aggregate?index=dataAsset&field=domains.displayName.keyword*deleted=false*` + ); + + await page.fill( + '[data-testid="search-input"]', + domain.responseData.displayName + ); + await searchResDomain; + + await waitForAllLoadersToDisappear(page); + + await expect( + page + .getByTestId('drop-down-menu') + .getByTestId(domain.responseData.displayName) + ).not.toBeAttached(); + + await page.getByTestId('close-btn').click(); + }); + + test('Should display domain and owner of deleted asset in suggestions when showDeleted is on', async ({ + page, + }) => { + await sidebarClick(page, SidebarItem.EXPLORE); + await page.waitForLoadState('networkidle'); + await waitForAllLoadersToDisappear(page); + + // Click on the show deleted toggle button + await page.getByTestId('show-deleted').click(); + + await page.waitForLoadState('networkidle'); + await waitForAllLoadersToDisappear(page); + + // The user should be visible in the owners filter when the deleted switch is on + const ownerSearchText = user.responseData.displayName.toLowerCase(); + await page.click('[data-testid="search-dropdown-Owners"]'); + + const searchResOwner = page.waitForResponse( + `/api/v1/search/aggregate?index=dataAsset&field=owners.displayName.keyword*deleted=true*` + ); + + await page.fill('[data-testid="search-input"]', ownerSearchText); + await searchResOwner; + + await waitForAllLoadersToDisappear(page); + + await expect( + page.getByTestId('drop-down-menu').getByTestId(ownerSearchText) + ).toBeAttached(); + + await page + .getByTestId('drop-down-menu') + .getByTestId(ownerSearchText) + .click(); + + const fetchWithOwner = page.waitForResponse( + `/api/v1/search/query?*deleted=true*owners.displayName.keyword*${ownerSearchText}*` + ); + await page.getByTestId('update-btn').click(); + await fetchWithOwner; + + await page.waitForLoadState('networkidle'); + await waitForAllLoadersToDisappear(page); + + // The domain should be visible in the domains filter when the deleted switch is on + const domainSearchText = domain.responseData.displayName.toLowerCase(); + await page.click('[data-testid="search-dropdown-Domains"]'); + + const searchResDomain = page.waitForResponse( + `/api/v1/search/aggregate?index=dataAsset&field=domains.displayName.keyword*deleted=true*` + ); + + await page.fill('[data-testid="search-input"]', domainSearchText); + await searchResDomain; + + await waitForAllLoadersToDisappear(page); + + await expect( + page.getByTestId('drop-down-menu').getByTestId(domainSearchText) + ).toBeAttached(); + + await page + .getByTestId('drop-down-menu') + .getByTestId(domainSearchText) + .click(); + + const fetchWithDomain = page.waitForResponse( + `/api/v1/search/query?*deleted=true*domains.displayName.keyword*${getEncodedFqn( + domainSearchText, + true + )}*` + ); + await page.getByTestId('update-btn').click(); + await fetchWithDomain; + + await page.waitForLoadState('networkidle'); + await waitForAllLoadersToDisappear(page); + + // Only the table option should be visible for the data assets filter when the deleted switch is on + // with the owner and domain filter applied + await page.click('[data-testid="search-dropdown-Data Assets"]'); + + await expect( + page.getByTestId('drop-down-menu').getByTestId('table') + ).toBeAttached(); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ingestion/MlFlowIngestionClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ingestion/MlFlowIngestionClass.ts index 0cc2e963fef..8a184a301a1 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ingestion/MlFlowIngestionClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ingestion/MlFlowIngestionClass.ts @@ -26,7 +26,7 @@ class MlFlowIngestionClass extends ServiceBaseClass { shouldAddDefaultFilters?: boolean; }) { const { - shouldTestConnection = true, + shouldTestConnection = false, shouldAddIngestion = false, shouldAddDefaultFilters = false, } = extraParams ?? {}; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ingestion/ServiceBaseClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ingestion/ServiceBaseClass.ts index 15452ce75b0..acac1af3a7b 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ingestion/ServiceBaseClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ingestion/ServiceBaseClass.ts @@ -104,8 +104,6 @@ class ServiceBaseClass { await this.fillConnectionDetails(page); if (this.shouldTestConnection) { - expect(page.getByTestId('next-button')).not.toBeVisible(); - await testConnection(page); } diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts index a0d3514c480..d31596518f8 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts @@ -532,3 +532,44 @@ export const executeWithRetry = async ( } } }; + +export const readElementInListWithScroll = async ( + page: Page, + locator: Locator, + hierarchyElementLocator: Locator +) => { + const element = locator; + + // Reset scroll position to top before starting pagination + await hierarchyElementLocator.hover(); + await page.mouse.wheel(0, -99999); + + await page.waitForTimeout(1000); + + // Retry mechanism for pagination + let elementCount = await element.count(); + let retryCount = 0; + const maxRetries = 10; + + while (elementCount === 0 && retryCount < maxRetries) { + await hierarchyElementLocator.hover(); + await page.mouse.wheel(0, 1000); + await page.waitForTimeout(500); + + // Create fresh locator and check if the article is now visible after this retry + const freshArticle = locator; + const count = await freshArticle.count(); + + // Check if the article is now visible after this retry + elementCount = count; + + // If we found the element, validate it and break out of the loop + if (count > 0) { + await expect(freshArticle).toBeVisible(); + + return; // Exit the function early since we found and validated the article + } + + retryCount++; + } +}; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts index c8d1f19f587..f57f8b7065f 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts @@ -26,6 +26,7 @@ import { TagClass } from '../support/tag/TagClass'; import { clickOutside, descriptionBox, + readElementInListWithScroll, redirectToHomePage, toastNotification, uuid, @@ -486,8 +487,24 @@ export const assignCertification = async ( certification: TagClass, endpoint: string ) => { + const certificationResponse = page.waitForResponse( + '/api/v1/tags?parent=Certification&limit=50' + ); await page.getByTestId('edit-certification').click(); + await certificationResponse; + await page.waitForSelector('.certification-card-popover', { + state: 'visible', + }); await page.waitForSelector('[data-testid="loader"]', { state: 'detached' }); + + await readElementInListWithScroll( + page, + page.getByTestId( + `radio-btn-${certification.responseData.fullyQualifiedName}` + ), + page.locator('[data-testid="certification-cards"] .ant-radio-group') + ); + await page .getByTestId(`radio-btn-${certification.responseData.fullyQualifiedName}`) .click(); @@ -503,6 +520,9 @@ export const assignCertification = async ( export const removeCertification = async (page: Page, endpoint: string) => { await page.getByTestId('edit-certification').click(); + await page.waitForSelector('.certification-card-popover', { + state: 'visible', + }); await page.waitForSelector('[data-testid="loader"]', { state: 'detached' }); const patchRequest = page.waitForResponse(`/api/v1/${endpoint}/*`); await page.getByTestId('clear-certification').click(); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/serviceIngestion.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/serviceIngestion.ts index 9a88b59e0eb..b7e73ca493d 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/serviceIngestion.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/serviceIngestion.ts @@ -159,19 +159,12 @@ export const testConnection = async (page: Page) => { const warningBadge = page.locator('[data-testid="warning-badge"]'); - const failBadge = page.locator('[data-testid="fail-badge"]'); - - await expect(successBadge.or(warningBadge).or(failBadge)).toBeVisible({ + await expect(successBadge.or(warningBadge)).toBeVisible({ timeout: 2.5 * 60 * 1000, }); await expect(page.getByTestId('messag-text')).toContainText( - new RegExp( - 'Connection test was successful.|' + - 'Test connection partially successful: Some steps had failures, we will only ingest partial metadata. Click here to view details.|' + - 'Test connection failed, please validate your connection and permissions for the failed steps.', - 'g' - ) + /Connection test was successful.|Test connection partially successful: Some steps had failures, we will only ingest partial metadata. Click here to view details./g ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Certification/Certification.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Certification/Certification.component.tsx index 75fcb8e93cc..4694f2d3884 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Certification/Certification.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Certification/Certification.component.tsx @@ -17,6 +17,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ReactComponent as CertificationIcon } from '../../assets/svg/ic-certification.svg'; import { Tag } from '../../generated/entity/classification/tag'; +import { Paging } from '../../generated/type/paging'; import { getTags } from '../../rest/tagAPI'; import { getEntityName } from '../../utils/EntityUtils'; import { stringToHTML } from '../../utils/StringsUtils'; @@ -35,59 +36,84 @@ const Certification = ({ onClose, }: CertificationProps) => { const { t } = useTranslation(); - const popoverRef = useRef(null); + const popoverRef = useRef<{ close: () => void } | null>(null); const [isLoadingCertificationData, setIsLoadingCertificationData] = useState(false); + const [hasContentLoading, setHasContentLoading] = useState(false); const [certifications, setCertifications] = useState>([]); const [selectedCertification, setSelectedCertification] = useState( currentCertificate ?? '' ); - const certificationCardData = useMemo(() => { - return ( - - {certifications.map((certificate) => { - const tagSrc = getTagImageSrc(certificate.style?.iconURL ?? ''); - const title = getEntityName(certificate); - const { id, fullyQualifiedName, description } = certificate; + const [paging, setPaging] = useState({} as Paging); + const [currentPage, setCurrentPage] = useState(1); - return ( -
{ - setSelectedCertification(fullyQualifiedName ?? ''); - }}> - -
- {tagSrc ? ( - {title} - ) : ( -
- -
- )} -
- - {title} - - - {stringToHTML(description)} - -
-
-
- ); - })} -
- ); - }, [certifications, selectedCertification]); + const getCertificationData = async (page = 1, append = false) => { + if (page === 1) { + setIsLoadingCertificationData(true); + } else { + setHasContentLoading(true); + } + + try { + const response = await getTags({ + parent: 'Certification', + limit: 50, + after: page > 1 ? paging.after : undefined, + }); + + const { data, paging: newPaging } = response; + + // Sort certifications with Gold, Silver, Bronze first (only for initial load) + const sortedData = + page === 1 + ? [...data].sort((a, b) => { + const order: Record = { + Gold: 0, + Silver: 1, + Bronze: 2, + }; + + const aName = getEntityName(a); + const bName = getEntityName(b); + + const aOrder = order[aName] ?? 3; + const bOrder = order[bName] ?? 3; + + return aOrder - bOrder; + }) + : data; + + if (append) { + setCertifications((prev) => [...prev, ...sortedData]); + } else { + setCertifications(sortedData); + } + + setPaging(newPaging); + setCurrentPage(page); + } catch (err) { + showErrorToast( + err as AxiosError, + t('server.entity-fetch-error', { + entity: t('label.certification-plural-lowercase'), + }) + ); + } finally { + setIsLoadingCertificationData(false); + setHasContentLoading(false); + } + }; + + const handleScroll = async (e: React.UIEvent) => { + const { currentTarget } = e; + const isAtBottom = + currentTarget.scrollTop + currentTarget.offsetHeight >= + currentTarget.scrollHeight - 1; // -1 for precision tolerance + + if (isAtBottom && paging.after && !hasContentLoading) { + await getCertificationData(currentPage + 1, true); + } + }; const updateCertificationData = async (value?: string) => { setIsLoadingCertificationData(true); @@ -98,43 +124,60 @@ const Certification = ({ setIsLoadingCertificationData(false); popoverRef.current?.close(); }; - const getCertificationData = async () => { - setIsLoadingCertificationData(true); - try { - const { data } = await getTags({ - parent: 'Certification', - limit: 50, - }); - // Sort certifications with Gold, Silver, Bronze first - const sortedData = [...data].sort((a, b) => { - const order: Record = { - Gold: 0, - Silver: 1, - Bronze: 2, - }; + const certificationCardData = useMemo(() => { + return ( +
+ + {certifications.map((certificate) => { + const tagSrc = getTagImageSrc(certificate.style?.iconURL ?? ''); + const title = getEntityName(certificate); + const { id, fullyQualifiedName, description } = certificate; - const aName = getEntityName(a); - const bName = getEntityName(b); - - const aOrder = order[aName] ?? 3; - const bOrder = order[bName] ?? 3; - - return aOrder - bOrder; - }); - - setCertifications(sortedData); - } catch (err) { - showErrorToast( - err as AxiosError, - t('server.entity-fetch-error', { - entity: t('label.certification-plural-lowercase'), - }) - ); - } finally { - setIsLoadingCertificationData(false); - } - }; + return ( +
{ + setSelectedCertification(fullyQualifiedName ?? ''); + }}> + +
+ {tagSrc ? ( + {title} + ) : ( +
+ +
+ )} +
+ + {title} + + + {stringToHTML(description)} + +
+
+
+ ); + })} +
+ {hasContentLoading && ( +
+ +
+ )} +
+ ); + }, [certifications, selectedCertification, hasContentLoading, handleScroll]); const handleCloseCertification = async () => { popoverRef.current?.close(); @@ -143,16 +186,19 @@ const Certification = ({ const onOpenChange = (visible: boolean) => { if (visible) { - getCertificationData(); + getCertificationData(1); setSelectedCertification(currentCertificate); + setCurrentPage(1); + setPaging({} as Paging); } else { setSelectedCertification(''); + setCertifications([]); } }; useEffect(() => { if (popoverProps?.open && certifications.length === 0) { - getCertificationData(); + getCertificationData(1); } }, [popoverProps?.open]); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractDetailTab/ContractDetail.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractDetailTab/ContractDetail.test.tsx index ec43a33e59b..aa4e9ff17c0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractDetailTab/ContractDetail.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractDetailTab/ContractDetail.test.tsx @@ -226,7 +226,7 @@ describe('ContractDetail', () => { }); it('should display contract actions', () => { - const { getByTestId, debug } = render( + const { getByTestId } = render( { />, { wrapper: MemoryRouter } ); - debug(); + + expect(getByTestId('manage-contract-actions')).toBeInTheDocument(); + }); + + it('should not display contract created by or created at if data is not present', () => { + const { getByTestId, queryByTestId } = render( + , + { wrapper: MemoryRouter } + ); + + expect( + queryByTestId('contract-created-by-label') + ).not.toBeInTheDocument(); + + expect( + queryByTestId('contract-created-at-label') + ).not.toBeInTheDocument(); + + expect(getByTestId('manage-contract-actions')).toBeInTheDocument(); + }); + + it('should display contract created by or created at if data is present', () => { + const { getByTestId, queryByTestId } = render( + , + { wrapper: MemoryRouter } + ); + + expect(queryByTestId('contract-created-by-label')).toBeInTheDocument(); + + expect(queryByTestId('contract-created-at-label')).toBeInTheDocument(); expect(getByTestId('manage-contract-actions')).toBeInTheDocument(); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractDetailTab/ContractDetail.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractDetailTab/ContractDetail.tsx index 8911ff381cd..4f4c715c4ca 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractDetailTab/ContractDetail.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractDetailTab/ContractDetail.tsx @@ -37,6 +37,7 @@ import { ReactComponent as SettingIcon } from '../../../assets/svg/ic-settings-v import { ReactComponent as DeleteIcon } from '../../../assets/svg/ic-trash.svg'; import { + CONTRACT_DATE_TIME_FORMAT, DataContractMode, DATA_CONTRACT_ACTION_DROPDOWN_KEY, } from '../../../constants/DataContract.constants'; @@ -53,6 +54,7 @@ import { downloadContractYamlFile, getConstraintStatus, } from '../../../utils/DataContract/DataContractUtils'; +import { customFormatDateTime } from '../../../utils/date-time/DateTimeUtils'; import { getEntityName } from '../../../utils/EntityUtils'; import { pruneEmptyChildren } from '../../../utils/TableUtils'; import { showErrorToast, showSuccessToast } from '../../../utils/ToastUtils'; @@ -271,6 +273,55 @@ const ContractDetail: React.FC<{ + {contract.createdBy && ( + <> +
+ + {`${t('label.created-by')} : `} + + + +
+ + + + )} + + {contract.createdAt && ( + <> +
+ + {`${t('label.created-at')} : `} + + + + {customFormatDateTime( + contract.createdAt, + CONTRACT_DATE_TIME_FORMAT + )} + +
+ + + + )} +
({ + Transi18next: ({ i18nKey, values }: any) => ( + + {i18nKey} - {values?.label}: {values?.data} + + ), +})); + +jest.mock('../../../assets/svg/ic-check-circle-2.svg', () => ({ + ReactComponent: () => , +})); + +describe('ContractSLA Component', () => { + it('should render null when contract.sla is empty', () => { + const contract: DataContract = { + fullyQualifiedName: 'test.contract', + name: 'Test Contract', + sla: {}, + } as DataContract; + + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + it('should render null when contract.sla is undefined', () => { + const contract: DataContract = { + fullyQualifiedName: 'test.contract', + name: 'Test Contract', + } as DataContract; + + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + it('should render SLA card with refresh frequency', () => { + const contract: DataContract = { + fullyQualifiedName: 'test.contract', + name: 'Test Contract', + id: MOCK_DATA_CONTRACT.id, + entity: MOCK_DATA_CONTRACT.entity, + sla: { + refreshFrequency: { + interval: 24, + unit: RefreshFrequencyUnit.Hour, + }, + }, + } as DataContract; + + render(); + + expect(screen.getByTestId('contract-sla-card')).toBeInTheDocument(); + expect( + screen.getByText('label.service-level-agreement') + ).toBeInTheDocument(); + expect( + screen.getByText( + 'message.freshness-sla-description - label.freshness: 24 hour' + ) + ).toBeInTheDocument(); + expect(screen.getByTestId('check-icon')).toBeInTheDocument(); + }); + + it('should render SLA card with availability time', () => { + const contract: DataContract = { + fullyQualifiedName: 'test.contract', + name: 'Test Contract', + sla: { + availabilityTime: '09:00 UTC', + }, + } as DataContract; + + render(); + + expect(screen.getByTestId('contract-sla-card')).toBeInTheDocument(); + expect( + screen.getByText( + 'message.completeness-sla-description - label.completeness: 09:00 UTC' + ) + ).toBeInTheDocument(); + }); + + it('should render SLA card with max latency', () => { + const contract: DataContract = { + fullyQualifiedName: 'test.contract', + name: 'Test Contract', + id: MOCK_DATA_CONTRACT.id, + entity: MOCK_DATA_CONTRACT.entity, + sla: { + maxLatency: { + value: 5, + unit: MaxLatencyUnit.Minute, + }, + }, + } as DataContract; + + render(); + + expect(screen.getByTestId('contract-sla-card')).toBeInTheDocument(); + expect( + screen.getByText( + 'message.latency-sla-description - label.latency: 5 minute' + ) + ).toBeInTheDocument(); + }); + + it('should render SLA card with retention', () => { + const contract: DataContract = { + fullyQualifiedName: 'test.contract', + name: 'Test Contract', + id: MOCK_DATA_CONTRACT.id, + entity: MOCK_DATA_CONTRACT.entity, + sla: { + retention: { + period: 30, + unit: RetentionUnit.Day, + }, + }, + } as DataContract; + + render(); + + expect(screen.getByTestId('contract-sla-card')).toBeInTheDocument(); + expect( + screen.getByText( + 'message.retention-sla-description - label.retention: 30 day' + ) + ).toBeInTheDocument(); + }); + + it('should render all SLA items when all properties are present', () => { + const contract: DataContract = { + fullyQualifiedName: 'test.contract', + name: 'Test Contract', + id: MOCK_DATA_CONTRACT.id, + entity: MOCK_DATA_CONTRACT.entity, + sla: { + refreshFrequency: { + interval: 24, + unit: RefreshFrequencyUnit.Hour, + }, + availabilityTime: '09:00 UTC', + maxLatency: { + value: 5, + unit: MaxLatencyUnit.Minute, + }, + retention: { + period: 30, + unit: RetentionUnit.Day, + }, + }, + } as DataContract; + + render(); + + expect(screen.getByTestId('contract-sla-card')).toBeInTheDocument(); + + // Check all SLA items are rendered + expect( + screen.getByText( + 'message.freshness-sla-description - label.freshness: 24 hour' + ) + ).toBeInTheDocument(); + expect( + screen.getByText( + 'message.completeness-sla-description - label.completeness: 09:00 UTC' + ) + ).toBeInTheDocument(); + expect( + screen.getByText( + 'message.latency-sla-description - label.latency: 5 minute' + ) + ).toBeInTheDocument(); + expect( + screen.getByText( + 'message.retention-sla-description - label.retention: 30 day' + ) + ).toBeInTheDocument(); + + // Check that all check icons are rendered + const checkIcons = screen.getAllByTestId('check-icon'); + + expect(checkIcons).toHaveLength(4); + }); + + it('should render only provided SLA items', () => { + const contract: DataContract = { + fullyQualifiedName: 'test.contract', + name: 'Test Contract', + id: MOCK_DATA_CONTRACT.id, + entity: MOCK_DATA_CONTRACT.entity, + sla: { + refreshFrequency: { + interval: 12, + unit: RefreshFrequencyUnit.Hour, + }, + retention: { + period: 7, + unit: RetentionUnit.Day, + }, + }, + } as DataContract; + + render(); + + expect(screen.getByTestId('contract-sla-card')).toBeInTheDocument(); + + // Check only provided items are rendered + expect( + screen.getByText( + 'message.freshness-sla-description - label.freshness: 12 hour' + ) + ).toBeInTheDocument(); + expect( + screen.getByText( + 'message.retention-sla-description - label.retention: 7 day' + ) + ).toBeInTheDocument(); + + // Check that availability and latency are not rendered + expect( + screen.queryByText(/message.completeness-sla-description/) + ).not.toBeInTheDocument(); + expect( + screen.queryByText(/message.latency-sla-description/) + ).not.toBeInTheDocument(); + + // Check correct number of check icons + const checkIcons = screen.getAllByTestId('check-icon'); + + expect(checkIcons).toHaveLength(2); + }); + + it('should handle uppercase units and convert them to lowercase', () => { + const contract: DataContract = { + fullyQualifiedName: 'test.contract', + name: 'Test Contract', + id: MOCK_DATA_CONTRACT.id, + entity: MOCK_DATA_CONTRACT.entity, + sla: { + refreshFrequency: { + interval: 1, + unit: RefreshFrequencyUnit.Day, + }, + maxLatency: { + value: 10, + unit: MaxLatencyUnit.Minute, + }, + }, + } as DataContract; + + render(); + + expect( + screen.getByText( + 'message.freshness-sla-description - label.freshness: 1 day' + ) + ).toBeInTheDocument(); + expect( + screen.getByText( + 'message.latency-sla-description - label.latency: 10 minute' + ) + ).toBeInTheDocument(); + }); + + it('should have correct CSS classes applied', () => { + const contract: DataContract = { + fullyQualifiedName: 'test.contract', + name: 'Test Contract', + id: MOCK_DATA_CONTRACT.id, + entity: MOCK_DATA_CONTRACT.entity, + sla: { + refreshFrequency: { + interval: 24, + unit: RefreshFrequencyUnit.Hour, + }, + }, + } as DataContract; + + const { container } = render(); + + expect(container.querySelector('.contract-card-items')).toBeInTheDocument(); + expect( + container.querySelector('.contract-card-header-container') + ).toBeInTheDocument(); + expect( + container.querySelector('.contract-card-header') + ).toBeInTheDocument(); + expect( + container.querySelector('.contract-dash-separator') + ).toBeInTheDocument(); + expect(container.querySelector('.sla-item-container')).toBeInTheDocument(); + expect(container.querySelector('.sla-item')).toBeInTheDocument(); + expect(container.querySelector('.sla-icon')).toBeInTheDocument(); + expect(container.querySelector('.sla-description')).toBeInTheDocument(); + }); + + it('should render SLA items in the correct order', () => { + const contract: DataContract = { + fullyQualifiedName: 'test.contract', + name: 'Test Contract', + id: MOCK_DATA_CONTRACT.id, + entity: MOCK_DATA_CONTRACT.entity, + sla: { + retention: { + period: 30, + unit: RetentionUnit.Day, + }, + maxLatency: { + value: 5, + unit: MaxLatencyUnit.Minute, + }, + availabilityTime: '09:00 UTC', + refreshFrequency: { + interval: 24, + unit: RefreshFrequencyUnit.Hour, + }, + }, + } as DataContract; + + const { container } = render(); + + const slaDescriptions = container.querySelectorAll('.sla-description'); + + // Order should be: refreshFrequency, availabilityTime, maxLatency, retention + expect(slaDescriptions[0]).toHaveTextContent( + 'message.freshness-sla-description' + ); + expect(slaDescriptions[1]).toHaveTextContent( + 'message.completeness-sla-description' + ); + expect(slaDescriptions[2]).toHaveTextContent( + 'message.latency-sla-description' + ); + expect(slaDescriptions[3]).toHaveTextContent( + 'message.retention-sla-description' + ); + }); + + describe('Edge Cases', () => { + it('should handle zero values correctly', () => { + const contract: DataContract = { + fullyQualifiedName: 'test.contract', + name: 'Test Contract', + id: MOCK_DATA_CONTRACT.id, + entity: MOCK_DATA_CONTRACT.entity, + sla: { + refreshFrequency: { + interval: 0, + unit: RefreshFrequencyUnit.Hour, + }, + maxLatency: { + value: 0, + unit: MaxLatencyUnit.Minute, + }, + }, + } as DataContract; + + render(); + + expect( + screen.getByText( + 'message.freshness-sla-description - label.freshness: 0 hour' + ) + ).toBeInTheDocument(); + expect( + screen.getByText( + 'message.latency-sla-description - label.latency: 0 minute' + ) + ).toBeInTheDocument(); + }); + + it('should handle empty string for availabilityTime', () => { + const contract: DataContract = { + fullyQualifiedName: 'test.contract', + name: 'Test Contract', + sla: { + availabilityTime: '', + }, + } as DataContract; + + const { container } = render(); + + // Should not render anything as availabilityTime is empty + expect(container.firstChild).toBeNull(); + }); + + it('should handle partial SLA objects with undefined properties', () => { + const contract: DataContract = { + fullyQualifiedName: 'test.contract', + name: 'Test Contract', + sla: { + refreshFrequency: undefined, + availabilityTime: '09:00 UTC', + maxLatency: undefined, + retention: undefined, + }, + } as DataContract; + + render(); + + // Only availabilityTime should be rendered + expect(screen.getByTestId('contract-sla-card')).toBeInTheDocument(); + expect( + screen.getByText( + 'message.completeness-sla-description - label.completeness: 09:00 UTC' + ) + ).toBeInTheDocument(); + + // Check only one check icon is rendered + const checkIcons = screen.getAllByTestId('check-icon'); + + expect(checkIcons).toHaveLength(1); + }); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractSLAFormTab/ContractSLAFormTab.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractSLAFormTab/ContractSLAFormTab.tsx index f8a7abccc3d..99454b4771f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractSLAFormTab/ContractSLAFormTab.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractSLAFormTab/ContractSLAFormTab.tsx @@ -26,15 +26,17 @@ import moment from 'moment'; import { useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { ReactComponent as LeftOutlined } from '../../../assets/svg/left-arrow.svg'; -import { SLA_AVAILABILITY_TIME_FORMAT } from '../../../constants/DataContract.constants'; +import { + MAX_LATENCY_UNITS, + REFRESH_FREQUENCY_UNITS, + RETENTION_UNITS, + SLA_AVAILABILITY_TIME_FORMAT, +} from '../../../constants/DataContract.constants'; import { ContractSLA, DataContract, - MaxLatencyUnit, - RefreshFrequencyUnit, - RetentionUnit, } from '../../../generated/entity/data/dataContract'; -import { enumToSelectOptions } from '../../../utils/DataContract/DataContractUtils'; +import { generateSelectOptionsFromString } from '../../../utils/DataContract/DataContractUtils'; import './contract-sla-form-tab.less'; export const ContractSLAFormTab: React.FC<{ @@ -52,9 +54,11 @@ export const ContractSLAFormTab: React.FC<{ REFRESH_FREQUENCY_UNIT_OPTIONS, } = useMemo(() => { return { - REFRESH_FREQUENCY_UNIT_OPTIONS: enumToSelectOptions(RefreshFrequencyUnit), - RETENTION_UNIT_OPTIONS: enumToSelectOptions(RetentionUnit), - MAX_LATENCY_OPTIONS: enumToSelectOptions(MaxLatencyUnit), + REFRESH_FREQUENCY_UNIT_OPTIONS: generateSelectOptionsFromString( + REFRESH_FREQUENCY_UNITS + ), + RETENTION_UNIT_OPTIONS: generateSelectOptionsFromString(RETENTION_UNITS), + MAX_LATENCY_OPTIONS: generateSelectOptionsFromString(MAX_LATENCY_UNITS), }; }, []); @@ -166,7 +170,7 @@ export const ContractSLAFormTab: React.FC<{ label={t('label.unit')} name="refresh_frequency_unit">