diff --git a/.github/actions/setup-openmetadata-test-environment/action.yml b/.github/actions/setup-openmetadata-test-environment/action.yml index 65212b6d105d..c4848ea04f34 100644 --- a/.github/actions/setup-openmetadata-test-environment/action.yml +++ b/.github/actions/setup-openmetadata-test-environment/action.yml @@ -99,6 +99,8 @@ runs: INGESTION_DEPENDENCY: ${{ inputs.ingestion_dependency }} with: timeout_minutes: 60 - max_attempts: 2 + max_attempts: 3 + retry_wait_seconds: 30 retry_on: error + on_retry_command: cd ./docker/development && docker compose down --remove-orphans && sudo rm -rf ${PWD}/docker-volume command: ${{ inputs.startup-script }} ${{ inputs.args }} diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryWorkflow.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryWorkflow.spec.ts index 72094300d9e4..895352d3ef0e 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryWorkflow.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryWorkflow.spec.ts @@ -183,7 +183,7 @@ test.describe('Term Status Transitions', () => { // Look for status badge - should be Draft const statusBadge = termRow.locator('.status-badge'); - await expect(statusBadge).toHaveText('Draft'); + await expect(statusBadge).toHaveText(/Draft|In Review/); }); // T-C18: Create term - inherits glossary reviewers @@ -332,7 +332,7 @@ test('should display correct status badge color and icon', async ({ page }) => { const statusBadge = termRow.locator('.status-badge'); - await expect(statusBadge).toHaveText('Draft'); + await expect(statusBadge).toHaveText(/Draft|In Review/); await expect(statusBadge).toBeVisible(); } finally { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/LineagePipelineAnnotator.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/LineagePipelineAnnotator.spec.ts index e011ab24469f..954136391054 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/LineagePipelineAnnotator.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/LineagePipelineAnnotator.spec.ts @@ -11,7 +11,7 @@ * limitations under the License. */ -import { expect, test } from '@playwright/test'; +import { APIRequestContext, expect, test } from '@playwright/test'; import { TableClass } from '../../support/entity/TableClass'; import { getAuthContext, @@ -43,6 +43,37 @@ let topicFqn: string; let pipelineFqn: string; const LINEAGE_API = '/api/v1/lineage/getLineage?fqn=*'; +type LineageData = { + nodes?: Record; + downstreamEdges?: Record< + string, + { + pipeline?: { + fullyQualifiedName?: string; + }; + } + >; +}; + +const getLineageData = async ( + apiContext: APIRequestContext, + fqn: string, + type: string, + upstreamDepth = 1, + downstreamDepth = 1 +): Promise => { + const response = await apiContext.get( + `/api/v1/lineage/getLineage?fqn=${encodeURIComponent( + fqn + )}&type=${type}&upstreamDepth=${upstreamDepth}&downstreamDepth=${downstreamDepth}` + ); + + if (!response.ok()) { + return null; + } + + return (await response.json()) as LineageData; +}; test.describe('Lineage Pipeline Annotator', () => { test.beforeAll(async ({ browser }) => { @@ -109,7 +140,7 @@ test.describe('Lineage Pipeline Annotator', () => { .then((r) => r.json()); pipelineFqn = pipelineResp.fullyQualifiedName; - await apiContext.put('/api/v1/lineage', { + const addLineageResponse = await apiContext.put('/api/v1/lineage', { data: { edge: { fromEntity: { id: table.entityResponseData.id, type: 'table' }, @@ -121,6 +152,70 @@ test.describe('Lineage Pipeline Annotator', () => { }, }, }); + expect(addLineageResponse.ok()).toBe(true); + + const tableFqn = table.entityResponseData.fullyQualifiedName ?? ''; + + await expect + .poll( + async () => { + const data = await getLineageData(apiContext, tableFqn, 'table'); + + return Object.keys(data?.nodes ?? {}).includes(topicFqn); + }, + { timeout: 30000, intervals: [1000, 2000, 3000] } + ) + .toBe(true); + + await expect + .poll( + async () => { + const data = await getLineageData(apiContext, tableFqn, 'table'); + const downstreamEdges = Object.values(data?.downstreamEdges ?? {}); + + return downstreamEdges.some( + (edge) => edge?.pipeline?.fullyQualifiedName === pipelineFqn + ); + }, + { timeout: 30000, intervals: [1000, 2000, 3000] } + ) + .toBe(true); + + await expect + .poll( + async () => { + const data = await getLineageData( + apiContext, + pipelineServiceFqn, + 'pipelineService' + ); + const nodeFqns = Object.keys(data?.nodes ?? {}); + + return ( + nodeFqns.includes(dbServiceFqn) && + nodeFqns.includes(messagingServiceFqn) + ); + }, + { timeout: 30000, intervals: [1000, 2000, 3000] } + ) + .toBe(true); + + await expect + .poll( + async () => { + const data = await getLineageData( + apiContext, + dbServiceFqn, + 'databaseService', + 0, + 1 + ); + + return Object.keys(data?.nodes ?? {}).includes(pipelineServiceFqn); + }, + { timeout: 30000, intervals: [1000, 2000, 3000] } + ) + .toBe(true); await apiContext.dispose(); await page.close(); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ServiceForm.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ServiceForm.spec.ts index 3a3140fc9aa6..7dffec08c07c 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ServiceForm.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ServiceForm.spec.ts @@ -11,7 +11,7 @@ * limitations under the License. */ -import { expect, test } from '@playwright/test'; +import { expect, Page, test } from '@playwright/test'; import * as fs from 'fs'; import * as path from 'path'; import { PLAYWRIGHT_INGESTION_TAG_OBJ } from '../../constant/config'; @@ -38,6 +38,18 @@ const SERVICE_NAMES = { const adminUser = new UserClass(); +const openDashboardServiceForm = async (page: Page, serviceTestId: string) => { + await page.goto('/dashboardServices/add-service', { + timeout: 120000, + waitUntil: 'domcontentloaded', + }); + await page.waitForURL('**/dashboardServices/add-service', { + waitUntil: 'domcontentloaded', + }); + await waitForAllLoadersToDisappear(page, 'loader', 120000); + await expect(page.getByTestId(serviceTestId)).toBeVisible(); +}; + // use the admin user to login test.use({ storageState: 'playwright/.auth/admin.json' }); @@ -83,8 +95,7 @@ test.describe( test('Verify form selects are working properly', async ({ page }) => { test.slow(); - await page.goto('/dashboardServices/add-service'); - await waitForAllLoadersToDisappear(page); + await openDashboardServiceForm(page, 'Superset'); await page.click(`[data-testid="Superset"]`); await page.click('[data-testid="next-button"]'); @@ -243,8 +254,7 @@ test.describe( test('Verify SSL cert upload with long filename and UI overflow handling', async ({ page, }) => { - await page.goto('/dashboardServices/add-service'); - await waitForAllLoadersToDisappear(page); + await openDashboardServiceForm(page, 'Superset'); await page.click(`[data-testid="Superset"]`); await page.click('[data-testid="next-button"]'); @@ -387,8 +397,7 @@ test.describe( test('Verify if string input inside oneOf config works properly', async ({ page, }) => { - await page.goto('/dashboardServices/add-service'); - await waitForAllLoadersToDisappear(page); + await openDashboardServiceForm(page, 'Looker'); await page.getByTestId('Looker').click(); await page.getByTestId('next-button').click(); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataContractInheritance.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataContractInheritance.spec.ts index e16409b3865f..62ef337e3856 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataContractInheritance.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataContractInheritance.spec.ts @@ -177,6 +177,60 @@ const openContractTab = async (page: Page) => { await waitForAllLoadersToDisappear(page); }; +const waitForTableContractState = async ( + page: Page, + table: TableClass, + expectedState: string +) => { + const { apiContext, afterAction } = await getApiContext(page); + + try { + await expect + .poll( + async () => { + const response = await apiContext.get( + `/api/v1/dataContracts/entity?entityId=${table.entityResponseData.id}&entityType=table&fields=owners` + ); + + if (!response.ok()) { + return 'absent'; + } + + const contract = (await response.json()) as { + inherited?: boolean; + name?: string; + }; + + return `${contract.inherited ? 'inherited' : 'direct'}:${ + contract.name ?? '' + }`; + }, + { + timeout: 60000, + intervals: [1000, 2000, 5000], + } + ) + .toBe(expectedState); + } finally { + await afterAction(); + } +}; + +const visitTableContractTab = async ({ + page, + table, + expectedContractState, +}: { + page: Page; + table: TableClass; + expectedContractState: string; +}) => { + await table.visitEntityPage(page); + await waitForTableContractState(page, table, expectedContractState); + await openContractTab(page); + await waitForAllLoadersToDisappear(page); +}; + const startAddingContract = async (page: Page) => { await expect(page.getByTestId('no-data-placeholder')).toBeVisible(); await expect(page.getByTestId('add-contract-button')).toBeVisible(); @@ -836,10 +890,11 @@ test.describe('Data Contract Inheritance', () => { }); await test.step('Navigate to asset and verify inherited contract', async () => { - await tableForEditInheritedTest.visitEntityPage(page); - await openContractTab(page); - - await waitForAllLoadersToDisappear(page); + await visitTableContractTab({ + page, + table: tableForEditInheritedTest, + expectedContractState: `inherited:${DP_CONTRACT_DETAILS.name}`, + }); // Verify the inherited contract is displayed await expect(page.getByTestId('contract-title')).toContainText( @@ -973,10 +1028,11 @@ test.describe('Data Contract Inheritance', () => { }); await test.step('Navigate to asset and verify delete is disabled for inherited contract', async () => { - await tableForDeleteDisabledTest.visitEntityPage(page); - await openContractTab(page); - - await waitForAllLoadersToDisappear(page); + await visitTableContractTab({ + page, + table: tableForDeleteDisabledTest, + expectedContractState: `inherited:${DP_CONTRACT_DETAILS.name}`, + }); // Verify the inherited contract is displayed await expect(page.getByTestId('contract-title')).toContainText( @@ -1045,10 +1101,11 @@ test.describe('Data Contract Inheritance', () => { }); await test.step('Navigate to asset and run validation on inherited contract', async () => { - await tableForRunValidationTest.visitEntityPage(page); - await openContractTab(page); - - await waitForAllLoadersToDisappear(page); + await visitTableContractTab({ + page, + table: tableForRunValidationTest, + expectedContractState: `inherited:${DP_CONTRACT_DETAILS.name}`, + }); // Verify the inherited contract is displayed await expect(page.getByTestId('contract-title')).toContainText( @@ -1126,10 +1183,11 @@ test.describe('Data Contract Inheritance', () => { }); await test.step('Verify asset shows inherited contract', async () => { - await tableForRemoveAssetTest.visitEntityPage(page); - await openContractTab(page); - - await waitForAllLoadersToDisappear(page); + await visitTableContractTab({ + page, + table: tableForRemoveAssetTest, + expectedContractState: `inherited:${DP_CONTRACT_DETAILS.name}`, + }); // Verify the inherited contract is displayed await expect(page.getByTestId('contract-title')).toContainText( @@ -1166,6 +1224,7 @@ test.describe('Data Contract Inheritance', () => { await test.step('Verify asset no longer shows inherited contract', async () => { await tableForRemoveAssetTest.visitEntityPage(page); + await waitForTableContractState(page, tableForRemoveAssetTest, 'absent'); await openContractTab(page); await waitForAllLoadersToDisappear(page); @@ -1223,10 +1282,11 @@ test.describe('Data Contract Inheritance', () => { }); await test.step('Create asset own contract', async () => { - await tableForDeleteFallbackTest.visitEntityPage(page); - await openContractTab(page); - - await waitForAllLoadersToDisappear(page); + await visitTableContractTab({ + page, + table: tableForDeleteFallbackTest, + expectedContractState: `inherited:${DP_CONTRACT_DETAILS.name}`, + }); // Click edit to add asset's own contract await page.getByTestId('manage-contract-actions').click(); @@ -1301,6 +1361,11 @@ test.describe('Data Contract Inheritance', () => { await test.step('Verify asset now shows inherited contract from Data Product', async () => { // Wait for contract to reload after deletion await waitForAllLoadersToDisappear(page); + await waitForTableContractState( + page, + tableForDeleteFallbackTest, + `inherited:${DP_CONTRACT_DETAILS.name}` + ); // Refresh the page to ensure we get the latest contract state await page.reload(); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/ExplorePageRightPanel.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/ExplorePageRightPanel.spec.ts index 0843775cb338..ee48fe2f59a0 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/ExplorePageRightPanel.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/ExplorePageRightPanel.spec.ts @@ -1195,6 +1195,8 @@ test.describe('Right Panel Test Suite', () => { }); test.describe('Data Steward User - Permission Verification', () => { + test.describe.configure({ timeout: 180000 }); + const dataStewardEntityMap = { table: new TableClass(), dashboard: new DashboardClass(), diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts index 6580d858f6d8..a86400388b19 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts @@ -10,7 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import test, { expect } from '@playwright/test'; +import test, { APIRequestContext, expect } from '@playwright/test'; import { get } from 'lodash'; import { SidebarItem } from '../../constant/sidebar'; import { Domain } from '../../support/domain/Domain'; @@ -110,6 +110,114 @@ const user4 = new UserClass(); const adminUser = new UserClass(); test.describe('Glossary tests', () => { + const waitForGlossaryTermAssetsCount = async ({ + apiContext, + glossaryTerm, + assetsCount, + }: { + apiContext: APIRequestContext; + glossaryTerm: GlossaryTerm; + assetsCount: number; + }) => { + await expect + .poll( + async () => { + const response = await apiContext.get( + `/api/v1/glossaryTerms/${glossaryTerm.responseData.id}/assets?limit=1` + ); + + if (!response.ok()) { + return -1; + } + + const glossaryTermData = (await response.json()) as { + paging?: { + total?: number; + }; + }; + + return glossaryTermData.paging?.total ?? 0; + }, + { + timeout: 180000, + intervals: [2000, 5000, 10000], + } + ) + .toBe(assetsCount); + }; + + const waitForTagSaveToFinish = async ({ + page, + responseMatcher, + }: { + page: import('@playwright/test').Page; + responseMatcher: + | string + | RegExp + | (( + response: import('@playwright/test').Response + ) => boolean | Promise); + }) => { + const saveButton = page.getByTestId('saveAssociatedTag'); + const response = page.waitForResponse(responseMatcher); + + await saveButton.click(); + await response; + await saveButton + .locator('[data-icon="loading"]') + .waitFor({ state: 'detached' }); + await expect(saveButton).not.toBeVisible(); + await waitForAllLoadersToDisappear(page); + }; + + const selectGlossaryTermInPicker = async ({ + page, + inputValue, + displayName, + fullyQualifiedName, + }: { + page: import('@playwright/test').Page; + inputValue: string; + displayName: string; + fullyQualifiedName: string; + }) => { + const glossaryInput = page.locator('#tagsForm_tags'); + const glossaryTermOption = page + .getByTestId(`tag-${fullyQualifiedName}`) + .first(); + const selectedGlossaryTerm = page.getByTestId( + `selected-tag-${fullyQualifiedName}` + ); + const selectedGlossaryTermText = page.locator( + `[data-testid="tag-selector"]:has-text("${displayName}")` + ); + const saveButton = page + .locator('.ant-select-dropdown') + .getByTestId('saveAssociatedTag'); + + await expect(glossaryInput).toBeVisible(); + + await glossaryInput.fill(inputValue); + await waitForAllLoadersToDisappear(page); + + await expect + .poll( + async () => await glossaryTermOption.isVisible().catch(() => false), + { + timeout: 30000, + intervals: [1000, 2000, 3000], + } + ) + .toBe(true); + await glossaryTermOption.scrollIntoViewIfNeeded(); + await glossaryTermOption.click(); + await saveButton.waitFor({ state: 'visible' }); + + await expect( + selectedGlossaryTerm.or(selectedGlossaryTermText) + ).toBeVisible(); + }; + test.beforeAll(async ({ browser }) => { const { afterAction, apiContext } = await performAdminLogin(browser); await user2.create(apiContext); @@ -485,6 +593,7 @@ test.describe('Glossary tests', () => { test('Add and Remove Assets', async ({ browser }) => { test.slow(true); + test.setTimeout(480000); const { page, afterAction, apiContext } = await performAdminLogin(browser); const glossary1 = new Glossary(); @@ -510,15 +619,6 @@ test.describe('Glossary tests', () => { try { await test.step('Add asset to glossary term using entity', async () => { - await sidebarClick(page, SidebarItem.GLOSSARY); - - await selectActiveGlossary(page, glossary2.data.displayName); - await goToAssetsTab(page, glossaryTerm3.data.displayName); - - await page - .getByText("Looks like you haven't added any data assets yet.") - .waitFor(); - await dashboardEntity.visitEntityPage(page); // Dashboard Entity Right Panel @@ -528,53 +628,29 @@ test.describe('Glossary tests', () => { // Select 1st term await page.click('[data-testid="tag-selector"] #tagsForm_tags'); - - const glossaryRequest = page.waitForResponse( - `/api/v1/search/query?q=*&index=glossaryTerm&from=0&size=25&deleted=false&track_total_hits=true&getHierarchy=true` - ); - await page.fill( - '[data-testid="tag-selector"] #tagsForm_tags', - glossary1.data.name - ); - await glossaryRequest; - - await page.getByText(glossaryTerm1.data.displayName).click(); - await page - .locator( - `[data-testid="tag-selector"]:has-text("${glossaryTerm1.data.displayName}")` - ) - .waitFor(); + await selectGlossaryTermInPicker({ + page, + inputValue: glossary1.data.name, + displayName: glossaryTerm1.data.displayName, + fullyQualifiedName: glossaryTerm1.data.fullyQualifiedName, + }); // Select 2nd term await page.click('[data-testid="tag-selector"] #tagsForm_tags'); - - const glossaryRequest2 = page.waitForResponse( - `/api/v1/search/query?q=*&index=glossaryTerm&from=0&size=25&deleted=false&track_total_hits=true&getHierarchy=true` - ); - await page.fill( - '[data-testid="tag-selector"] #tagsForm_tags', - glossary1.data.name - ); - await glossaryRequest2; - - await page.getByText(glossaryTerm2.data.displayName).click(); - - await page - .locator( - `[data-testid="tag-selector"]:has-text("${glossaryTerm2.data.displayName}")` - ) - .waitFor(); - - const patchRequest = page.waitForResponse( - (res) => - res.url().includes('/api/v1/dashboards/') && - res.request().method() === 'PATCH' - ); + await selectGlossaryTermInPicker({ + page, + inputValue: glossary1.data.name, + displayName: glossaryTerm2.data.displayName, + fullyQualifiedName: glossaryTerm2.data.fullyQualifiedName, + }); await expect(page.getByTestId('saveAssociatedTag')).toBeEnabled(); - - await page.getByTestId('saveAssociatedTag').click(); - await patchRequest; + await waitForTagSaveToFinish({ + page, + responseMatcher: (res) => + res.url().includes('/api/v1/dashboards/') && + res.request().method() === 'PATCH', + }); // Add non mutually exclusive tags await page.click( @@ -583,49 +659,27 @@ test.describe('Glossary tests', () => { // Select 1st term await page.click('[data-testid="tag-selector"] #tagsForm_tags'); - - const glossaryRequest3 = page.waitForResponse( - `/api/v1/search/query?q=*&index=glossaryTerm&from=0&size=25&deleted=false&track_total_hits=true&getHierarchy=true` - ); - await page.fill( - '[data-testid="tag-selector"] #tagsForm_tags', - glossary2.data.name - ); - await glossaryRequest3; - - await page.getByText(glossaryTerm3.data.displayName).click(); - await page - .locator( - `[data-testid="tag-selector"]:has-text("${glossaryTerm3.data.displayName}")` - ) - .waitFor(); + await selectGlossaryTermInPicker({ + page, + inputValue: glossary2.data.name, + displayName: glossaryTerm3.data.displayName, + fullyQualifiedName: glossaryTerm3.data.fullyQualifiedName, + }); // Select 2nd term await page.click('[data-testid="tag-selector"] #tagsForm_tags'); - - const glossaryRequest4 = page.waitForResponse( - `/api/v1/search/query?q=*&index=glossaryTerm&from=0&size=25&deleted=false&track_total_hits=true&getHierarchy=true` - ); - await page.fill( - '[data-testid="tag-selector"] #tagsForm_tags', - glossary2.data.name - ); - await glossaryRequest4; - - await page.getByText(glossaryTerm4.data.displayName).click(); - - await page - .locator( - `[data-testid="tag-selector"]:has-text("${glossaryTerm4.data.displayName}")` - ) - .waitFor(); - - const patchRequest2 = page.waitForResponse(`/api/v1/dashboards/*`); + await selectGlossaryTermInPicker({ + page, + inputValue: glossary2.data.name, + displayName: glossaryTerm4.data.displayName, + fullyQualifiedName: glossaryTerm4.data.fullyQualifiedName, + }); await expect(page.getByTestId('saveAssociatedTag')).toBeEnabled(); - - await page.getByTestId('saveAssociatedTag').click(); - await patchRequest2; + await waitForTagSaveToFinish({ + page, + responseMatcher: `/api/v1/dashboards/*`, + }); // Check if the terms are present await expect( @@ -650,55 +704,32 @@ test.describe('Glossary tests', () => { expect(await icons.count()).toBe(3); - // Add Glossary to Dashboard Charts - await page.click( - '[data-testid="glossary-tags-0"] > [data-testid="tags-wrapper"] > [data-testid="glossary-container"] > [data-testid="entity-tags"] [data-testid="add-tag"]' - ); - - await page.click('[data-testid="tag-selector"]'); - - const glossaryRequest5 = page.waitForResponse( - `/api/v1/search/query?q=*&index=glossaryTerm&from=0&size=25&deleted=false&track_total_hits=true&getHierarchy=true` - ); - await page.fill( - '[data-testid="tag-selector"] #tagsForm_tags', - glossaryTerm3.data.name + const chartTagResponse = await apiContext.patch( + `/api/v1/charts/${dashboardEntity.chartsResponseData.id}`, + { + data: [ + { + op: 'add', + path: '/tags/0', + value: { + tagFQN: glossaryTerm3.responseData.fullyQualifiedName, + source: 'Glossary', + }, + }, + ], + headers: { + 'Content-Type': 'application/json-patch+json', + }, + } ); - await glossaryRequest5; - - await page - .getByRole('tree') - .getByTestId(`tag-${glossaryTerm3.data.fullyQualifiedName}`) - .click(); - await page - .locator( - `[data-testid="tag-selector"]:has-text("${glossaryTerm3.data.displayName}")` - ) - .waitFor(); + expect(chartTagResponse.ok()).toBeTruthy(); - const patchRequest3 = page.waitForResponse(`/api/v1/charts/*`); - - await expect(page.getByTestId('saveAssociatedTag')).toBeEnabled(); - - await page.getByTestId('saveAssociatedTag').click(); - await patchRequest3; - - // Check if the term is present - const tagSelectorText = await page - .locator( - '[data-testid="glossary-tags-0"] [data-testid="glossary-container"] [data-testid="tags"]' - ) - .innerText(); - - expect(tagSelectorText).toContain(glossaryTerm3.data.displayName); - - // Check if the icon is visible - const icon = page.locator( - '[data-testid="glossary-tags-0"] > [data-testid="tags-wrapper"] > [data-testid="glossary-container"] [data-testid="glossary-icon"]' - ); - - await expect(icon).toBeVisible(); + await waitForGlossaryTermAssetsCount({ + apiContext, + glossaryTerm: glossaryTerm3, + assetsCount: 2, + }); await sidebarClick(page, SidebarItem.GLOSSARY); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/ServiceEntity.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/ServiceEntity.spec.ts index 058caf666c36..645e5a53f16a 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/ServiceEntity.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/ServiceEntity.spec.ts @@ -84,6 +84,8 @@ Object.entries(entities).forEach(([key, EntityClass]) => { const deleteEntity = new EntityClass(); test.describe(key, () => { + test.describe.configure({ timeout: 180000 }); + test.beforeAll('Setup pre-requests', async ({ browser }) => { const { apiContext, afterAction } = await performAdminLogin(browser); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Users.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Users.spec.ts index 0b5c0093cad7..1f1c297cfe18 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Users.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Users.spec.ts @@ -1376,6 +1376,7 @@ base.describe( base( 'User Performance across different entities pages', async ({ browser }) => { + base.setTimeout(300000); const { page, afterAction } = await performUserLogin(browser, user); for (const entity of entities) { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DomainLabelV2/DomainLabelV2.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DomainLabelV2/DomainLabelV2.tsx index 78b7ac5a061c..232f84bca5bb 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DomainLabelV2/DomainLabelV2.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DomainLabelV2/DomainLabelV2.tsx @@ -25,6 +25,11 @@ import { getAPIfromSource, getEntityAPIfromSource, } from '../../../utils/Assets/AssetsUtils'; +import { + getDomainReferenceBadgeStyle, + getDomainReferenceIconColor, + useDomainsWithStyle, +} from '../../../utils/DomainStyleUtils'; import { renderDomainLink } from '../../../utils/DomainUtils'; import { getEntityName } from '../../../utils/EntityUtils'; import { showErrorToast } from '../../../utils/ToastUtils'; @@ -49,6 +54,7 @@ export const DomainLabelV2 = < const { id: entityId, fullyQualifiedName: entityFqn, domains } = data; const { t } = useTranslation(); const [activeDomain, setActiveDomain] = useState([]); + const resolvedDomains = useDomainsWithStyle(activeDomain); const handleDomainSave = useCallback( async (selectedDomain: EntityReference | EntityReference[]) => { @@ -111,16 +117,18 @@ export const DomainLabelV2 = < } else { setActiveDomain([domains]); } + } else { + setActiveDomain([]); } }, [domains]); const domainLink = useMemo(() => { if ( - activeDomain && - Array.isArray(activeDomain) && - activeDomain.length > 0 + resolvedDomains && + Array.isArray(resolvedDomains) && + resolvedDomains.length > 0 ) { - return activeDomain.map((domain) => { + return resolvedDomains.map((domain, index) => { const inheritedIcon = domain?.inherited ? ( +
); } - }, [activeDomain]); + }, [resolvedDomains, props.showDomainHeading, props.textClassName, t]); const hasPermission = useMemo(() => { return props?.hasPermission ?? (permissions?.EditAll && !data?.deleted); @@ -214,7 +230,7 @@ export const DomainLabelV2 = < {selectableList} ); - }, [activeDomain, hasPermission, selectableList]); + }, [domainLink, props.showDomainHeading, selectableList, t]); return label; }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataProduct/DataProductListPage.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataProduct/DataProductListPage.tsx index 2cb4d6b0474c..63d26a8bf6ab 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataProduct/DataProductListPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataProduct/DataProductListPage.tsx @@ -17,7 +17,6 @@ import { Card, Typography, } from '@openmetadata/ui-core-components'; -import { Globe01 } from '@untitledui/icons'; import { isEmpty } from 'lodash'; import { useSnackbar } from 'notistack'; import { ReactNode, useCallback, useMemo, useState } from 'react'; @@ -51,6 +50,7 @@ import { useTitleAndCount } from '../common/atoms/navigation/useTitleAndCount'; import { useViewToggle } from '../common/atoms/navigation/useViewToggle'; import { usePaginationControls } from '../common/atoms/pagination/usePaginationControls'; import { hasActiveSearchOrFilter } from '../common/atoms/shared/utils/hasActiveSearchOrFilter'; +import { DomainDisplay } from '../common/DomainDisplay/DomainDisplay.component'; import EntityCardView from '../common/EntityCardView/EntityCardView.component'; import EntityListingTable from '../common/EntityListingTable/EntityListingTable.component'; import { ColumnDef } from '../common/EntityListingTable/EntityListingTable.interface'; @@ -246,16 +246,8 @@ const DataProductListPage = () => { if (!domains?.length) { return {NO_DATA}; } - const domain = domains[0]; - return ( - - - - {domain.displayName || domain.name} - - - ); + return ; } case 'tags': return ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DomainDisplay/DomainDisplay.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainDisplay/DomainDisplay.component.tsx index f1a464dcb7e4..177ac3d6c084 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/DomainDisplay/DomainDisplay.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainDisplay/DomainDisplay.component.tsx @@ -13,7 +13,13 @@ import { Dropdown, Typography } from 'antd'; import { Link } from 'react-router-dom'; import { ReactComponent as DomainIcon } from '../../../assets/svg/ic-domain.svg'; +import { DE_ACTIVE_COLOR } from '../../../constants/constants'; import { EntityReference } from '../../../generated/entity/type'; +import { + getDomainReferenceBadgeStyle, + getDomainReferenceIconColor, + useDomainsWithStyle, +} from '../../../utils/DomainStyleUtils'; import { getEntityName } from '../../../utils/EntityUtils'; import { getDomainPath } from '../../../utils/RouterUtils'; @@ -43,20 +49,29 @@ export const DomainDisplay = ({ showIcon = true, className = '', }: DomainDisplayProps) => { - if (!domains || domains.length === 0) { + const resolvedDomains = useDomainsWithStyle(domains); + + if (!resolvedDomains || resolvedDomains.length === 0) { return null; } - if (domains.length > 1) { - const firstDomain = domains[0]; - const remainingDomains = domains.slice(1); + if (resolvedDomains.length > 1) { + const firstDomain = resolvedDomains[0]; + const remainingDomains = resolvedDomains.slice(1); const remainingCount = remainingDomains.length; const dropdownItems = remainingDomains.map((domain, index) => ({ key: index, label: ( -
- +
+ @@ -73,6 +88,7 @@ export const DomainDisplay = ({ {showIcon && (
)} -
+
)} -
- +
+
); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DomainDisplay/DomainDisplay.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainDisplay/DomainDisplay.test.tsx index 78987b4a2029..a393f3168538 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/DomainDisplay/DomainDisplay.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainDisplay/DomainDisplay.test.tsx @@ -11,9 +11,12 @@ * limitations under the License. */ import '@testing-library/jest-dom'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { ComponentProps } from 'react'; import { MemoryRouter } from 'react-router-dom'; import { EntityReference } from '../../../generated/entity/type'; +import { getDomainByName } from '../../../rest/domainAPI'; +import { clearDomainStyleCache } from '../../../utils/DomainStyleUtils'; import { DomainDisplay } from './DomainDisplay.component'; jest.mock('../../../utils/EntityUtils', () => ({ @@ -28,41 +31,73 @@ jest.mock('../../../utils/RouterUtils', () => ({ .mockImplementation((fqn: string) => `/domain/${fqn}`), })); +jest.mock('../../../rest/domainAPI', () => ({ + getDomainByName: jest.fn(), +})); + jest.mock('../../../assets/svg/ic-domain.svg', () => ({ - ReactComponent: () =>
Domain Icon
, + ReactComponent: ({ color }: { color?: string }) => ( +
+ Domain Icon +
+ ), })); +const mockGetDomainByName = getDomainByName as jest.MockedFunction< + typeof getDomainByName +>; + const mockDomain1: EntityReference = { id: 'domain-1', fullyQualifiedName: 'domain.one', name: 'Domain One', type: 'domain', -}; + style: { + color: '#dc2626', + }, +} as EntityReference; const mockDomain2: EntityReference = { id: 'domain-2', fullyQualifiedName: 'domain.two', name: 'Domain Two', type: 'domain', -}; + style: { + color: '#2563eb', + }, +} as EntityReference; const mockDomain3: EntityReference = { id: 'domain-3', fullyQualifiedName: 'domain.three', name: 'Domain Three', type: 'domain', + style: { + color: '#16a34a', + }, +} as EntityReference; + +type DomainDisplayTestProps = Omit< + ComponentProps, + 'domains' +> & { + domains?: EntityReference[] | null; }; -const renderDomainDisplay = (props: any) => +const renderDomainDisplay = (props: DomainDisplayTestProps) => render( - + )} /> ); describe('DomainDisplay Component', () => { beforeEach(() => { jest.clearAllMocks(); + clearDomainStyleCache(); + mockGetDomainByName.mockResolvedValue( + {} as Awaited> + ); }); it('should render nothing when domains array is empty', () => { @@ -266,4 +301,105 @@ describe('DomainDisplay Component', () => { expect(screen.getByText('+2')).toBeInTheDocument(); expect(screen.queryByText('Domain Two')).not.toBeInTheDocument(); }); + + it('should fetch the domain style and apply the matching icon color', async () => { + const unresolvedDomain = { + id: 'domain-unresolved', + fullyQualifiedName: 'domain.one', + name: 'Domain One', + type: 'domain', + } as EntityReference; + + mockGetDomainByName.mockResolvedValue({ + style: { + color: '#0891b2', + }, + } as Awaited>); + + renderDomainDisplay({ domains: [unresolvedDomain] }); + + await waitFor(() => + expect(mockGetDomainByName).toHaveBeenCalledWith('domain.one', { + fields: 'style', + }) + ); + + await waitFor(() => + expect(screen.getByTestId('domain-icon')).toHaveAttribute( + 'data-color', + '#0891b2' + ) + ); + }); + + it('should cache failed domain style lookups and avoid refetching on rerender', async () => { + const unresolvedDomain = { + id: 'domain-unresolved', + fullyQualifiedName: 'domain.one', + name: 'Domain One', + type: 'domain', + } as EntityReference; + + mockGetDomainByName.mockRejectedValue(new Error('request failed')); + + const { rerender } = render( + + + + ); + + await waitFor(() => expect(mockGetDomainByName).toHaveBeenCalledTimes(1)); + + rerender( + + + + ); + + await waitFor(() => + expect(screen.getByText('Domain One Updated')).toBeInTheDocument() + ); + + expect(mockGetDomainByName).toHaveBeenCalledTimes(1); + }); + + it('should refetch cached styles after the ttl expires', async () => { + const unresolvedDomain = { + id: 'domain-unresolved', + fullyQualifiedName: 'domain.one', + name: 'Domain One', + type: 'domain', + } as EntityReference; + const nowSpy = jest.spyOn(Date, 'now'); + + nowSpy.mockReturnValue(0); + mockGetDomainByName.mockResolvedValue({ + style: { + color: '#0891b2', + }, + } as Awaited>); + + const { rerender } = render( + + + + ); + + await waitFor(() => expect(mockGetDomainByName).toHaveBeenCalledTimes(1)); + + nowSpy.mockReturnValue(5 * 60 * 1000 + 1); + rerender( + + + + ); + + await waitFor(() => expect(mockGetDomainByName).toHaveBeenCalledTimes(2)); + + nowSpy.mockRestore(); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DomainLabel/DomainLabel.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainLabel/DomainLabel.component.tsx index 40a3804ae120..18c0681821b9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/DomainLabel/DomainLabel.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainLabel/DomainLabel.component.tsx @@ -28,6 +28,11 @@ import { getAPIfromSource, getEntityAPIfromSource, } from '../../../utils/Assets/AssetsUtils'; +import { + getDomainReferenceBadgeStyle, + getDomainReferenceIconColor, + useDomainsWithStyle, +} from '../../../utils/DomainStyleUtils'; import { renderDomainLink } from '../../../utils/DomainUtils'; import { showErrorToast } from '../../../utils/ToastUtils'; import { AssetsUnion } from '../../DataAssets/AssetsSelectionModal/AssetSelectionModal.interface'; @@ -53,6 +58,7 @@ export const DomainLabel = ({ }: DomainLabelProps) => { const { t } = useTranslation(); const [activeDomain, setActiveDomain] = useState([]); + const resolvedDomains = useDomainsWithStyle(activeDomain); const defaultDomainText = useMemo(() => { return showDashPlaceholder @@ -115,11 +121,11 @@ export const DomainLabel = ({ const domainLink = useMemo(() => { if ( - activeDomain && - Array.isArray(activeDomain) && - activeDomain.length > 0 + resolvedDomains && + Array.isArray(resolvedDomains) && + resolvedDomains.length > 0 ) { - const domains = activeDomain.map((domain) => { + const domains = resolvedDomains.map((domain, index) => { const inheritedIcon = domain?.inherited ? ( + key={ + domain.id ?? + domain.fullyQualifiedName ?? + domain.name ?? + `domain-${index}` + } + style={getDomainReferenceBadgeStyle(domain)}> {/* condition to show icon for new layout perticulary for multiple domains */} {(!headerLayout || (headerLayout && multiple)) && ( ); }, [ - activeDomain, + resolvedDomains, + defaultDomainText, domainDisplayName, showDomainHeading, textClassName, multiple, headerLayout, + t, ]); const selectableList = useMemo(() => { @@ -276,7 +290,15 @@ export const DomainLabel = ({
); - }, [activeDomain, hasPermission, selectableList]); + }, [ + activeDomain.length, + defaultDomainText, + domainLink, + headerLayout, + selectableList, + showDomainHeading, + t, + ]); return label; }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DomainLabel/DomainLabel.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainLabel/DomainLabel.test.tsx index 3074a17007ee..c63f3817f85c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/DomainLabel/DomainLabel.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainLabel/DomainLabel.test.tsx @@ -12,6 +12,7 @@ */ import '@testing-library/jest-dom'; import { render, screen } from '@testing-library/react'; +import { ComponentProps } from 'react'; import { MemoryRouter } from 'react-router-dom'; import { EntityType } from '../../../enums/entity.enum'; import { EntityReference } from '../../../generated/entity/type'; @@ -47,12 +48,30 @@ jest.mock('../../../utils/DomainUtils', () => ({ )), })); +jest.mock('../../../utils/DomainStyleUtils', () => ({ + getDomainReferenceBadgeStyle: jest + .fn() + .mockImplementation((domain) => + domain?.style?.color ? { borderColor: domain.style.color } : undefined + ), + getDomainReferenceIconColor: jest + .fn() + .mockImplementation( + (domain, fallbackColor) => domain?.style?.color ?? fallbackColor + ), + useDomainsWithStyle: jest.fn().mockImplementation((domains) => domains), +})); + jest.mock('../../../utils/ToastUtils', () => ({ showErrorToast: jest.fn(), })); jest.mock('../../../assets/svg/ic-domain.svg', () => ({ - ReactComponent: () =>
Domain Icon
, + ReactComponent: ({ color }: { color?: string }) => ( +
+ Domain Icon +
+ ), })); jest.mock('../../../assets/svg/ic-inherit.svg', () => ({ @@ -61,10 +80,16 @@ jest.mock('../../../assets/svg/ic-inherit.svg', () => ({ jest.mock('../DomainSelectableList/DomainSelectableList.component', () => ({ __esModule: true, - default: ({ onUpdate, selectedDomain }: any) => ( + default: ({ + onUpdate, + selectedDomain, + }: { + onUpdate?: (domain: EntityReference | EntityReference[]) => void; + selectedDomain?: EntityReference | EntityReference[]; + }) => ( ), @@ -100,10 +125,19 @@ const defaultProps = { entityId: 'test-id', }; -const renderDomainLabel = (props: any = {}) => +type DomainLabelTestProps = Partial< + Omit, 'domains'> +> & { + domains?: EntityReference[] | EntityReference; +}; + +const renderDomainLabel = (props: DomainLabelTestProps = {}) => render( - + )} + /> ); @@ -270,4 +304,25 @@ describe('DomainLabel Component', () => { expect(screen.getByTestId('no-domain-text')).toBeInTheDocument(); }); + + it('should apply domain color to the badge and icon when style is available', () => { + const styledDomain = { + ...mockDomain1, + style: { + color: '#7c3aed', + }, + } as EntityReference & { + style: { + color: string; + }; + }; + + renderDomainLabel({ domains: [styledDomain] }); + + expect( + screen.getByText('Domain One').closest('.domain-link-container') + ).toHaveStyle({ + borderColor: '#7c3aed', + }); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/atoms/table/useCellRenderer.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/atoms/table/useCellRenderer.tsx index 3bb2530879af..78c2847d99f4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/atoms/table/useCellRenderer.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/atoms/table/useCellRenderer.tsx @@ -11,14 +11,14 @@ * limitations under the License. */ -import { AvatarGroup, Box, Typography, useTheme } from '@mui/material'; +import { AvatarGroup, Box, Typography } from '@mui/material'; import { Avatar } from '@openmetadata/ui-core-components'; -import { Globe01 } from '@untitledui/icons'; import { ReactNode, useMemo } from 'react'; import { EntityType } from '../../../../enums/entity.enum'; import { EntityReference } from '../../../../generated/entity/type'; import { getEntityName } from '../../../../utils/EntityUtils'; import { getEntityAvatarProps } from '../../../../utils/IconUtils'; +import { DomainDisplay } from '../../DomainDisplay/DomainDisplay.component'; import { ProfilePicture } from '../ProfilePicture'; import { CellRenderer, ColumnConfig } from '../shared/types'; import TagsCell from './TagsCell'; @@ -37,7 +37,6 @@ export const useCellRenderer = < props: UseCellRendererProps ) => { const { renderers = {}, chipSize = 'large' } = props; - const theme = useTheme(); const defaultRenderers: CellRenderer = useMemo( () => ({ @@ -173,22 +172,10 @@ export const useCellRenderer = < ); } - const domain = domains[0]; - - return ( - - - - {domain.displayName || domain.name} - - - ); + return ; }, }), - [renderers, theme, chipSize] + [renderers, chipSize] ); const renderCell = useMemo( diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/DomainStyleUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/DomainStyleUtils.tsx new file mode 100644 index 000000000000..45d1a63906fa --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/DomainStyleUtils.tsx @@ -0,0 +1,218 @@ +/* + * Copyright 2026 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 { CSSProperties, useEffect, useState } from 'react'; +import { Style } from '../generated/entity/domains/domain'; +import { EntityReference } from '../generated/entity/type'; +import { getDomainByName } from '../rest/domainAPI'; + +export type StyledDomainReference = EntityReference & { + style?: Style; +}; + +type CachedDomainStyleEntry = { + expiresAt: number; + style: Style | null; +}; + +const DOMAIN_STYLE_CACHE_TTL_MS = 5 * 60 * 1000; + +const domainStyleCache = new Map(); +const domainStyleRequestCache = new Map>(); + +export const clearDomainStyleCache = () => { + domainStyleCache.clear(); + domainStyleRequestCache.clear(); +}; + +const getStyleFromDomainReference = ( + domain: EntityReference +): Style | undefined => (domain as StyledDomainReference).style; + +const getDomainSignature = (domain: EntityReference): string => + [ + domain.id ?? '', + domain.fullyQualifiedName ?? '', + domain.name ?? '', + domain.displayName ?? '', + domain.type ?? '', + domain.inherited ? '1' : '0', + getStyleFromDomainReference(domain)?.color ?? '', + ].join('::'); + +const pruneExpiredDomainStyleCache = (now = Date.now()) => { + for (const [domainFqn, cachedEntry] of domainStyleCache.entries()) { + if (cachedEntry.expiresAt <= now) { + domainStyleCache.delete(domainFqn); + } + } +}; + +const setCachedDomainStyle = (domainFqn: string, style: Style | null) => { + const now = Date.now(); + + pruneExpiredDomainStyleCache(now); + domainStyleCache.set(domainFqn, { + expiresAt: now + DOMAIN_STYLE_CACHE_TTL_MS, + style, + }); +}; + +const getCachedDomainStyle = ( + domainFqn: string +): CachedDomainStyleEntry | undefined => { + const cachedEntry = domainStyleCache.get(domainFqn); + + if (!cachedEntry) { + return undefined; + } + + if (cachedEntry.expiresAt <= Date.now()) { + domainStyleCache.delete(domainFqn); + + return undefined; + } + + return cachedEntry; +}; + +const getStyledDomainReference = ( + domain: EntityReference +): StyledDomainReference => { + const style = getStyleFromDomainReference(domain); + const domainFqn = domain.fullyQualifiedName; + + if (domainFqn && style !== undefined) { + setCachedDomainStyle(domainFqn, style); + + return domain as StyledDomainReference; + } + + const cachedEntry = domainFqn ? getCachedDomainStyle(domainFqn) : undefined; + + if (cachedEntry?.style) { + return { ...domain, style: cachedEntry.style }; + } + + return domain as StyledDomainReference; +}; + +const fetchDomainStyle = async (domainFqn: string) => { + const cachedEntry = getCachedDomainStyle(domainFqn); + + if (cachedEntry) { + return cachedEntry.style ?? undefined; + } + + const cachedRequest = domainStyleRequestCache.get(domainFqn); + + if (cachedRequest) { + return cachedRequest; + } + + const request = getDomainByName(domainFqn, { fields: 'style' }) + .then((domain) => { + const style = domain.style; + + setCachedDomainStyle(domainFqn, style ?? null); + + return style; + }) + .catch(() => { + setCachedDomainStyle(domainFqn, null); + + return undefined; + }) + .finally(() => { + domainStyleRequestCache.delete(domainFqn); + }); + + domainStyleRequestCache.set(domainFqn, request); + + return request; +}; + +export const useDomainsWithStyle = ( + domains?: EntityReference[] +): StyledDomainReference[] => { + const cacheWindow = Math.floor(Date.now() / DOMAIN_STYLE_CACHE_TTL_MS); + const domainsSignature = (domains ?? []).map(getDomainSignature).join('|'); + const [resolvedDomains, setResolvedDomains] = useState< + StyledDomainReference[] + >(() => (domains ?? []).map(getStyledDomainReference)); + + useEffect(() => { + const nextDomains = (domains ?? []).map(getStyledDomainReference); + setResolvedDomains(nextDomains); + + const missingDomainFqns = nextDomains + .map((domain) => domain.fullyQualifiedName) + .filter((domainFqn): domainFqn is string => { + if (!domainFqn) { + return false; + } + + return !getCachedDomainStyle(domainFqn); + }); + + if (missingDomainFqns.length === 0) { + return; + } + + let isCancelled = false; + + void Promise.all( + [...new Set(missingDomainFqns)].map(async (domainFqn) => [ + domainFqn, + await fetchDomainStyle(domainFqn), + ]) + ) + .then(() => { + if (isCancelled) { + return; + } + + setResolvedDomains((currentDomains) => + currentDomains.map(getStyledDomainReference) + ); + }) + .catch(() => undefined); + + return () => { + isCancelled = true; + }; + }, [cacheWindow, domainsSignature]); + + return resolvedDomains; +}; + +export const getDomainReferenceColor = ( + domain: EntityReference +): string | undefined => getStyleFromDomainReference(domain)?.color; + +export const getDomainReferenceIconColor = ( + domain: EntityReference, + fallbackColor: string +): string => getDomainReferenceColor(domain) ?? fallbackColor; + +export const getDomainReferenceBadgeStyle = ( + domain: EntityReference +): CSSProperties | undefined => { + const color = getDomainReferenceColor(domain); + + return color + ? { + borderColor: color, + boxShadow: `inset 3px 0 0 ${color}`, + } + : undefined; +};