diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DatabaseClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DatabaseClass.ts index 612c8226613..cb836d4fd22 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DatabaseClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DatabaseClass.ts @@ -14,7 +14,12 @@ import { APIRequestContext, expect, Page } from '@playwright/test'; import { Operation } from 'fast-json-patch'; import { SERVICE_TYPE } from '../../constant/service'; import { ServiceTypes } from '../../constant/settings'; -import { assignDomain, removeDomain, uuid } from '../../utils/common'; +import { + assignDomain, + removeDomain, + uuid, + verifyDomainLinkInCard, +} from '../../utils/common'; import { addMultiOwner, addOwner, @@ -250,17 +255,16 @@ export class DatabaseClass extends EntityClass { ).toBeVisible(); } - async verifyDomainChangeInES(page: Page, domain: Domain['responseData']) { - // Verify domain change in ES + async verifyDomainChangeInES(page: Page, domains: Domain['responseData'][]) { const searchTerm = this.tableResponseData?.['fullyQualifiedName']; await page.getByTestId('searchBox').fill(searchTerm); await page.getByTestId('searchBox').press('Enter'); - await expect( - page - .getByTestId(`table-data-card_${searchTerm}`) - .getByTestId('domains-link') - ).toContainText(domain.displayName); + const entityCard = page.getByTestId(`table-data-card_${searchTerm}`); + + for (const domain of domains) { + await verifyDomainLinkInCard(entityCard, domain); + } await page.getByTestId('searchBox').clear(); } @@ -272,7 +276,7 @@ export class DatabaseClass extends EntityClass { } async verifyDomainPropagation(page: Page, domain: Domain['responseData']) { - await this.verifyDomainChangeInES(page, domain); + await this.verifyDomainChangeInES(page, [domain]); await this.visitEntityPage(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 5c40e7c91f0..002107daef9 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts @@ -10,7 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Browser, expect, Page, request } from '@playwright/test'; +import { Browser, expect, Locator, Page, request } from '@playwright/test'; import { randomUUID } from 'crypto'; import { SidebarItem } from '../constant/sidebar'; import { adjectives, nouns } from '../constant/user'; @@ -411,6 +411,23 @@ export const generateRandomUsername = (prefix = '') => { }; }; +export const verifyDomainLinkInCard = async ( + entityCard: Locator, + domain: Domain['responseData'] +) => { + const domainLink = entityCard.getByTestId('domain-link').filter({ + hasText: domain.displayName, + }); + + await expect(domainLink).toBeVisible(); + await expect(domainLink).toContainText(domain.displayName); + + const href = await domainLink.getAttribute('href'); + + expect(href).toContain('/domain/'); + await expect(domainLink).toBeEnabled(); +}; + export const verifyDomainPropagation = async ( page: Page, domain: Domain['responseData'], @@ -418,12 +435,16 @@ export const verifyDomainPropagation = async ( ) => { await page.getByTestId('searchBox').fill(childFqnSearchTerm); await page.getByTestId('searchBox').press('Enter'); + await page.waitForSelector(`[data-testid*="table-data-card"]`); - await expect( - page - .getByTestId(`table-data-card_${childFqnSearchTerm}`) - .getByTestId('domains-link') - ).toContainText(domain.displayName); + const entityCard = page.getByTestId(`table-data-card_${childFqnSearchTerm}`); + + await expect(entityCard).toBeVisible(); + + const domainLink = entityCard.getByTestId('domain-link').first(); + + await expect(domainLink).toBeVisible(); + await expect(domainLink).toContainText(domain.displayName); }; export const replaceAllSpacialCharWith_ = (text: string) => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.test.tsx new file mode 100644 index 00000000000..9e58db555d8 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.test.tsx @@ -0,0 +1,110 @@ +/* + * Copyright 2025 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import searchClassBase from '../../../utils/SearchClassBase'; +import ExploreSearchCard from './ExploreSearchCard'; +import { ExploreSearchCardProps } from './ExploreSearchCard.interface'; + +jest.mock('../../../utils/RouterUtils', () => ({ + getDomainPath: jest.fn().mockReturnValue('/mock-domain'), +})); + +jest.mock('../../../utils/EntityUtils', () => ({ + getEntityName: jest.fn().mockReturnValue('Mock Entity'), + highlightSearchText: jest.fn().mockReturnValue(''), +})); + +jest.mock('../../../utils/SearchClassBase', () => ({ + __esModule: true, + default: { + getListOfEntitiesWithoutDomain: jest.fn(), + getListOfEntitiesWithoutTier: jest.fn().mockReturnValue([]), + getServiceIcon: jest.fn().mockReturnValue(service-icon), + getEntityBreadcrumbs: jest.fn().mockReturnValue([]), + getEntityIcon: jest.fn().mockReturnValue(entity-icon), + getEntityLink: jest.fn().mockReturnValue('/entity/test'), + getEntityName: jest.fn().mockReturnValue('Test Domain'), + getSearchEntityLinkTarget: jest.fn().mockReturnValue('_self'), + }, +})); + +jest.mock('../../common/DomainDisplay/DomainDisplay.component', () => ({ + DomainDisplay: jest + .fn() + .mockReturnValue(
Domain Display
), +})); + +const baseSource: ExploreSearchCardProps['source'] = { + id: 'base-1', + fullyQualifiedName: 'test.fqn', + name: 'test', + entityType: 'table', + tags: [], + owners: [], + domains: [], +}; + +const defaultProps: Omit = { + id: '1', + showEntityIcon: false, +}; + +const renderCard = ( + sourceOverrides: Partial +) => + render( + + + + ); + +describe('ExploreSearchCard - Domain section', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders DomainDisplay component', () => { + renderCard({ + domains: [{ id: '1', fullyQualifiedName: 'domain.test', type: 'domain' }], + }); + + expect(screen.getByTestId('domain-display')).toBeInTheDocument(); + }); + + it('renders empty Domain row when no domains exist and entity requires domain', () => { + ( + searchClassBase.getListOfEntitiesWithoutDomain as jest.Mock + ).mockReturnValue([]); + + renderCard({ domains: [] }); + + expect(screen.queryByTestId('domain-icon')).not.toBeInTheDocument(); + + expect(screen.getByTestId('Domain')).toBeInTheDocument(); + }); + + it('does not render Domain when entityType is excluded from domains', () => { + ( + searchClassBase.getListOfEntitiesWithoutDomain as jest.Mock + ).mockReturnValue(['table']); + + renderCard({ domains: [] }); + + expect(screen.queryByText('Domain')).not.toBeInTheDocument(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.tsx index e02b99fdfdd..d772743d333 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.tsx @@ -30,13 +30,13 @@ import { Table } from '../../../generated/entity/data/table'; import { EntityReference } from '../../../generated/entity/type'; import { TagLabel } from '../../../generated/tests/testCase'; import { AssetCertification } from '../../../generated/type/assetCertification'; -import { getEntityName, highlightSearchText } from '../../../utils/EntityUtils'; -import { getDomainPath } from '../../../utils/RouterUtils'; +import { highlightSearchText } from '../../../utils/EntityUtils'; import searchClassBase from '../../../utils/SearchClassBase'; import { stringToHTML } from '../../../utils/StringsUtils'; import { getUsagePercentile } from '../../../utils/TableUtils'; import { useRequiredParams } from '../../../utils/useRequiredParams'; import CertificationTag from '../../common/CertificationTag/CertificationTag'; +import { DomainDisplay } from '../../common/DomainDisplay/DomainDisplay.component'; import { OwnerLabel } from '../../common/OwnerLabel/OwnerLabel.component'; import TitleBreadcrumb from '../../common/TitleBreadcrumb/TitleBreadcrumb.component'; import TableDataCardBody from '../../Database/TableDataCardBody/TableDataCardBody'; @@ -84,14 +84,13 @@ const ExploreSearchCard: React.FC = forwardRef< ); const _otherDetails: ExtraInfo[] = [ - ...(source?.domains - ? source.domains.map((domain) => ({ - key: 'Domains', - value: getDomainPath(domain.fullyQualifiedName) ?? '', - placeholderText: getEntityName(domain), - isLink: true, - openInNewTab: false, - })) + ...(source?.domains && source.domains.length > 0 + ? [ + { + key: 'Domains', + value: , + }, + ] : !searchClassBase .getListOfEntitiesWithoutDomain() .includes(source?.entityType ?? '') 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 new file mode 100644 index 00000000000..f1a464dcb7e --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainDisplay/DomainDisplay.component.tsx @@ -0,0 +1,126 @@ +/* + * Copyright 2025 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Dropdown, Typography } from 'antd'; +import { Link } from 'react-router-dom'; +import { ReactComponent as DomainIcon } from '../../../assets/svg/ic-domain.svg'; +import { EntityReference } from '../../../generated/entity/type'; +import { getEntityName } from '../../../utils/EntityUtils'; +import { getDomainPath } from '../../../utils/RouterUtils'; + +interface DomainDisplayProps { + domains: EntityReference[]; + showIcon?: boolean; + className?: string; +} + +const DomainLink: React.FC<{ + domain: EntityReference; +}> = ({ domain }) => ( + <> + + + {getEntityName(domain)} + + + +); + +export const DomainDisplay = ({ + domains, + showIcon = true, + className = '', +}: DomainDisplayProps) => { + if (!domains || domains.length === 0) { + return null; + } + + if (domains.length > 1) { + const firstDomain = domains[0]; + const remainingDomains = domains.slice(1); + const remainingCount = remainingDomains.length; + + const dropdownItems = remainingDomains.map((domain, index) => ({ + key: index, + label: ( +
+ + + + {getEntityName(domain)} + + +
+ ), + })); + + return ( +
+ {showIcon && ( +
+ +
+ )} + +
+ + + + + + {`+${remainingCount}`} + + + +
+
+ ); + } + + return ( +
+ {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 new file mode 100644 index 00000000000..78987b4a202 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainDisplay/DomainDisplay.test.tsx @@ -0,0 +1,269 @@ +/* + * Copyright 2025 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { EntityReference } from '../../../generated/entity/type'; +import { DomainDisplay } from './DomainDisplay.component'; + +jest.mock('../../../utils/EntityUtils', () => ({ + getEntityName: jest + .fn() + .mockImplementation((entity) => entity?.name || 'Unknown'), +})); + +jest.mock('../../../utils/RouterUtils', () => ({ + getDomainPath: jest + .fn() + .mockImplementation((fqn: string) => `/domain/${fqn}`), +})); + +jest.mock('../../../assets/svg/ic-domain.svg', () => ({ + ReactComponent: () =>
Domain Icon
, +})); + +const mockDomain1: EntityReference = { + id: 'domain-1', + fullyQualifiedName: 'domain.one', + name: 'Domain One', + type: 'domain', +}; + +const mockDomain2: EntityReference = { + id: 'domain-2', + fullyQualifiedName: 'domain.two', + name: 'Domain Two', + type: 'domain', +}; + +const mockDomain3: EntityReference = { + id: 'domain-3', + fullyQualifiedName: 'domain.three', + name: 'Domain Three', + type: 'domain', +}; + +const renderDomainDisplay = (props: any) => + render( + + + + ); + +describe('DomainDisplay Component', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render nothing when domains array is empty', () => { + const { container } = renderDomainDisplay({ domains: [] }); + + expect(container.firstChild).toBeNull(); + }); + + it('should render nothing when domains is undefined', () => { + const { container } = renderDomainDisplay({ domains: undefined }); + + expect(container.firstChild).toBeNull(); + }); + + it('should render nothing when domains is null', () => { + const { container } = renderDomainDisplay({ domains: null }); + + expect(container.firstChild).toBeNull(); + }); + + it('should render single domain with icon by default', () => { + renderDomainDisplay({ domains: [mockDomain1] }); + + expect(screen.getByTestId('domain-icon')).toBeInTheDocument(); + expect(screen.getByText('Domain One')).toBeInTheDocument(); + expect(screen.getByRole('link')).toHaveAttribute( + 'href', + '/domain/domain.one' + ); + }); + + it('should render single domain without icon when showIcon is false', () => { + renderDomainDisplay({ domains: [mockDomain1], showIcon: false }); + + expect(screen.queryByTestId('domain-icon')).not.toBeInTheDocument(); + expect(screen.getByText('Domain One')).toBeInTheDocument(); + expect(screen.getByRole('link')).toHaveAttribute( + 'href', + '/domain/domain.one' + ); + }); + + it('should render multiple domains with dropdown by default', () => { + renderDomainDisplay({ + domains: [mockDomain1, mockDomain2, mockDomain3], + }); + + expect(screen.getByText('Domain One')).toBeInTheDocument(); + expect(screen.getByTestId('domain-count-button')).toBeInTheDocument(); + expect(screen.getByText('+2')).toBeInTheDocument(); + expect(screen.queryByText('Domain Two')).not.toBeInTheDocument(); + expect(screen.queryByText('Domain Three')).not.toBeInTheDocument(); + expect(screen.getAllByTestId('domain-icon')).toHaveLength(1); + }); + + it('should render single domain normally', () => { + renderDomainDisplay({ + domains: [mockDomain1], + }); + + expect(screen.getByText('Domain One')).toBeInTheDocument(); + expect(screen.queryByTestId('domain-count-button')).not.toBeInTheDocument(); + expect(screen.queryByText(', ')).not.toBeInTheDocument(); + }); + + it('should render correct links for all domains', () => { + renderDomainDisplay({ domains: [mockDomain1, mockDomain2] }); + + expect(screen.getByRole('link', { name: 'Domain One' })).toHaveAttribute( + 'href', + '/domain/domain.one' + ); + }); + + it('should handle domain with missing fullyQualifiedName', () => { + const domainWithoutFQN = { + ...mockDomain1, + fullyQualifiedName: undefined, + }; + + renderDomainDisplay({ domains: [domainWithoutFQN] }); + + expect(screen.getByRole('link')).toHaveAttribute( + 'href', + '/domain/undefined' + ); + }); + + it('should handle domain with missing name', () => { + const domainWithoutName = { + ...mockDomain1, + name: undefined, + }; + + renderDomainDisplay({ domains: [domainWithoutName] }); + + expect(screen.getByText('Unknown')).toBeInTheDocument(); + }); + + it('should have proper link accessibility', () => { + renderDomainDisplay({ domains: [mockDomain1] }); + + const link = screen.getByRole('link'); + + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute('href', '/domain/domain.one'); + }); + + it('should have proper test IDs for testing', () => { + renderDomainDisplay({ domains: [mockDomain1] }); + + expect(screen.getByTestId('domain-icon')).toBeInTheDocument(); + expect(screen.getByTestId('domain-link')).toBeInTheDocument(); + }); + + it('should style domain links correctly', () => { + renderDomainDisplay({ domains: [mockDomain1] }); + + const link = screen.getByRole('link'); + + expect(link).toHaveClass('no-underline'); + }); + + it('should style domain text correctly', () => { + renderDomainDisplay({ domains: [mockDomain1] }); + + const domainText = screen.getByText('Domain One'); + + expect(domainText).toHaveClass('text-sm', 'text-primary'); + }); + + it('should not render icon when showIcon is false', () => { + renderDomainDisplay({ domains: [mockDomain1], showIcon: false }); + + expect(screen.queryByTestId('domain-icon')).not.toBeInTheDocument(); + }); + + it('should render only one icon for multiple domains', () => { + renderDomainDisplay({ domains: [mockDomain1, mockDomain2, mockDomain3] }); + + const domainIcons = screen.getAllByTestId('domain-icon'); + + expect(domainIcons).toHaveLength(1); + }); + + it('should handle domain with empty name', () => { + const domainWithEmptyName = { + ...mockDomain1, + name: '', + }; + + renderDomainDisplay({ domains: [domainWithEmptyName] }); + + expect(screen.getByText('Unknown')).toBeInTheDocument(); + }); + + it('should handle domain with empty fullyQualifiedName', () => { + const domainWithEmptyFQN = { + ...mockDomain1, + fullyQualifiedName: '', + }; + + renderDomainDisplay({ domains: [domainWithEmptyFQN] }); + + expect(screen.getByRole('link')).toHaveAttribute('href', '/domain/'); + }); + + it('should handle mixed domain data (some with names, some without)', () => { + const domainsWithMixedData = [ + mockDomain1, + { ...mockDomain2, name: undefined }, + mockDomain3, + ]; + + renderDomainDisplay({ domains: domainsWithMixedData }); + + expect(screen.getByText('Domain One')).toBeInTheDocument(); + }); + + it('should show correct count in dropdown button', () => { + const manyDomains = [ + mockDomain1, + mockDomain2, + mockDomain3, + mockDomain1, + mockDomain2, + ]; + + renderDomainDisplay({ + domains: manyDomains, + }); + + expect(screen.getByText('+4')).toBeInTheDocument(); + }); + + it('should always use dropdown behavior for multiple domains', () => { + renderDomainDisplay({ domains: [mockDomain1, mockDomain2, mockDomain3] }); + + expect(screen.getByText('Domain One')).toBeInTheDocument(); + expect(screen.getByTestId('domain-count-button')).toBeInTheDocument(); + expect(screen.getByText('+2')).toBeInTheDocument(); + expect(screen.queryByText('Domain Two')).not.toBeInTheDocument(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DomainLabel/domain-label.less b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainLabel/domain-label.less index 6ac667dbbeb..b500a006b25 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/DomainLabel/domain-label.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainLabel/domain-label.less @@ -42,14 +42,22 @@ .domain-count-button { background-color: @primary-button-background; - height: 32px; - width: 32px; + height: auto; + width: auto; + padding: 0 4px; border-radius: 200px; text-align: center; text-decoration: none; + display: flex; + align-items: center; + justify-content: center; span { color: @blue-24; } border: 1px solid @blue-24; margin-left: -4px; } + +.domain-count-label { + font-size: 10px; +}