feat: add a domain icon and drop-down for multiple domain names (#23299)

* feat: add domain icon and comma-separated domain names

* fix integration test id

* Revamp domain render on search card

* fix unit test unused props

* fix integration test

* nit

* fix minor unused props

* Fix all failing integration test

* nit

* Fix domain propagation test

* Change font size for domain count

* fix overflow count number

---------

Co-authored-by: Anujkumar Yadav <anujkumaryadav@Anujkumars-MacBook-Pro.local>
This commit is contained in:
Anujkumar Yadav 2025-09-11 15:35:08 +05:30 committed by GitHub
parent 9fd34c8f89
commit 76c4e371a9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 564 additions and 27 deletions

View File

@ -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);
}

View File

@ -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) => {

View File

@ -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(<span>service-icon</span>),
getEntityBreadcrumbs: jest.fn().mockReturnValue([]),
getEntityIcon: jest.fn().mockReturnValue(<span>entity-icon</span>),
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(<div data-testid="domain-display">Domain Display</div>),
}));
const baseSource: ExploreSearchCardProps['source'] = {
id: 'base-1',
fullyQualifiedName: 'test.fqn',
name: 'test',
entityType: 'table',
tags: [],
owners: [],
domains: [],
};
const defaultProps: Omit<ExploreSearchCardProps, 'source'> = {
id: '1',
showEntityIcon: false,
};
const renderCard = (
sourceOverrides: Partial<ExploreSearchCardProps['source']>
) =>
render(
<MemoryRouter>
<ExploreSearchCard
{...defaultProps}
source={{ ...baseSource, ...sourceOverrides }}
/>
</MemoryRouter>
);
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();
});
});

View File

@ -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<ExploreSearchCardProps> = forwardRef<
);
const _otherDetails: ExtraInfo[] = [
...(source?.domains
? source.domains.map((domain) => ({
...(source?.domains && source.domains.length > 0
? [
{
key: 'Domains',
value: getDomainPath(domain.fullyQualifiedName) ?? '',
placeholderText: getEntityName(domain),
isLink: true,
openInNewTab: false,
}))
value: <DomainDisplay domains={source.domains} />,
},
]
: !searchClassBase
.getListOfEntitiesWithoutDomain()
.includes(source?.entityType ?? '')

View File

@ -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 }) => (
<>
<Link
className="no-underline"
data-testid="domain-link"
to={getDomainPath(domain.fullyQualifiedName) ?? ''}>
<Typography.Text className="text-sm text-primary">
{getEntityName(domain)}
</Typography.Text>
</Link>
</>
);
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: (
<div className="d-flex items-center gap-2">
<DomainIcon height={14} name="domain" width={14} />
<Link
className="no-underline"
to={getDomainPath(domain.fullyQualifiedName) ?? ''}>
<Typography.Text className="text-sm text-primary">
{getEntityName(domain)}
</Typography.Text>
</Link>
</div>
),
}));
return (
<div className={`d-flex items-center gap-2 ${className}`}>
{showIcon && (
<div className="d-flex">
<DomainIcon
data-testid="domain-icon"
height={18}
name="domain"
width={18}
/>
</div>
)}
<div className="d-flex items-center gap-2">
<DomainLink domain={firstDomain} />
<Dropdown
menu={{
items: dropdownItems,
className: 'domain-tooltip-list',
}}
trigger={['hover']}>
<Typography.Text
className={`flex-center cursor-pointer align-middle ant-typography-secondary domain-count-button ${
remainingCount <= 9 ? 'h-6 w-6' : ''
}`}
data-testid="domain-count-button">
<span className="ant-typography domain-count-label">
{`+${remainingCount}`}
</span>
</Typography.Text>
</Dropdown>
</div>
</div>
);
}
return (
<div className={`d-flex items-center gap-2 ${className}`}>
{showIcon && (
<div className="d-flex">
<DomainIcon
data-testid="domain-icon"
height={18}
name="domain"
width={18}
/>
</div>
)}
<div className="d-flex items-center gap-1">
<DomainLink domain={domains[0]} />
</div>
</div>
);
};

View File

@ -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: () => <div data-testid="domain-icon">Domain Icon</div>,
}));
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(
<MemoryRouter>
<DomainDisplay {...props} />
</MemoryRouter>
);
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();
});
});

View File

@ -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;
}