feat(homepage): add default platforms module on the homepage (#14942)

Co-authored-by: Chris Collins <chriscollins3456@gmail.com>
This commit is contained in:
purnimagarg1 2025-10-22 01:07:58 +05:30 committed by GitHub
parent d0bd2d50e4
commit 8f21a6c6ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 368 additions and 61 deletions

View File

@ -247,6 +247,10 @@ enum DataHubPageModuleType {
Module displaying the related terms of a given glossary term
"""
RELATED_TERMS
"""
Module displaying the platforms in the instance
"""
PLATFORMS
}
"""

View File

@ -5,12 +5,17 @@ import { CorpUser, DataPlatform, ScenarioType } from '@types';
export const PLATFORMS_MODULE_ID = 'Platforms';
const MAX_PLATFORMS_TO_FETCH = 10;
export type PlatformAndCount = {
platform: DataPlatform;
count: number;
};
export const useGetPlatforms = (user?: CorpUser | null): { platforms: PlatformAndCount[]; loading: boolean } => {
export const useGetPlatforms = (
user?: CorpUser | null,
maxToFetch?: number,
): { platforms: PlatformAndCount[]; loading: boolean } => {
const { localState } = useUserContext();
const { selectedViewUrn } = localState;
const { data, loading } = useListRecommendationsQuery({
@ -20,7 +25,7 @@ export const useGetPlatforms = (user?: CorpUser | null): { platforms: PlatformAn
requestContext: {
scenario: ScenarioType.Home,
},
limit: 10,
limit: maxToFetch || MAX_PLATFORMS_TO_FETCH,
viewUrn: selectedViewUrn,
},
},

View File

@ -14,6 +14,7 @@ import DocumentationModule from '@app/homeV3/modules/documentation/Documentation
import TopDomainsModule from '@app/homeV3/modules/domains/TopDomainsModule';
import HierarchyViewModule from '@app/homeV3/modules/hierarchyViewModule/HierarchyViewModule';
import LinkModule from '@app/homeV3/modules/link/LinkModule';
import PlatformsModule from '@app/homeV3/modules/platforms/PlatformsModule';
import { DataHubPageModuleType } from '@types';
@ -32,6 +33,7 @@ function Module(props: ModuleProps) {
if (module.properties.type === DataHubPageModuleType.ChildHierarchy) return ChildHierarchyModule;
if (module.properties.type === DataHubPageModuleType.DataProducts) return DataProductsModule;
if (module.properties.type === DataHubPageModuleType.RelatedTerms) return RelatedTermsModule;
if (module.properties.type === DataHubPageModuleType.Platforms) return PlatformsModule;
// TODO: remove the sample large module once we have other modules to fill this out
console.error(`Issue finding module with type ${module.properties.type}`);

View File

@ -23,6 +23,10 @@ interface Props {
hideSubtitle?: boolean;
hideMatches?: boolean;
padding?: string;
// For custom click action on entity (either entire container or just name depending on navigateOnlyOnNameClick)
customOnEntityClick?: (entity: Entity) => void;
// For custom hover action on entity name
customHoverEntityName?: (entity: Entity, children: React.ReactNode) => React.ReactNode;
}
export default function EntityItem({
@ -34,6 +38,8 @@ export default function EntityItem({
hideSubtitle,
hideMatches,
padding,
customOnEntityClick,
customHoverEntityName,
}: Props) {
const entityRegistry = useEntityRegistryV2();
const linkProps = useGetModalLinkProps();
@ -50,37 +56,43 @@ export default function EntityItem({
[entity.urn, moduleType, templateType],
);
const autoCompleteItemProps = {
entity,
key: entity.urn,
hideSubtitle,
hideMatches,
padding,
customDetailsRenderer,
dragIconRenderer,
customHoverEntityName,
navigateOnlyOnNameClick,
customOnEntityClick,
};
if (customOnEntityClick && !navigateOnlyOnNameClick) {
return (
<div
role="button"
tabIndex={0}
onClick={() => customOnEntityClick(entity)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
customOnEntityClick(entity);
}
}}
>
<AutoCompleteEntityItem {...autoCompleteItemProps} onClick={sendAnalytics} />
</div>
);
}
if (navigateOnlyOnNameClick) {
return <AutoCompleteEntityItem {...autoCompleteItemProps} onClick={sendAnalytics} />;
}
return (
<>
{navigateOnlyOnNameClick ? (
<AutoCompleteEntityItem
entity={entity}
key={entity.urn}
customDetailsRenderer={customDetailsRenderer}
hideSubtitle={hideSubtitle}
hideMatches={hideMatches}
padding={padding}
navigateOnlyOnNameClick
dragIconRenderer={dragIconRenderer}
onClick={sendAnalytics}
/>
) : (
<StyledLink
to={entityRegistry.getEntityUrl(entity.type, entity.urn)}
onClick={sendAnalytics}
{...linkProps}
>
<AutoCompleteEntityItem
entity={entity}
key={entity.urn}
hideSubtitle={hideSubtitle}
hideMatches={hideMatches}
padding={padding}
customDetailsRenderer={customDetailsRenderer}
dragIconRenderer={dragIconRenderer}
/>
</StyledLink>
)}
</>
<StyledLink to={entityRegistry.getEntityUrl(entity.type, entity.urn)} onClick={sendAnalytics} {...linkProps}>
<AutoCompleteEntityItem {...autoCompleteItemProps} />
</StyledLink>
);
}

View File

@ -33,6 +33,7 @@ export const DEFAULT_MODULE_URNS = [
'urn:li:dataHubPageModule:child_hierarchy',
'urn:li:dataHubPageModule:data_products',
'urn:li:dataHubPageModule:related_terms',
'urn:li:dataHubPageModule:platforms',
];
export const DEFAULT_TEMPLATE_URN = 'urn:li:dataHubPageTemplate:home_default_1';

View File

@ -0,0 +1,77 @@
import { Text, Tooltip } from '@components';
import React from 'react';
import { useUserContext } from '@app/context/useUserContext';
import { useGetPlatforms } from '@app/homeV2/content/tabs/discovery/sections/platform/useGetPlatforms';
import EmptyContent from '@app/homeV3/module/components/EmptyContent';
import EntityItem from '@app/homeV3/module/components/EntityItem';
import LargeModule from '@app/homeV3/module/components/LargeModule';
import { ModuleProps } from '@app/homeV3/module/types';
import usePlatformModuleUtils from '@app/homeV3/modules/platforms/usePlatformsModuleUtils';
import { formatNumber, formatNumberWithoutAbbreviation } from '@app/shared/formatNumber';
import { DataHubPageModuleType, Entity } from '@types';
const NUMBER_OF_PLATFORMS = 15;
const PlatformsModule = (props: ModuleProps) => {
const { user } = useUserContext();
const { platforms, loading } = useGetPlatforms(user, NUMBER_OF_PLATFORMS);
const { navigateToDataSources, handleEntityClick } = usePlatformModuleUtils();
const renderAssetCount = (entity: Entity) => {
const platformEntity = platforms.find((platform) => platform.platform.urn === entity.urn);
const assetCount = platformEntity?.count || 0;
return (
<>
{assetCount > 0 && (
<Text size="sm" color="gray">
{formatNumber(assetCount)}
</Text>
)}
</>
);
};
const renderCustomTooltip = (entity: Entity, children: React.ReactNode) => {
const platformEntity = platforms.find((platform) => platform.platform.urn === entity.urn);
return (
<Tooltip
title={`View ${formatNumberWithoutAbbreviation(platformEntity?.count)} ${platformEntity?.platform.name} assets`}
placement="bottom"
>
{children}
</Tooltip>
);
};
return (
<LargeModule {...props} loading={loading} dataTestId="platforms-module">
{platforms.length === 0 ? (
<EmptyContent
icon="Database"
title="No Platforms Yet"
description="You have not ingested any data."
linkText="Add data sources"
onLinkClick={navigateToDataSources}
/>
) : (
<div data-testid="platform-entities">
{platforms.map((platform) => (
<EntityItem
entity={platform.platform}
key={platform.platform.urn}
moduleType={DataHubPageModuleType.Platforms}
customDetailsRenderer={renderAssetCount}
customOnEntityClick={handleEntityClick}
customHoverEntityName={renderCustomTooltip}
/>
))}
</div>
)}
</LargeModule>
);
};
export default PlatformsModule;

View File

@ -0,0 +1,87 @@
import { act, renderHook } from '@testing-library/react-hooks';
import { type Mock, vi } from 'vitest';
import usePlatformsModuleUtils from '@app/homeV3/modules/platforms/usePlatformsModuleUtils';
import { PLATFORM_FILTER_NAME } from '@app/search/utils/constants';
import { navigateToSearchUrl } from '@app/searchV2/utils/navigateToSearchUrl';
import { PageRoutes } from '@conf/Global';
// Mocks
const mockPush = vi.fn();
const mockHistory = { push: mockPush };
vi.mock('react-router', () => ({
useHistory: () => mockHistory,
}));
vi.mock('@app/searchV2/utils/navigateToSearchUrl', () => ({
navigateToSearchUrl: vi.fn(),
}));
const entityMock = { urn: 'urn:li:entity', type: 'test-type' } as any;
describe('usePlatformsModuleUtils', () => {
beforeEach(() => {
mockPush.mockClear();
(navigateToSearchUrl as unknown as Mock).mockClear();
});
it('should provide navigateToDataSources', () => {
const { result } = renderHook(() => usePlatformsModuleUtils());
expect(result.current.navigateToDataSources).toBeInstanceOf(Function);
});
it('should provide handleEntityClick', () => {
const { result } = renderHook(() => usePlatformsModuleUtils());
expect(result.current.handleEntityClick).toBeInstanceOf(Function);
});
it('navigateToDataSources should call history.push with INGESTION route', () => {
const { result } = renderHook(() => usePlatformsModuleUtils());
act(() => {
result.current.navigateToDataSources();
});
expect(mockPush).toHaveBeenCalledWith({ pathname: PageRoutes.INGESTION });
});
it('handleEntityClick should call navigateToSearchUrl with correct params', () => {
const { result } = renderHook(() => usePlatformsModuleUtils());
act(() => {
result.current.handleEntityClick(entityMock);
});
expect(navigateToSearchUrl).toHaveBeenCalledWith({
history: mockHistory,
filters: [
{
field: PLATFORM_FILTER_NAME,
values: [entityMock.urn],
},
],
});
});
it('handleEntityClick should call navigateToSearchUrl with different entity', () => {
const anotherEntity = { urn: 'urn:li:other-entity', type: 'other-type' } as any;
const { result } = renderHook(() => usePlatformsModuleUtils());
act(() => {
result.current.handleEntityClick(anotherEntity);
});
expect(navigateToSearchUrl).toHaveBeenCalledWith({
history: mockHistory,
filters: [
{
field: PLATFORM_FILTER_NAME,
values: [anotherEntity.urn],
},
],
});
});
it('should not call navigateToSearchUrl if entity is undefined', () => {
const { result } = renderHook(() => usePlatformsModuleUtils());
act(() => {
result.current.handleEntityClick(undefined as any);
});
expect(navigateToSearchUrl).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,34 @@
import { useHistory } from 'react-router';
import { PLATFORM_FILTER_NAME } from '@app/search/utils/constants';
import { navigateToSearchUrl } from '@app/searchV2/utils/navigateToSearchUrl';
import { PageRoutes } from '@conf/Global';
import { Entity } from '@types';
const usePlatformsModuleUtils = () => {
const history = useHistory();
const navigateToDataSources = () => {
history.push({
pathname: `${PageRoutes.INGESTION}`,
});
};
const handleEntityClick = (entity: Entity) => {
if (!entity?.urn) return;
navigateToSearchUrl({
history,
filters: [
{
field: PLATFORM_FILTER_NAME,
values: [entity.urn],
},
],
});
};
return { navigateToDataSources, handleEntityClick };
};
export default usePlatformsModuleUtils;

View File

@ -155,11 +155,13 @@ describe('useAddModuleMenu', () => {
// Check "Default" group - HomePage should have yourAssets and domains
expect(items?.[1]).toHaveProperty('key', 'customLargeModulesGroup');
// @ts-expect-error SubMenuItem should have children
expect(items?.[1]?.children).toHaveLength(2);
expect(items?.[1]?.children).toHaveLength(3);
// @ts-expect-error SubMenuItem should have children
expect(items?.[1]?.children?.[0]).toHaveProperty('key', 'your-assets');
// @ts-expect-error SubMenuItem should have children
expect(items?.[1]?.children?.[1]).toHaveProperty('key', 'domains');
// @ts-expect-error SubMenuItem should have children
expect(items?.[1]?.children?.[2]).toHaveProperty('key', 'platforms');
});
it('should include admin created modules when available in global template for HomePage', () => {
@ -576,7 +578,7 @@ describe('useAddModuleMenu', () => {
// Default modules should be HomePage defaults (not entity-specific)
const defaultChildren = getChildren(result.current.items?.[1]);
expect(defaultChildren).toHaveLength(2);
expect(defaultChildren).toHaveLength(3);
expect(defaultChildren[0]).toHaveProperty('key', 'your-assets');
expect(defaultChildren[1]).toHaveProperty('key', 'domains');
});

View File

@ -38,6 +38,17 @@ const DOMAINS_MODULE: PageModuleFragment = {
},
};
const PLATFORMS_MODULE: PageModuleFragment = {
urn: 'urn:li:dataHubPageModule:platforms',
type: EntityType.DatahubPageModule,
properties: {
name: 'Platforms',
type: DataHubPageModuleType.Platforms,
visibility: { scope: PageModuleScope.Global },
params: {},
},
};
export const ASSETS_MODULE: PageModuleFragment = {
urn: 'urn:li:dataHubPageModule:assets',
type: EntityType.DatahubPageModule,
@ -221,6 +232,23 @@ export default function useAddModuleMenu(position: ModulePositionInput, closeMen
'data-testid': 'add-domains-module',
};
const platforms = {
name: 'Platforms',
key: 'platforms',
label: (
<MenuItem
description="Most used platforms in your organization"
title="Platforms"
icon="Database"
isSmallModule={false}
/>
),
onClick: () => {
handleAddExistingModule(PLATFORMS_MODULE);
},
'data-testid': 'add-platforms-module',
};
const assets = {
name: 'Assets',
key: 'assets',
@ -289,7 +317,7 @@ export default function useAddModuleMenu(position: ModulePositionInput, closeMen
'data-testid': 'add-related-terms-module',
};
const defaultHomeModules = [yourAssets, domains];
const defaultHomeModules = [yourAssets, domains, platforms];
// TODO: make this a function to pull out and write unit tests for
let defaultSummaryModules = [assets];
if (entityType === EntityType.Domain) {

View File

@ -115,6 +115,8 @@ interface EntityAutocompleteItemProps {
hideMatches?: boolean;
padding?: string;
onClick?: () => void;
customHoverEntityName?: (entity: Entity, children: React.ReactNode) => React.ReactNode;
customOnEntityClick?: (entity: Entity) => void;
dataTestId?: string;
}
@ -132,6 +134,8 @@ export default function AutoCompleteEntityItem({
hideMatches,
padding,
onClick,
customHoverEntityName,
customOnEntityClick,
dataTestId,
}: EntityAutocompleteItemProps) {
const theme = useTheme();
@ -146,27 +150,55 @@ export default function AutoCompleteEntityItem({
? DisplayNameHoverFromSelf
: DisplayNameHoverFromContainer;
const displayNameContent = variantProps?.nameCanBeHovered ? (
<Link to={entityRegistry.getEntityUrl(entity.type, entity.urn)} {...linkProps}>
<DisplayNameHoverComponent
let displayNameContent;
if (customOnEntityClick) {
displayNameContent = (
<div
role="button"
tabIndex={0}
onClick={() => customOnEntityClick(entity)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
customOnEntityClick(entity);
}
}}
>
<DisplayNameHoverComponent
displayName={displayName}
highlight={query}
color={variantProps?.nameColor}
colorLevel={variantProps?.nameColorLevel}
weight={variantProps?.nameWeight}
$decorationColor={getColor(variantProps?.nameColor, variantProps?.nameColorLevel, theme)}
/>
</div>
);
} else if (variantProps?.nameCanBeHovered) {
displayNameContent = (
<Link to={entityRegistry.getEntityUrl(entity.type, entity.urn)} {...linkProps}>
<DisplayNameHoverComponent
displayName={displayName}
highlight={query}
color={variantProps?.nameColor}
colorLevel={variantProps?.nameColorLevel}
weight={variantProps?.nameWeight}
$decorationColor={getColor(variantProps?.nameColor, variantProps?.nameColorLevel, theme)}
/>
</Link>
);
} else {
displayNameContent = (
<DisplayName
displayName={displayName}
highlight={query}
color={variantProps?.nameColor}
colorLevel={variantProps?.nameColorLevel}
weight={variantProps?.nameWeight}
$decorationColor={getColor(variantProps?.nameColor, variantProps?.nameColorLevel, theme)}
showNameTooltipIfTruncated
/>
</Link>
) : (
<DisplayName
displayName={displayName}
highlight={query}
color={variantProps?.nameColor}
colorLevel={variantProps?.nameColorLevel}
weight={variantProps?.nameWeight}
showNameTooltipIfTruncated
/>
);
);
}
return (
<Container
@ -190,14 +222,18 @@ export default function AutoCompleteEntityItem({
)}
<DescriptionContainer>
<HoverEntityTooltip
placement="bottom"
entity={entity}
showArrow={false}
canOpen={variantProps?.showEntityPopover}
>
<DisplayNameWrapper>{displayNameContent}</DisplayNameWrapper>
</HoverEntityTooltip>
{customHoverEntityName ? (
customHoverEntityName(entity, <DisplayNameWrapper>{displayNameContent}</DisplayNameWrapper>)
) : (
<HoverEntityTooltip
placement="bottom"
entity={entity}
showArrow={false}
canOpen={variantProps?.showEntityPopover}
>
<DisplayNameWrapper>{displayNameContent}</DisplayNameWrapper>
</HoverEntityTooltip>
)}
{!hideSubtitle && (
<EntitySubtitle

View File

@ -44,4 +44,9 @@ enum DataHubPageModuleType {
* Module displaying the related terms of a given glossary term
*/
RELATED_TERMS
/**
* Module displaying the platforms in an instance
*/
PLATFORMS
}

View File

@ -44,7 +44,7 @@ bootstrap:
mcps_location: "bootstrap_mcps/roles.yaml"
- name: page-modules
version: v5
version: v6
blocking: true
async: false
mcps_location: "bootstrap_mcps/page-modules.yaml"

View File

@ -72,4 +72,16 @@
scope: GLOBAL
params: {}
created: {{&auditStamp}}
lastModified: {{&auditStamp}}
- entityUrn: urn:li:dataHubPageModule:platforms
entityType: dataHubPageModule
aspectName: dataHubPageModuleProperties
changeType: UPSERT
aspect:
name: "Platforms"
type: PLATFORMS
visibility:
scope: GLOBAL
params: {}
created: {{&auditStamp}}
lastModified: {{&auditStamp}}

View File

@ -11,6 +11,7 @@
- "urn:li:dataHubPageModule:your_assets"
- modules:
- "urn:li:dataHubPageModule:top_domains"
- "urn:li:dataHubPageModule:platforms"
surface:
surfaceType: HOME_PAGE
visibility:

View File

@ -41,7 +41,8 @@ public class PageModuleService {
"urn:li:dataHubPageModule:assets",
"urn:li:dataHubPageModule:child_hierarchy",
"urn:li:dataHubPageModule:data_products",
"urn:li:dataHubPageModule:related_terms");
"urn:li:dataHubPageModule:related_terms",
"urn:li:dataHubPageModule:platforms");
public PageModuleService(@Nonnull EntityClient entityClient) {
this.entityClient = entityClient;